Complete DevOps Bootcamp: Master DevOps in 12 Weeks
FastAPIAuthentication and Authorization

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 ─┘
SegmentHoldsExample contents
HeaderThe algorithm used to sign{"alg": "HS256", "typ": "JWT"}
PayloadClaims about the user{"sub": "alice", "exp": 1700000000}
SignatureHMAC or RSA proof the payload wasn't tampered withbinary 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

ClaimFull nameWhat it means
subSubjectWho the token is about (user id, email, etc.)
expExpiryUnix timestamp after which the token is no longer valid
iatIssued atWhen it was created
nbfNot beforeEarliest time the token may be used
issIssuerWhich server minted it
audAudienceWhich service it's meant for
jtiJWT IDUnique 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.

StorageProsCons
localStorageEasy in JS, survives reloadsReadable by any XSS payload
sessionStorageCleared on tab closeSame XSS risk while open
In-memory onlyXSS still bad but token lost on reloadForces refresh on every reload
HttpOnly cookieNot visible to JS, automatic on requestsNeed 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 HS256 with a weak secret. Treat the secret like a database password.
  • Forgetting to validate exp. The pyjwt library 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 pyjwt or python-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