A JSON Web Token (JWT) is a compact, URL-safe string that encodes a signed JSON payload. It has three dot-separated Base64URL-encoded parts:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← Header
.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxN} ← Payload (claims)
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← Signature
- Header: algorithm and token type (
{"alg": "HS256", "typ": "JWT"}). - Payload: claims — standard (
sub,exp,iat) and custom (role,scopes). - Signature: HMAC or RSA/EC signature over header + payload.
JWTs are not encrypted by default — the payload is only Base64-encoded. Anyone can decode it. The signature only proves it wasn't tampered with.
Rule of thumb: never put sensitive data (passwords, PII) in JWT payload — use JWE (encrypted JWT) if you need to protect the payload contents.
Install python-jose[cryptography] and use jose.jwt:
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
SECRET_KEY = "your-256-bit-secret"
ALGORITHM = "HS256"
def create_access_token(user_id: int, expires_minutes: int = 30) -> str:
payload = {
"sub": str(user_id),
"exp": datetime.now(timezone.utc) + timedelta(minutes=expires_minutes),
"iat": datetime.now(timezone.utc),
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise HTTPException(401, "Invalid token")
Rule of thumb: always pass algorithms= as a list (not a string) to jwt.decode() —
the list prevents algorithm-confusion attacks where an attacker switches from RS256 to HS256.
The exp claim is a Unix timestamp. python-jose automatically validates it
during jwt.decode() — expired tokens raise ExpiredSignatureError (a subclass
of JWTError).
from jose import ExpiredSignatureError, JWTError
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
except ExpiredSignatureError:
raise HTTPException(401, "Token has expired")
except JWTError:
raise HTTPException(401, "Invalid token")
return payload
Clock skew: python-jose allows a small tolerance via options={"leeway": 10} (10 seconds).
Rule of thumb: catch ExpiredSignatureError separately from other JWTError subtypes
so you can give clients a specific "please refresh" error instead of a generic "invalid token".
| Claim | Name | Meaning |
|---|---|---|
sub |
Subject | Who the token is about (user ID) |
exp |
Expiration | Unix timestamp when token expires |
iat |
Issued At | Unix timestamp when token was created |
nbf |
Not Before | Token not valid before this timestamp |
jti |
JWT ID | Unique ID for revocation |
iss |
Issuer | Who created the token |
aud |
Audience | Who the token is intended for |
payload = {
"sub": str(user.id),
"exp": utcnow + timedelta(minutes=30),
"iat": utcnow,
"jti": str(uuid4()), # for revocation
"iss": "https://myapi.example.com",
}
Rule of thumb: always set sub, exp, and iat; add jti if you need
token revocation; add iss/aud for multi-service architectures.
| HS256 (HMAC) | RS256 (RSA) | |
|---|---|---|
| Key type | Shared secret | Public/private key pair |
| Who can sign | Anyone with the secret | Only the private key holder |
| Who can verify | Anyone with the secret | Anyone with the public key |
| Use case | Single service | Multiple services / external clients |
# HS256 — single service
jwt.encode(payload, "shared-secret", algorithm="HS256")
jwt.decode(token, "shared-secret", algorithms=["HS256"])
# RS256 — multi-service (sign with private, verify with public)
jwt.encode(payload, private_key, algorithm="RS256")
jwt.decode(token, public_key, algorithms=["RS256"])
With RS256, you can publish the public key (JWKS endpoint) so other services verify tokens without ever seeing the private key.
Rule of thumb: use HS256 for simple single-service auth; use RS256 when tokens need to be verified by external services or published via a JWKS endpoint.
If a service uses RS256 but jwt.decode() is called with algorithms=["RS256", "HS256"],
an attacker can forge a token by taking the RS256 public key (which is public),
using it as an HMAC secret, and signing a new token with HS256. The server
accepts it because HS256 is allowed.
Prevention:
# SAFE — only allow the expected algorithm
jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"]) # never include HS256
# DANGEROUS
jwt.decode(token, PUBLIC_KEY, algorithms=["RS256", "HS256"])
Also: never use algorithms=None or skip algorithm validation.
Rule of thumb: always pin the algorithm to exactly one value matching what you use to sign — allow-listing multiple algorithms invites confusion attacks.
Early JWT libraries accepted tokens with "alg": "none" — meaning no signature
is needed. An attacker could forge any payload, set alg: none, and the library
would accept it without any verification.
# Malicious token — unsigned
{"alg": "none", "typ": "JWT"}.{"sub": "admin"}.
Modern libraries (python-jose, PyJWT) reject none by default. Ensure
"none" is not in your algorithms= list:
jwt.decode(token, SECRET, algorithms=["HS256"]) # "none" not listed → rejected
Rule of thumb: never include "none" in algorithms=; if your library has a
verify_signature=False option, never use it in production.
| Storage | XSS risk | CSRF risk | Notes |
|---|---|---|---|
localStorage |
High | None | JS-accessible — token stolen by XSS |
sessionStorage |
High | None | Same as localStorage but tab-scoped |
httpOnly cookie |
None | Medium | Inaccessible to JS; add SameSite=Lax |
Best practice — httpOnly, Secure, SameSite=Lax cookie:
response.set_cookie(
key="access_token",
value=token,
httponly=True,
secure=True,
samesite="lax",
max_age=1800,
)
SameSite=Lax blocks cross-site POST requests (CSRF for state-changing operations)
while allowing cross-site GET navigation.
Rule of thumb: store tokens in httpOnly cookies, not localStorage — XSS is
more common than CSRF, and httpOnly provides a meaningful defence against it.
Refresh token rotation issues a new refresh token on every use and immediately invalidates the old one. If a stolen refresh token is used, the legitimate user's next request will fail (old token invalid) — alerting you to a compromise.
@app.post("/refresh")
async def refresh(old_token: str = Body(...)):
payload = decode_refresh_token(old_token)
jti = payload["jti"]
# Check old token exists and isn't already used
stored = await db.get_refresh_token(jti)
if not stored or stored.used:
# Token reuse detected — revoke all user tokens
await db.revoke_all_tokens(payload["sub"])
raise HTTPException(401, "Refresh token reuse detected")
await db.mark_used(jti)
new_refresh = create_refresh_token(payload["sub"])
new_access = create_access_token(payload["sub"])
await db.store_refresh_token(new_refresh)
return {"access_token": new_access, "refresh_token": new_refresh}
Rule of thumb: implement refresh token rotation for any app with sensitive data — a detected reuse should trigger automatic revocation of all user sessions.
Pass options={"verify_signature": False} to jwt.decode() in python-jose,
or use PyJWT with algorithms=[]:
# python-jose
payload = jwt.decode(
token,
key="",
algorithms=["HS256"],
options={"verify_signature": False},
)
This is never safe for authenticating users. Valid uses:
- Logging the
subfrom an already-verified token that expired (for debugging). - Inspecting headers to decide which key/algorithm to use before re-decoding with verification.
Rule of thumb: never use unverified JWT decoding in the auth path — it is only for tooling and debugging flows where the token has already been verified elsewhere.
JWTs have no spec-defined size limit, but practical limits exist:
- HTTP headers: servers typically cap individual headers at 8 KB.
- Cookies: limited to 4 KB per cookie.
- Typical JWT: a token with 5-10 claims is 200-500 bytes after Base64URL encoding.
Problems arise when you embed large payloads (roles list, user profile) in the token:
# BAD — embedding permissions in token can push it over 4 KB
payload = {"sub": "1", "permissions": list_of_200_permissions}
# GOOD — store just the user ID; look up permissions from DB/cache
payload = {"sub": "1", "exp": ..., "role": "admin"}
Rule of thumb: keep JWT payloads under 1 KB — store only stable identity claims
(sub, role, scopes); look up volatile permissions from cache at request time.
More Security & Auth interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.