JWT Access and Refresh Tokens
A JWT is a small, signed, self-describing token. The whole identity of a request can live inside it, which means the server does not need to look anything up in a database just to know who is calling. That sounds magical, but it comes with trade-offs that are worth understanding before you commit to using JWTs in earnest.
Anatomy of a JWT
Take a token apart and you find three base64 segments separated by dots.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSIsImV4cCI6MTcwMDAwMH0.signature
└── header ──┘ └────── payload ──────┘ └─ signature ─┘| Segment | Holds | Example contents |
|---|---|---|
| Header | The algorithm used to sign | {"alg": "HS256", "typ": "JWT"} |
| Payload | Claims about the user | {"sub": "alice", "exp": 1700000000} |
| Signature | HMAC or RSA proof the payload wasn't tampered with | binary bytes |
The first two parts are not encrypted - they're just base64. Anyone with the token can read them. The signature is what stops people from changing the payload and getting away with it.
Standard claims you should know
| Claim | Full name | What it means |
|---|---|---|
sub | Subject | Who the token is about (user id, email, etc.) |
exp | Expiry | Unix timestamp after which the token is no longer valid |
iat | Issued at | When it was created |
nbf | Not before | Earliest time the token may be used |
iss | Issuer | Which server minted it |
aud | Audience | Which service it's meant for |
jti | JWT ID | Unique id, useful for revocation lists |
You don't have to use all of them. sub and exp are the bare minimum for an access token.
Why two tokens?
A single long-lived token is convenient but dangerous. If it leaks, the attacker has free run for as long as the token is valid. A single short-lived token is safer but annoying - users would get logged out every fifteen minutes.
The compromise: hand out two tokens.
┌──────────────┐
Login ──► │ Access token │ expires in ~15-30 min
│ Refresh token│ expires in days or weeks
└──────────────┘
Every API call uses the access token.
When it expires:
client sends refresh token to /auth/refresh
server returns a fresh access token
(optionally rotates the refresh token too)The access token is what gets sent on every request, so it should be short-lived. The refresh token is sent only to the refresh endpoint, so it can live longer.
Issuing both at login
Continuing from the OAuth2 doc, the login endpoint can return both tokens:
from datetime import datetime, timedelta, timezone
import jwt
ACCESS_MINUTES = 15
REFRESH_DAYS = 7
def _build_token(subject: str, expires_delta: timedelta, token_type: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": subject,
"iat": now,
"exp": now + expires_delta,
"type": token_type,
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def create_access_token(subject: str) -> str:
return _build_token(subject, timedelta(minutes=ACCESS_MINUTES), "access")
def create_refresh_token(subject: str) -> str:
return _build_token(subject, timedelta(days=REFRESH_DAYS), "refresh")The custom type claim is not a standard, but it stops a refresh token from being accepted as an access token by mistake.
@router.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == form.username).first()
if user is None or not verify_password(form.password, user.hashed_password):
raise HTTPException(401, "Incorrect email or password")
return {
"access_token": create_access_token(user.email),
"refresh_token": create_refresh_token(user.email),
"token_type": "bearer",
}The refresh endpoint
from pydantic import BaseModel
from jwt import InvalidTokenError
class RefreshRequest(BaseModel):
refresh_token: str
@router.post("/refresh")
def refresh(payload: RefreshRequest):
try:
data = jwt.decode(payload.refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
except InvalidTokenError:
raise HTTPException(401, "Invalid refresh token")
if data.get("type") != "refresh":
raise HTTPException(401, "Wrong token type")
new_access = create_access_token(data["sub"])
return {"access_token": new_access, "token_type": "bearer"}Two checks beyond the signature: the token is a valid JWT, and it claims to be a refresh token. Skipping the type check is a classic bug - an access token would also be accepted, defeating the point.
What JWT does not solve
This is where teams get burned. JWTs feel like a complete auth solution, but a few hard problems remain.
JWTs can't be revoked without help
Once you issue a JWT, the server has no idea who holds it. A logout button on the client just deletes the token locally. Until the token's exp passes, anyone who has stolen it can still use it.
To get real revocation you need state on the server: either a denylist of revoked jti values, or a per-user "tokens issued before this timestamp are invalid" field. At that point you have given up some of the stateless appeal of JWTs.
For most apps, the pragmatic answer is: keep access tokens short (15 minutes), and accept that a stolen access token has a small window of damage. Refresh token rotation - issuing a new refresh token every time one is used, and revoking the old one - closes the longer-lived hole.
Storage on the client
This part is not FastAPI's problem but it shapes how secure the whole system actually is.
| Storage | Pros | Cons |
|---|---|---|
localStorage | Easy in JS, survives reloads | Readable by any XSS payload |
sessionStorage | Cleared on tab close | Same XSS risk while open |
| In-memory only | XSS still bad but token lost on reload | Forces refresh on every reload |
HttpOnly cookie | Not visible to JS, automatic on requests | Need CSRF protection |
The current recommendation from most security folks is HttpOnly, Secure, SameSite=Strict cookies for browser apps, and the platform keychain (iOS Keychain, Android Keystore) for native apps. Your backend doesn't change much - it just sets a cookie instead of returning a JSON token.
Common mistakes worth listing
- Using
HS256with a weak secret. Treat the secret like a database password. - Forgetting to validate
exp. Thepyjwtlibrary does this automatically; libraries in other languages sometimes don't. - Sticking sensitive data in the payload "because it's signed." Signed is not encrypted. Anyone with the token sees the payload.
- Using one token type for everything. Two short-lived tokens beat one long-lived one.
- Building your own JWT library. Use
pyjwtorpython-jose. Cryptography is unforgiving.
In one line
A JWT is a signed envelope carrying claims about a user. Pair a short-lived access token with a longer-lived refresh token, validate both honestly, and remember that JWTs trade revocability for statelessness - that trade is good for most APIs but worth making with your eyes open.
How is this guide?
Last updated on
