JWT Access and Refresh Tokens
26 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)The last lesson used random tokens that the server had to look up. A JWT carries the user and the expiry inside itself, signed so nobody can change it. This lesson shows how to make it, check it, expire it and renew it safely.
A signed token the server does not need to store
JWT (JSON Web Token) is a signed text with a header, a claims part and a signature. Anyone can read the claims, because they are only encoded, not encrypted. Only the key holder can create a valid signature.
A JWT is a text with three parts joined by dots: a header, the claims and a signature. The server signs it with a secret key. On the next request it checks the signature. If anyone edits the claims, the signature no longer matches.
import jwt
SECRET = "demo-secret-change-me"token = jwt.encode({"sub": "asha", "exp": 4102444800}, SECRET, algorithm="HS256")claims = jwt.decode(token, SECRET, algorithms=["HS256"])print(claims["sub"])Example 1 · The parts of a token
import jwt
SECRET = "demo-secret-change-me"claims = {"sub": "asha", "role": "learner", "exp": 4102444800}token = jwt.encode(claims, SECRET, algorithm="HS256")
print(token.count("."))print(jwt.get_unverified_header(token))print(jwt.decode(token, SECRET, algorithms=["HS256"]))print(jwt.decode(token, options={"verify_signature": False})["role"])2
{'alg': 'HS256', 'typ': 'JWT'}
{'sub': 'asha', 'role': 'learner', 'exp': 4102444800}
learnerWhat this code does
- The token has two dots, so three parts. The header names the algorithm.
- The claims can be read without the key (
verify_signatureoff), which proves they are not secret. Never trust claims read this way.
jwt is the PyJWT package. Always pass algorithms=[...] when decoding, so an attacker cannot pick a weaker algorithm in the header. The secret comes from an environment variable in real code.
Claims and expiry
Claim is one named fact stored in a token, such as sub (who), exp (when it stops working) or type (access or refresh). Never put passwords or secrets in claims.
Claims are the facts inside the token. sub is the subject (the user id), exp is the expiry time in seconds since 1970, and you can add your own such as type. PyJWT checks exp for you and raises an error when the token is too old.
import jwt
old = jwt.encode({"sub": "asha", "exp": 1000000000}, SECRET, algorithm="HS256")try: jwt.decode(old, SECRET, algorithms=["HS256"])except jwt.ExpiredSignatureError: print("expired")Example 2 · Expired, forged and unsigned tokens
import jwt
SECRET = "demo-secret-change-me"good = jwt.encode({"sub": "asha", "exp": 4102444800}, SECRET, algorithm="HS256")old = jwt.encode({"sub": "asha", "exp": 1000000000}, SECRET, algorithm="HS256")forged = jwt.encode({"sub": "admin", "exp": 4102444800}, "attacker-key", algorithm="HS256")unsigned = jwt.encode({"sub": "admin"}, None, algorithm="none")
for label, token in [("good", good), ("expired", old), ("forged", forged), ("unsigned", unsigned)]: try: jwt.decode(token, SECRET, algorithms=["HS256"]) print(label, "accepted") except jwt.PyJWTError as err: print(label, type(err).__name__)good accepted expired ExpiredSignatureError forged InvalidSignatureError unsigned InvalidAlgorithmError
What this code does
- Only the good token is accepted. Each failure has its own PyJWT error, all of them subclasses of
PyJWTError. - The unsigned token (
alg: none) is refused becausealgorithms=["HS256"]does not allow it.
In real code, set exp to the current UTC time plus the lifetime: int(time.time()) + 900. Catch jwt.PyJWTError once to cover expired, tampered and broken tokens.
Access token and refresh token
Refresh token is a long-lived token whose only job is to get a new access token. It is sent to one route only. If it leaks, the attacker has a long window, so it must be rotated and revocable.
A stolen token works until it expires. So the main token must live a short time, for example 15 minutes. But asking the user to log in every 15 minutes is annoying. A second, longer-lived refresh token is used only to get new access tokens.
def login(user): return {"access_token": make_token(user, "access", 15 * 60), "refresh_token": make_token(user, "refresh", 7 * 24 * 3600)}
def refresh(refresh_token): claims = decode(refresh_token, kind="refresh") return claimsExample 3 · Access and refresh tokens in an app
import secrets, timeimport jwtfrom typing import Annotatedfrom fastapi import Depends, FastAPI, HTTPExceptionfrom fastapi.security import OAuth2PasswordBearerfrom fastapi.testclient import TestClientfrom pydantic import BaseModel
SECRET = "demo-secret-change-me"ALGO = "HS256"ACTIVE = {}
def make_token(sub, kind, seconds, jti=None): claims = {"sub": sub, "type": kind, "exp": int(time.time()) + seconds} if jti: claims["jti"] = jti return jwt.encode(claims, SECRET, algorithm=ALGO)
def decode(token, kind): try: claims = jwt.decode(token, SECRET, algorithms=[ALGO]) except jwt.PyJWTError: raise HTTPException(401, "Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}) if claims.get("type") != kind: raise HTTPException(401, "Wrong token type", headers={"WWW-Authenticate": "Bearer"}) return claims
app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="login")
class Login(BaseModel): username: str
class Refresh(BaseModel): refresh_token: str
def issue(sub): jti = secrets.token_hex(8) ACTIVE[jti] = sub return {"access_token": make_token(sub, "access", 15 * 60), "refresh_token": make_token(sub, "refresh", 7 * 24 * 3600, jti)}
@app.post("/login")def login(body: Login): return issue(body.username) # the password check is left out here
@app.get("/me")def me(token: Annotated[str, Depends(oauth2)]): return {"user": decode(token, "access")["sub"]}
client = TestClient(app)tokens = client.post("/login", json={"username": "asha"}).json()print(sorted(tokens))
access = {"Authorization": "Bearer " + tokens["access_token"]}refresh = {"Authorization": "Bearer " + tokens["refresh_token"]}print(client.get("/me", headers=access).json())r = client.get("/me", headers=refresh)print(r.status_code, r.json()["detail"])['access_token', 'refresh_token']
{'user': 'asha'}
401 Wrong token typeWhat this code does
- Login returns both tokens.
/meaccepts the access token and rejects the refresh token, because thetypeclaim is wrong. - Tokens use the real clock for
exp, so only stable parts are printed.
The type claim stops a long-lived refresh token from being used as an access token. Check it in both places.
Rotation, revocation and storage
Rotation means every refresh gives a new refresh token and kills the old one. If an old refresh token is used again, someone copied it, so you revoke the whole session. This needs a small store of valid token ids (jti) on the server.
def refresh(refresh_token): claims = decode(refresh_token, kind="refresh") if claims["jti"] not in ACTIVE: revoke_all(claims["sub"]) # reuse detected raise HTTPException(401, "Refresh token reused") ACTIVE.pop(claims["jti"]) # old one dies return issue_tokens(claims["sub"])Example 4 · Refresh rotation and reuse detection
import secrets, timeimport jwtfrom typing import Annotatedfrom fastapi import Depends, FastAPI, HTTPExceptionfrom fastapi.security import OAuth2PasswordBearerfrom fastapi.testclient import TestClientfrom pydantic import BaseModel
SECRET = "demo-secret-change-me"ALGO = "HS256"ACTIVE = {}
def make_token(sub, kind, seconds, jti=None): claims = {"sub": sub, "type": kind, "exp": int(time.time()) + seconds} if jti: claims["jti"] = jti return jwt.encode(claims, SECRET, algorithm=ALGO)
def decode(token, kind): try: claims = jwt.decode(token, SECRET, algorithms=[ALGO]) except jwt.PyJWTError: raise HTTPException(401, "Invalid or expired token", headers={"WWW-Authenticate": "Bearer"}) if claims.get("type") != kind: raise HTTPException(401, "Wrong token type", headers={"WWW-Authenticate": "Bearer"}) return claims
app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="login")
class Login(BaseModel): username: str
class Refresh(BaseModel): refresh_token: str
def issue(sub): jti = secrets.token_hex(8) ACTIVE[jti] = sub return {"access_token": make_token(sub, "access", 15 * 60), "refresh_token": make_token(sub, "refresh", 7 * 24 * 3600, jti)}
@app.post("/login")def login(body: Login): return issue(body.username) # the password check is left out here
@app.get("/me")def me(token: Annotated[str, Depends(oauth2)]): return {"user": decode(token, "access")["sub"]}
@app.post("/refresh")def refresh(body: Refresh): claims = decode(body.refresh_token, "refresh") if claims["jti"] not in ACTIVE: for jti in [j for j, s in ACTIVE.items() if s == claims["sub"]]: del ACTIVE[jti] raise HTTPException(401, "Refresh token reused: session revoked") del ACTIVE[claims["jti"]] return issue(claims["sub"])
client = TestClient(app)
first = client.post("/login", json={"username": "asha"}).json()second = client.post("/refresh", json={"refresh_token": first["refresh_token"]})print("refresh once:", second.status_code)
again = client.post("/refresh", json={"refresh_token": first["refresh_token"]})print("reuse old:", again.status_code, again.json()["detail"])
third = client.post("/refresh", json={"refresh_token": second.json()["refresh_token"]})print("newest after reuse:", third.status_code)refresh once: 200 reuse old: 401 Refresh token reused: session revoked newest after reuse: 401
What this code does
- Each refresh kills the old id and issues a new pair.
- Using the old refresh token again means it was copied. The server revokes the whole session, so even the newest token stops working.
For browsers, keep the refresh token in an HttpOnly, Secure, SameSite cookie, which page scripts cannot read. Keep the access token in memory. Avoid localStorage for long-lived tokens, because any injected script can read it.
Next step
Check your understanding
12 quick questions