Skip to content
Home Python FastAPI Authentication — JWT and OAuth2 with Password Flow

FastAPI Authentication — JWT and OAuth2 with Password Flow

Where developers are forged. · Structured learning · Free forever.
📍 Part of: Python Libraries → Topic 41 of 51
Master JWT authentication in FastAPI.
⚙️ Intermediate — basic Python knowledge assumed
In this tutorial, you'll learn
Master JWT authentication in FastAPI.
  • OAuth2PasswordBearer is a built-in FastAPI provider that extracts the token from the Authorization: Bearer header automatically — you declare it once, and every dependency that uses it gets the token without any manual header parsing.
  • The 'sub' (subject) claim in the JWT payload should be an immutable user identifier — use a UUID rather than a username or email, which can change and cause lookup failures on tokens issued before the update.
  • Always use Annotated for dependency declarations in modern FastAPI — it provides accurate editor type hints, cleaner function signatures, and is the pattern the FastAPI documentation recommends from version 0.95 onwards.
✦ Plain-English analogy ✦ Real code with output ✦ Interview questions
Quick Answer
  • FastAPI uses OAuth2PasswordBearer to extract the Bearer token from the Authorization header automatically
  • The /token endpoint exchanges username + password for a signed JWT containing a 'sub' claim and expiry
  • jwt.decode() with python-jose verifies the signature and expiration — tampered or expired tokens raise JWTError
  • Protect any route by adding Depends(get_current_user) — unauthenticated requests get 401 before logic runs
  • A weak SECRET_KEY lets attackers forge valid tokens for any user — use 256+ bits of entropy from env vars
  • Stateless JWTs cannot be revoked — implement a Redis-backed blacklist or short-lived tokens + refresh rotation
🚨 START HERE
JWT Authentication Quick Debug Reference
Rapid diagnostics for JWT and authentication issues in production FastAPI services
🟡jwt.exceptions.ExpiredSignatureError appearing in production logs
Immediate ActionConfirm server clock is synchronised via NTP — clock drift is the most common cause of premature expiry errors that do not reproduce locally
Commands
date -u && ntpq -p
python3 -c "from jose import jwt; print(jwt.decode('<TOKEN>', '<KEY>', algorithms=['HS256']))"
Fix NowSynchronise the clock with ntpdate pool.ntp.org or chronyc makestep — do not increase token expiry as a workaround, that masks the actual problem
🟡401 on every request immediately after SECRET_KEY rotation
Immediate ActionVerify the new key is loaded by all running workers — a rolling restart with the old key still in memory on some pods will cause split-brain 401s
Commands
curl -s http://localhost:8000/openapi.json | python3 -m json.tool | head -5
docker exec <container> env | grep SECRET_KEY
Fix NowForce a full restart of all Uvicorn workers — not a rolling restart — so every process loads the new key simultaneously. Warn users that all sessions will be invalidated.
🟡Token is accepted and returns 200 but the user data in the response is wrong or belongs to a different user
Immediate ActionDecode the token payload and verify the 'sub' claim matches what you expect — the bug is in what was encoded at login time, not in the validation
Commands
python3 -c "from jose import jwt; import json; h,p,s='<TOKEN>'.split('.'); import base64; print(json.loads(base64.b64decode(p+'==').decode()))"
curl -s -H 'Authorization: Bearer <TOKEN>' http://localhost:8000/users/me
Fix NowCheck the create_access_token call in the /token endpoint — confirm data={'sub': user.id} is using the correct identifier field and not a mutable value like username
🟡Swagger UI /docs does not show the lock icon or the Authorize popup
Immediate ActionThe tokenUrl in OAuth2PasswordBearer does not match the actual path of your login endpoint — Swagger uses this to wire up the auth flow
Commands
curl -s http://localhost:8000/openapi.json | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('components',{}).get('securitySchemes',{}))"
grep -n 'tokenUrl' io/thecodeforge/auth/config.py
Fix NowEnsure tokenUrl in OAuth2PasswordBearer matches the actual @app.post path exactly — if your app has a prefix, tokenUrl must include it (e.g., tokenUrl='/api/v1/token' not tokenUrl='token')
Production IncidentThe Forged Admin Token: How a Leaked SECRET_KEY Compromised Every User SessionA production API accepted forged JWTs for 6 hours because the SECRET_KEY was committed to a public GitHub repository during a config migration. An attacker crafted tokens with admin privileges and exfiltrated 2.3 million user records before the breach was detected.
SymptomUnusual data export volumes flagged by database monitoring — 2.3 million SELECT queries against the users table over 6 hours, all from a single API session. No corresponding legitimate user activity in audit logs. The queries were paced slowly enough to stay under rate limit thresholds, which is what delayed detection. The first signal came from the database team noticing sustained high read IOPS on a table that normally sits idle outside business hours.
AssumptionThe team was confident the SECRET_KEY lived only in environment variables. During a Kubernetes config migration the previous month, a developer had temporarily committed a .env file containing the live key to a public repository to unblock a deployment. The commit was deleted within hours, but Git history is permanent and the key had already been indexed by automated secret-scanning bots. It remained exploitable for 11 days before the incident.
Root causeThe SECRET_KEY value was discovered in a public GitHub commit via a secret-scanning bot. The attacker used it to construct JWTs with arbitrary 'sub' claims — including admin usernames — and 'exp' timestamps years in the future. Because JWT validation checks only the signature and expiry, the forged tokens were indistinguishable from legitimate ones. No JTI claim existed, no blacklist existed, and all previously issued tokens remained valid even after the team suspected a problem. The attacker had a six-hour window of silent, verified access before the key was rotated.
FixRotated the SECRET_KEY immediately — this invalidated all existing tokens simultaneously, which caused a brief forced re-login for all active users, but that was the right trade-off. Moved all secrets to AWS Secrets Manager with IAM role-based access — no human can retrieve the raw key value. Added pre-commit hooks using detect-secrets and gitleaks to the repository to block future credential commits at the source. Implemented a JTI-based token blacklist in Redis with TTL matching token expiry. Added a monitoring alert for any single session generating more than 1,000 database queries within 10 minutes.
Key Lesson
SECRET_KEY must never touch version control in any form — use a secrets manager and inject at runtime via environment variables that are never loggedRotating the SECRET_KEY invalidates all existing tokens instantly — this is your nuclear option for a key compromise and must be practiced before you need it under pressureA token blacklist with JTI claims is the only mechanism for revoking specific tokens before their natural expiry — without it, logout is theatreMonitor for anomalous query patterns at the session level — a single authenticated session reading millions of rows is a stronger breach signal than failed login attempts
Production Debug GuideFrom symptom to resolution for common JWT authentication issues in production FastAPI services
All users get 401 Unauthorized immediately after a deploymentThe SECRET_KEY almost certainly changed between deployments. jwt.decode() rejects any token signed with a different key — there is no graceful fallback. Verify with: python3 -c "from jose import jwt; print(jwt.decode('<token>', '<new_key>', algorithms=['HS256']))". If it throws, the key changed. Check your deployment environment variables against the key used to sign the existing tokens. Rolling deployments that mix old and new keys will cause intermittent 401s until the old pods drain.
Token works perfectly in development but fails in production with ExpiredSignatureErrorClock skew between the token-issuing server and the validating server. jwt.decode() compares the 'exp' claim against the server's current UTC time — if the validating server's clock is 2 minutes ahead, a token with a 2-minute remaining lifetime fails immediately. Run 'date -u' on both systems and compare. The fix is NTP synchronisation, not increasing token expiry as a workaround.
Intermittent 401 errors under high load — works fine for some requests, fails for othersMultiple Uvicorn workers are likely using different SECRET_KEY values. This happens when the key is generated dynamically at import time (e.g., SECRET_KEY = secrets.token_hex(32) at module level) rather than loaded from an environment variable. Each worker process generates a different key on startup, so tokens signed by worker A are rejected by worker B. Load SECRET_KEY from os.getenv() and confirm the value is identical across all workers.
Users report being logged out after exactly 15 minutes regardless of the configured expiryThe create_access_token function has a 15-minute fallback when expires_delta is None. The /token endpoint is not passing ACCESS_TOKEN_EXPIRE_MINUTES as expires_delta. Check the token creation call — if timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) is not being constructed and passed explicitly, the fallback fires every time. This is a silent bug because it does not raise any error.
WWW-Authenticate header is missing from 401 responsesThe HTTPException is being raised without headers={"WWW-Authenticate": "Bearer"}. Without this header, Swagger UI cannot trigger the auth popup, browser-based OAuth2 clients cannot detect the authentication challenge, and RFC 6750 compliance is broken. Check every raise HTTPException(status_code=401) in your auth dependencies and ensure the header is included on all of them — a single missing instance will confuse clients that rely on it.

The Authentication Backbone: JWT Configuration

Before handling a single request, you need the security foundation in place. SECRET_KEY, ALGORITHM, token expiry, the password hashing context, and the OAuth2 scheme — these are the five constants that everything else builds on.

In a real production service, none of these live in source code. The SECRET_KEY in particular must come from an environment variable backed by a secrets manager — AWS Secrets Manager, HashiCorp Vault, or GCP Secret Manager. The value in the example below is a placeholder that is intentionally identical to the one in the FastAPI documentation, which means it has been seen by hundreds of thousands of people and must never be used in production.

The ALGORITHM choice matters more than most tutorials acknowledge. HS256 is symmetric — the same SECRET_KEY signs and verifies tokens. This works well for a single service or a small cluster where every node can share the secret securely. RS256 is asymmetric — a private key signs tokens and a public key verifies them. This is the right choice for distributed architectures where multiple services need to validate tokens but should not be able to issue them. If you are building a platform where third-party services will validate your tokens, RS256 is not optional.

The CryptContext with bcrypt handles password hashing. The 'deprecated=auto' setting tells passlib to automatically re-hash passwords using older schemes if it encounters them — useful when migrating from a weaker algorithm. The default bcrypt work factor is 12, which means each hash takes roughly 300ms to compute on modern hardware. That is intentional — it makes offline brute-force attacks computationally expensive enough to be impractical.

io/thecodeforge/auth/config.py · PYTHON
123456789101112131415161718192021222324252627
import os
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext

# Never hardcode this value — load from secrets manager at runtime
# Generate a safe key with: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY = os.getenv("SECRET_KEY", "")
if not SECRET_KEY:
    raise RuntimeError("SECRET_KEY environment variable is not set — refusing to start")

# HS256 for single-service; RS256 for distributed systems where multiple services verify tokens
ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")

# Keep access tokens short — 15-30 minutes limits the damage window if a token is stolen
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))

# bcrypt with deprecated=auto handles algorithm migrations gracefully
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# tokenUrl tells Swagger UI where to send login requests for the Authorize popup
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

app = FastAPI()
▶ Output
Configuration loaded. SECRET_KEY validated. Bcrypt hashing context ready.
Mental Model
JWT as a Self-Contained ID Card
A JWT is structurally similar to a government-issued ID card — it contains your identity claims and a holographic seal (the signature) that anyone can verify without calling the issuing authority. The key insight is that the payload is readable by anyone who intercepts the token, but only someone with the SECRET_KEY can produce a valid seal.
  • Header = the card type (algorithm used) — tells verifiers how to check the seal, not a secret
  • Payload = your identity claims (sub, exp, roles, jti) — base64-encoded, not encrypted, readable by anyone
  • Signature = the holographic seal — HMAC of header + payload using SECRET_KEY, proves the token was not tampered with
  • Anyone can READ the payload by base64-decoding it — never put passwords, PII, or secrets in JWT claims
  • Only someone with the SECRET_KEY can PRODUCE a valid signature — this is the entire security model, which is why key protection is non-negotiable
📊 Production Insight
A SECRET_KEY in version control is the single most common cause of JWT-based breaches — not weak algorithms, not missing expiry, just a key that ended up in a commit.
Once an attacker has the key, they can forge tokens for any user, with any claims, and any expiry, with no detectable difference from legitimate tokens.
Rule: load SECRET_KEY from a secrets manager at startup, validate it is non-empty before the app accepts traffic, and rotate it on any suspected exposure.
🎯 Key Takeaway
SECRET_KEY is the root of trust for your entire authentication system — if it leaks, every token ever issued is compromised.
HS256 is symmetric (one key signs and verifies); RS256 is asymmetric (private key signs, public key verifies) — choose based on your architecture, not convenience.
bcrypt's deliberate slowness is a feature: 300ms per hash makes brute-force attacks impractical at scale.

Token Generation and Login Flow

The /token endpoint is the gateway into your authentication system. It receives credentials via OAuth2PasswordRequestForm, verifies them against the database, and returns a signed JWT. Getting this endpoint right means everything downstream is trustworthy. Getting it wrong means downstream correctness is irrelevant.

The 'sub' (subject) claim is the standard JWT field for the user identifier. Using a UUID rather than a username is a deliberate choice — usernames can change, email addresses can be updated, but a UUID assigned at account creation is permanent. If you use a mutable identifier as the 'sub' and a user changes their username, tokens issued before the change still decode to the old username, causing lookup failures that are genuinely confusing to debug.

The 'exp' claim is a Unix timestamp that jwt.decode() validates automatically against the server's current UTC time. Expired tokens raise ExpiredSignatureError, which your error handler should catch and convert to a 401. The important nuance here is datetime.now(timezone.utc) — using datetime.now() without timezone awareness creates naive datetimes that behave unpredictably across environments and cause token expiry to vary based on server timezone configuration. Always use timezone.utc.

The 15-minute fallback in create_access_token is intentional defensive programming — if someone calls the function without an explicit expires_delta, the token expires quickly rather than living forever. But in the /token endpoint, always pass the expiry explicitly. Relying on the fallback in production leads to user experience issues that generate support tickets rather than error logs.

io/thecodeforge/auth/token_logic.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import jwt

def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
    """
    Create a signed JWT access token.
    Always pass expires_delta explicitly in production — the 15-minute
    fallback exists for defensive safety, not as a default you should rely on.
    """
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)


@app.post("/token")
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()]
):
    """
    Exchange credentials for a JWT access token.
    In production, replace the hardcoded check with a database lookup
    and use pwd_context.verify() to compare the submitted password
    against the stored bcrypt hash — never compare plaintext passwords.
    """
    # Replace this with: user = await get_user_from_db(form_data.username)
    # Then: if not user or not pwd_context.verify(form_data.password, user.hashed_password)
    if form_data.username != "forge_dev" or form_data.password != "secret_pass":
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )

    access_token = create_access_token(
        data={"sub": form_data.username},
        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
    )
    return {"access_token": access_token, "token_type": "bearer"}
▶ Output
{"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "bearer"}
⚠ Token Expiry Is Your Damage Limiter
A stolen JWT is valid until it expires — there is no built-in revocation mechanism in the JWT standard. A 30-minute expiry limits the attacker's window to 30 minutes of access per stolen token. For longer sessions without frequent re-authentication, implement refresh tokens: issue a short-lived access_token (15-30 minutes) paired with a long-lived refresh_token (7 days, stored server-side). The refresh token can be revoked. Rotate refresh tokens on each exchange so that reuse of a consumed token is detectable and triggers full session revocation.
📊 Production Insight
The 15-minute fallback in create_access_token fires silently when expires_delta is not passed — no error, no warning, just tokens that expire faster than expected.
Users get logged out every 15 minutes and file support tickets, and the root cause is invisible in logs because it is not an error.
Rule: always construct timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) explicitly at the call site in the /token endpoint — never rely on the fallback in production code.
🎯 Key Takeaway
Use UUIDs as the 'sub' claim, not usernames or emails — mutable identifiers cause token-to-user lookup failures after profile updates.
Always use datetime.now(timezone.utc) for expiry calculations — naive datetimes produce inconsistent expiry behaviour across environments.
Refresh tokens enable long-lived sessions without long-lived access tokens — store them server-side so they can be revoked, unlike stateless JWTs.

Protecting Routes with Dependency Injection

Securing a route in FastAPI is one function declaration and one parameter. That simplicity is deceptive — under the hood, FastAPI's dependency injection system runs get_current_user before the route handler, resolves its return value, and injects it as a typed parameter. If get_current_user raises an exception, the route handler never executes. Not partially, not with degraded access — it does not execute at all.

This pattern eliminates the most common class of authentication bugs: the forgotten check. In frameworks where auth is middleware or an optional decorator, it is possible to add a new route and forget to apply the auth check. In FastAPI with Depends(), the route is either public by omission (no dependency declared) or protected by inclusion. That is still a footgun, but it is a more visible one — the lack of a dependency in the function signature is detectable by a linter in a way that a missing middleware call is not.

The get_current_user dependency extracts the token via oauth2_scheme (which reads the Authorization: Bearer header automatically), decodes it with jwt.decode(), and validates the 'sub' claim. The critical detail is the generic credentials_exception defined once at the top of the function — this ensures that whether the token is missing, expired, tampered, or missing the 'sub' claim, the client receives the same response. Giving different error messages for different failure modes (e.g., 'token expired' vs 'invalid signature') leaks information that helps attackers probe your validation logic.

For role-based access control, chain an additional dependency on top of get_current_user. A require_admin dependency calls Depends(get_current_user) internally and then checks the user's role field. This keeps the auth and authz layers separated — get_current_user answers 'who is this', require_admin answers 'can this person do that'.

io/thecodeforge/auth/dependencies.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
from typing import Annotated
from fastapi import Depends, HTTPException, status
from jose import JWTError, jwt

async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
    """
    Core authentication dependency. Inject this into any route that requires
    a valid, non-expired JWT.

    Returns the user dict on success. Raises 401 on any validation failure.
    The error message is intentionally generic — specific failure reasons
    help attackers probe your validation logic.
    """
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str | None = payload.get("sub")
        if username is None:
            raise credentials_exception
    except JWTError:
        # Catches ExpiredSignatureError, DecodeError, InvalidSignatureError
        # All map to the same 401 response — do not differentiate for clients
        raise credentials_exception

    # In production, fetch the full user from the database here
    # user = await db.users.find_one({"username": username})
    # if user is None: raise credentials_exception
    return {"username": username, "active": True}


async def require_admin(
    current_user: Annotated[dict, Depends(get_current_user)]
):
    """
    Role-based access dependency — chains on get_current_user.
    Use this for admin-only endpoints instead of duplicating the role check.
    """
    if current_user.get("role") != "admin":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Insufficient privileges",
        )
    return current_user


@app.get("/users/me")
async def read_users_me(
    current_user: Annotated[dict, Depends(get_current_user)]
):
    return current_user


@app.get("/admin/dashboard")
async def admin_dashboard(
    current_user: Annotated[dict, Depends(require_admin)]
):
    return {"message": "Welcome to the admin dashboard", "user": current_user}
▶ Output
{"username": "forge_dev", "active": true}
Mental Model
Depends() as a Bouncer at Every Door
Depends(get_current_user) is a bouncer stationed permanently at each protected route — it runs before the door opens, and if the credential check fails, the request never reaches your business logic. The bouncer cannot be bypassed by calling the route directly, and you cannot accidentally forget to station the bouncer if you make the dependency explicit in the function signature.
  • FastAPI resolves and runs the dependency before the route handler — the route function does not execute if auth fails
  • One get_current_user function protects unlimited routes — no per-endpoint auth boilerplate
  • The dependency returns the verified user object — your route handler receives an authenticated identity, not a raw token string
  • A missing Depends() on a route makes it silently public — add a CI lint check that flags undecorated route handlers
  • Chain dependencies for RBAC: require_admin calls Depends(get_current_user) internally, keeping auth and authz cleanly separated
📊 Production Insight
A route without Depends(get_current_user) is silently public — no error during development, no warning at startup, no log entry when unauthenticated requests come in.
This is how internal admin endpoints end up publicly exposed: someone adds a quick endpoint during development and the auth dependency is 'todo'.
Rule: add a CI check that flags route handlers in protected routers without an explicit authentication dependency — treat it like a missing test, not a style issue.
🎯 Key Takeaway
get_current_user is your single enforcement point — one function, consistently applied, protects every route that declares it.
Do not differentiate error messages for different JWT failure modes — generic 401 responses give attackers less to work with.
Chain RBAC dependencies on top of get_current_user rather than duplicating role checks inside route handlers — keep authentication and authorisation as separate concerns.

Token Revocation and Blacklisting Strategies

The most common misconception about JWTs in production is that logout invalidates the token. It does not. Deleting the token from the client's localStorage or cookie jar prevents the client from sending it, but the token itself remains cryptographically valid until its expiry. If an attacker captured it before logout, they can continue using it.

In production, three scenarios demand server-side revocation: user-initiated logout (should invalidate the current token immediately), password change (should invalidate all previously issued tokens — a compromised account's tokens should stop working the moment the password is reset), and credential compromise (stolen device, leaked token — immediate revocation regardless of expiry).

The standard mechanism is the JTI (JWT ID) claim — a unique identifier generated per token at issuance. Include it as the 'jti' claim, and when revocation is needed, add the JTI to a Redis SET with a TTL matching the token's remaining lifetime. On each authenticated request, check whether the JTI exists in the blacklist before accepting the token. This adds approximately 1ms of latency per request — a Redis GET on a local or regional Redis instance. That is the cost of revocability.

The TTL on the Redis key is important. Setting it to the token's remaining expiry (not the full token lifetime) means the blacklist entry disappears automatically when the token would have expired anyway. You are not storing revoked JTIs forever — just long enough for the token to have naturally expired. This keeps the Redis memory footprint bounded and eliminates the need for a separate cleanup job.

For password changes and account compromise, you want to revoke all tokens for a user simultaneously without tracking every individual JTI. A token version counter in the database handles this: include the user's current token_version as a claim when issuing tokens. On each validation, compare the token's version claim against the database value. To revoke all tokens, increment the database counter — every existing token now carries a stale version number and fails validation. This is more efficient than blacklisting individual JTIs for bulk revocation scenarios.

io/thecodeforge/auth/token_revocation.py · PYTHON
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
import uuid
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import Depends, HTTPException, status
from jose import JWTError, jwt
import redis.asyncio as redis

redis_client = redis.from_url(
    os.getenv("REDIS_URL", "redis://localhost:6379"),
    decode_responses=True
)


def create_access_token_with_jti(
    data: dict,
    expires_delta: timedelta | None = None
) -> tuple[str, str]:
    """
    Issues a JWT with a unique JTI claim for individual revocation.
    Returns (encoded_token, jti) — store the JTI if you need to revoke later.
    """
    to_encode = data.copy()
    jti = str(uuid.uuid4())
    expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=30))
    to_encode.update({"jti": jti, "exp": expire})
    token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return token, jti


async def revoke_token(jti: str, ttl_seconds: int) -> None:
    """
    Add a token's JTI to the Redis blacklist.
    TTL should match the token's remaining lifetime — not the full token duration.
    This keeps the blacklist bounded without a separate cleanup job.
    """
    await redis_client.setex(f"blacklist:{jti}", ttl_seconds, "revoked")


async def is_token_revoked(jti: str) -> bool:
    """Returns True if the JTI is in the blacklist."""
    return await redis_client.exists(f"blacklist:{jti}") == 1


async def get_current_user_with_blacklist(
    token: Annotated[str, Depends(oauth2_scheme)]
):
    """
    Authentication dependency with JTI blacklist check.
    Drop-in replacement for get_current_user when revocation is required.
    Adds ~1ms Redis GET per request — acceptable cost for immediate revocability.
    """
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        jti: str | None = payload.get("jti")
        username: str | None = payload.get("sub")
        if username is None or jti is None:
            raise credentials_exception
        if await is_token_revoked(jti):
            raise credentials_exception
    except JWTError:
        raise credentials_exception
    return {"username": username, "jti": jti}
▶ Output
Token validated with blacklist check. Revoked tokens are rejected immediately regardless of expiry.
⚠ Stateless JWTs Cannot Be Revoked Without a Blacklist
Without a blacklist, logout is a client-side illusion. The client deletes the token; the server has no idea and will accept it until expiry. This is the most persistent misconception in JWT authentication: that because JWT is a standard, logout must work. It does not — not without a server-side revocation mechanism. If you ship a production auth system without a blacklist or token versioning, you are shipping a system where 'logout' does nothing from a security standpoint. A 30-minute token expiry limits the damage window, but it does not close it.
📊 Production Insight
Redis blacklist lookups add roughly 1ms per authenticated request — this is the price of revocability and it is worth paying.
Without a blacklist, 'logout' only clears the client's copy of the token while the server continues to honour it.
Rule: every production JWT system needs either JTI blacklisting for per-token revocation or a token_version counter for per-user bulk revocation — preferably both.
🎯 Key Takeaway
JWTs are stateless — the server validates them using only the signature and expiry, with no record of what was issued.
JTI claim plus Redis blacklist provides per-token revocation with bounded memory overhead and approximately 1ms latency cost.
For password change and credential compromise scenarios, a token_version counter in the database revokes all user tokens in a single database write.

Refresh Token Rotation for Long-Lived Sessions

Short-lived access tokens protect your API, but they create a real UX problem: users must re-authenticate every 15-30 minutes, which is unacceptable for any application where users expect to stay logged in. Refresh tokens solve this without compromising the security properties of short-lived access tokens.

The mechanics are straightforward: at login, issue two tokens — a short-lived access_token (15-30 minutes, stateless JWT) and a long-lived refresh_token (7-30 days, opaque random string). The access_token is used for API requests. The refresh_token is stored by the client and sent to a dedicated /refresh endpoint when the access_token expires. The server validates the refresh token, issues a fresh access_token, and the user continues seamlessly.

The security-critical property is rotation. Each time a refresh token is used, it is marked as consumed and a new refresh token is issued alongside the new access token. If an attacker steals a refresh token and uses it before the legitimate user does, the legitimate user's next refresh attempt fails — the token was already consumed. That failure is the detection signal. Your system should respond by revoking all tokens for that user and forcing re-authentication, because you now know a token was stolen.

Refresh tokens must be stored server-side. Unlike JWTs, they cannot be validated from their content alone — they need a database or Redis lookup to confirm they exist, have not been consumed, and have not expired. Store a hash of the refresh token (not the raw value) in the database, just as you would store a password hash. If the database is compromised, raw refresh tokens are not exposed.

One operational detail that is easy to miss: the refresh token exchange must be atomic. Reading the token record, marking it as used, and issuing the new token must happen in a single transaction or with a compare-and-swap operation. A race condition where two simultaneous refresh requests both succeed on the same token before either is marked as used defeats the reuse detection entirely.

io/thecodeforge/auth/refresh_tokens.py · PYTHON
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import HTTPException, status
from jose import jwt

REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))

# In production, replace with async database operations
# Store hashed refresh tokens: hashlib.sha256(token.encode()).hexdigest()
# Never store the raw token value — treat it like a password
refresh_token_store: dict[str, dict] = {}


def create_refresh_token(user_id: str) -> str:
    """
    Generate a cryptographically secure opaque refresh token.
    secrets.token_urlsafe(64) gives 384 bits of entropy — sufficient for
    any brute-force resistance requirement.
    """
    token = secrets.token_urlsafe(64)
    refresh_token_store[token] = {
        "user_id": user_id,
        "expires_at": datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS),
        "used": False,
    }
    return token


async def refresh_access_token(refresh_token: str) -> dict:
    """
    Exchange a valid refresh token for a new access + refresh token pair.
    Rotation: the submitted refresh token is consumed and a new one issued.
    Reuse detection: if a consumed token appears again, revoke everything
    for that user — this means a stolen token was used after legitimate rotation.
    """
    record = refresh_token_store.get(refresh_token)

    if not record:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid refresh token",
        )

    if record["used"]:
        # A consumed token reappeared — this is a theft indicator.
        # Revoke all sessions for this user and force re-authentication.
        # In production: invalidate all refresh tokens for record["user_id"]
        # and increment the user's token_version in the database.
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Refresh token reuse detected — all sessions have been revoked",
        )

    if datetime.now(timezone.utc) > record["expires_at"]:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Refresh token expired",
        )

    # Mark as consumed before issuing the new pair
    # In production, this must be atomic — use a database transaction
    # or Redis SET NX to prevent race conditions on concurrent refresh requests
    record["used"] = True

    new_access_token = create_access_token(
        data={"sub": record["user_id"]},
        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
    )
    new_refresh_token = create_refresh_token(record["user_id"])

    return {
        "access_token": new_access_token,
        "refresh_token": new_refresh_token,
        "token_type": "bearer",
    }
▶ Output
New access_token and refresh_token issued. Submitted refresh_token is now consumed and will be rejected on any subsequent use.
Mental Model
Refresh Tokens as One-Time Tickets
A refresh token is a one-time ticket at a theme park — you hand it in at the counter, they give you a new wristband and a new ticket, and the old ticket is shredded on the spot. If someone pickpockets your old ticket and tries to use it after you have already exchanged it, the system immediately knows something is wrong — that ticket was shredded, so why is it here again?
  • Access token = the wristband (short-lived, stateless, used for API requests throughout the day)
  • Refresh token = the exchange ticket (long-lived, server-side record, used only once to get a new wristband)
  • Rotation = shredding the old ticket on each exchange — prevents the same token from being reused
  • Reuse detection = the alarm when a shredded ticket appears — signals theft and triggers full revocation
  • Store hashed refresh tokens in the database — if the database is compromised, raw token values are not exposed
📊 Production Insight
Without rotation, a stolen refresh token grants indefinite access until the user changes their password — the attacker's session outlives yours.
Rotation limits this: the attacker's stolen token is consumed on first use, and the legitimate user's next refresh triggers reuse detection and full revocation.
Rule: the refresh token exchange must be atomic — a race condition where two concurrent requests both succeed on the same token defeats the entire reuse detection mechanism.
🎯 Key Takeaway
Access tokens are stateless and short-lived; refresh tokens are stateful, long-lived, and stored server-side.
Rotation consumes each refresh token on use — if a consumed token appears again, you know it was stolen and can revoke all sessions for that user.
The refresh token exchange must be atomic — use a database transaction or Redis SET NX to prevent concurrent requests from bypassing reuse detection.
🗂 Authentication Strategies in FastAPI
Choosing the right approach depends on your revocation requirements, session duration needs, and infrastructure constraints. Most production APIs end up at JWT + Redis blacklist + refresh token rotation — the other rows are either stepping stones or special-purpose solutions.
StrategyStateRevocationBest For
JWT (stateless access token only)Stateless — no server-side session requiredNot possible without additional infrastructure — stolen tokens are valid until expiryInternal services where tokens are short-lived, the attack surface is low, and horizontal scaling without shared state is the priority
JWT + Redis Blacklist (JTI-based)Semi-stateful — JTI blacklist entries in Redis with TTLImmediate — blacklist the JTI on logout or compromise, rejected on next requestProduction APIs that need both horizontal scalability and the ability to revoke individual tokens without a full key rotation
Session-based (server-side session store)Stateful — session record in database or Redis, referenced by session ID cookieImmediate — delete or invalidate the session recordServer-rendered web applications, admin panels, and any context where session auditability and immediate revocation are more important than stateless scalability
OAuth2 + Refresh Token RotationSemi-stateful — refresh tokens stored server-side, access tokens statelessImmediate for refresh tokens — rotate and revoke; access tokens expire naturally within their short windowMobile apps, SPAs, and any client that needs sessions lasting days or weeks without requiring the user to re-authenticate frequently
API Key (static token, database-validated)Stateless key or database-validated per requestImmediate when database-validated — delete or deactivate the key recordMachine-to-machine communication, service-to-service authentication, and third-party developer integrations where human session semantics do not apply

🎯 Key Takeaways

  • OAuth2PasswordBearer is a built-in FastAPI provider that extracts the token from the Authorization: Bearer header automatically — you declare it once, and every dependency that uses it gets the token without any manual header parsing.
  • The 'sub' (subject) claim in the JWT payload should be an immutable user identifier — use a UUID rather than a username or email, which can change and cause lookup failures on tokens issued before the update.
  • Always use Annotated for dependency declarations in modern FastAPI — it provides accurate editor type hints, cleaner function signatures, and is the pattern the FastAPI documentation recommends from version 0.95 onwards.
  • Access tokens should be short-lived (15-30 minutes) to limit the damage window on theft. Use refresh token rotation for sessions that need to last days or weeks — store refresh tokens server-side so they can be revoked.
  • The SECRET_KEY is the root of trust for your entire authentication system — generate it with at least 256 bits of entropy, load it from a secrets manager at runtime, validate it is non-empty at startup, and rotate it immediately on any suspected exposure.
  • JWTs are stateless — implement a JTI blacklist in Redis for per-token revocation or a token_version counter in the database for per-user bulk revocation. Both are necessary in production; neither is the default.
  • A missing Depends() on a route silently makes it publicly accessible with no error or warning — add a CI lint check or use router-level dependencies to make authentication the enforced default, not the remembered exception.

⚠ Common Mistakes to Avoid

    Hardcoding SECRET_KEY in source code or committing it to version control
    Symptom

    If the repository is public or access is compromised, any attacker can forge valid JWTs for any user with any claims and any expiry. The breach is silent — forged tokens pass all validation checks and are indistinguishable from legitimate tokens in logs. The first signal is typically anomalous data access patterns, not authentication failures, which delays detection significantly.

    Fix

    Load SECRET_KEY exclusively from environment variables backed by a secrets manager (AWS Secrets Manager, HashiCorp Vault, GCP Secret Manager). Add pre-commit hooks using detect-secrets or gitleaks to block credential commits at the source. Rotate the key immediately and unconditionally if any exposure is suspected — accept the forced re-login of all users as the necessary operational cost.

    Using a weak or guessable SECRET_KEY
    Symptom

    Attackers perform offline brute-force attacks against captured JWTs using tools like hashcat. With a weak key — 'secret', 'password123', or anything under 128 bits of entropy — they compute the signing key within minutes and can then forge tokens for any user without any further network access.

    Fix

    Generate the SECRET_KEY with at least 256 bits of entropy: python3 -c "import secrets; print(secrets.token_hex(32))". The output is a 64-character hex string representing 256 bits of cryptographically secure randomness. Store it in a secrets manager and never log it, even at debug level.

    Missing Depends(get_current_user) on a route that should be protected
    Symptom

    The route is publicly accessible without authentication. No error is raised during development, no warning appears at startup, and no log entry is created when unauthenticated requests arrive. This is typically discovered during a security audit, during a penetration test, or after a data exposure incident — not before.

    Fix

    Add a CI lint check that verifies all route handlers in protected routers include an authentication dependency. Use router-level dependencies (router = APIRouter(dependencies=[Depends(get_current_user)])) to make auth the default for entire groups of endpoints rather than relying on per-route declaration.

    Omitting the WWW-Authenticate: Bearer header from 401 responses
    Symptom

    Swagger UI does not show the lock icon or the Authorize popup. Browser-based OAuth2 clients and tooling that follows RFC 6750 cannot trigger the authentication challenge automatically. Users see a bare 401 with no guidance on how to authenticate, and the Swagger UI testing workflow breaks entirely.

    Fix

    Include headers={"WWW-Authenticate": "Bearer"} in every HTTPException with status_code=401 throughout your authentication dependencies. This is a requirement of RFC 6750 for Bearer token authentication, not a convention — missing it breaks standards-compliant clients.

    Storing sensitive data in JWT payload claims
    Symptom

    JWT payloads are base64-encoded, not encrypted. Anyone who intercepts a token — via server logs that record Authorization headers, browser developer tools, network traffic analysis, or a compromised frontend — can decode the payload and read every claim in plaintext. Passwords, PII, internal system metadata, or granular role definitions in the payload are exposed to any party who can observe the token.

    Fix

    Store only non-sensitive, stable identifiers in the JWT payload — user ID or UUID and the minimum claims needed for token validation. Fetch sensitive data from the database on each request using the 'sub' identifier. If authorization claims like roles must be in the token for performance reasons, accept that they are readable and ensure they contain no data that is harmful if disclosed. If the payload must contain sensitive data, use JWE (JSON Web Encryption) to encrypt it — but reconsider the architecture first.

Interview Questions on This Topic

  • QExplain the stateless nature of JWT. Why does this benefit FastAPI's performance compared to session-based authentication?Mid-levelReveal
    JWT is stateless because the token itself contains all the information needed to authenticate the request — the server validates it using only the cryptographic signature and the expiry claim, with no database or session store lookup required per request. This has two concrete performance benefits for FastAPI. First, it eliminates the per-request I/O that session-based auth requires — reading a session record from Redis or a database on every request adds 1-5ms of latency depending on infrastructure proximity. Second, it enables true horizontal scaling — any FastAPI instance can validate any JWT using only the SECRET_KEY and the ALGORITHM, with no shared state required. You can add instances, remove instances, and deploy across regions without coordinating a shared session store. The trade-off is revocation. A stateless JWT cannot be invalidated server-side without adding external state — a Redis blacklist or a database token_version counter. The moment you add either of those, you have reintroduced a per-request external lookup, partially offsetting the statelessness advantage. Most production systems accept this trade-off: stateless JWTs for the common case (high-performance validation), with a blacklist for the exceptional case (logout, credential compromise). The blacklist lookup is optional until it is needed, whereas session store lookup is mandatory for every request in session-based auth.
  • QHow does the Depends() mechanism help in preventing code duplication for protected routes?Mid-levelReveal
    FastAPI's Depends() implements dependency injection — it resolves and executes the dependency function before the route handler and injects its return value as a typed parameter into the route function. For authentication, this means you define get_current_user once: extract the token from the Authorization header (OAuth2PasswordBearer handles the header parsing), decode the JWT, validate the 'sub' claim, and return the user object. Every protected route declares this dependency in its signature. FastAPI handles the rest. This eliminates three distinct forms of duplication: the header extraction logic (handled once by OAuth2PasswordBearer), the JWT decoding and claim validation logic (centralised in get_current_user), and the error handling (one HTTPException pattern, consistently applied). Without Depends(), every route handler would contain the same 10-15 lines of auth code, creating opportunities for inconsistent error responses, missed validation steps, and security regressions whenever a developer makes a change to one copy but not the others. The more important property is execution order — Depends() runs before the route handler, enforced by the framework. Auth cannot be forgotten or bypassed; it either exists as a declared dependency or the route is publicly accessible. The failure mode is visible (missing dependency in the function signature) rather than invisible (absent middleware call).
  • QScenario: a user's laptop is stolen. Using standard JWTs, how would you revoke their active token before it expires?SeniorReveal
    Standard JWTs are stateless and cannot be revoked without adding server-side state. There are two production-viable approaches: The first is JTI-based blacklisting. Every JWT includes a unique 'jti' (JWT ID) claim — a UUID generated at issuance. On each authenticated request, the validation dependency checks whether the token's JTI exists in a Redis SET. To revoke a specific token, add its JTI to the Redis SET with a TTL equal to the token's remaining lifetime. The check adds approximately 1ms per request. This approach is surgical — you can revoke a single token while leaving all others for that user valid. The second is token_version countering. At issuance, include the user's current token_version (an integer stored in the database) as a claim in the JWT. On each validation, fetch the user's current token_version from the database (or a cache) and compare it to the token's claim. To revoke all tokens for a user, increment their token_version. Any token carrying an older version number is immediately invalid. This is more efficient for bulk revocation — one database write invalidates all outstanding tokens for a user simultaneously. For the stolen laptop scenario, I would increment the token_version in the database. This immediately invalidates all active tokens for that user on the next request, forcing re-authentication. Combined with a password reset requirement, this closes both the token and the credential vectors. The JTI blacklist would handle subsequent individual token revocations like logout from specific devices.
  • QWhat is the risk of using a weak SECRET_KEY, and how does it undermine the JWT signature?SeniorReveal
    The JWT signature is an HMAC-SHA256 hash of the base64-encoded header concatenated with the base64-encoded payload, computed using the SECRET_KEY. The entire security guarantee of HS256 rests on the assumption that the SECRET_KEY cannot be determined by an attacker who has access to valid tokens. With a weak SECRET_KEY — a short string, a dictionary word, or anything with low entropy — an attacker can perform an offline brute-force attack. They capture any legitimate JWT (from network traffic, server logs, or a browser), then iterate over candidate keys using a tool like hashcat, computing the HMAC-SHA256 of the token's header.payload for each candidate and comparing the result to the token's signature. With a weak key, this succeeds in minutes on commodity hardware. Once the key is known, the attacker can construct arbitrary tokens: any 'sub' claim (any user, including admins), any 'exp' claim (years in the future), and any additional claims. These forged tokens are cryptographically identical to legitimate tokens — jwt.decode() accepts them without question. The attack is completely silent in logs because there are no authentication failures, only successful authentications for forged identities. The mitigation is straightforward: generate the SECRET_KEY with secrets.token_hex(32), which produces 256 bits of cryptographically secure randomness. At 256 bits, a brute-force attack is computationally infeasible regardless of the attacker's hardware budget. Store it in a secrets manager, never log it, and rotate it periodically as part of your security hygiene.
  • QDescribe the flow of a Bearer token from the client to the server. Which HTTP header carries it?JuniorReveal
    The Bearer token flow has four distinct steps: First, the client sends a POST request to the /token endpoint with username and password in the request body, formatted as application/x-www-form-urlencoded as required by OAuth2PasswordRequestForm. This is form-encoded, not JSON — a common source of confusion when testing with tools that default to JSON bodies. Second, the server validates the credentials against the database using a constant-time password hash comparison (pwd_context.verify()), generates a signed JWT containing at minimum the 'sub' (user identifier) and 'exp' (expiry) claims, and returns it as {"access_token": "eyJ...", "token_type": "bearer"}. Third, the client stores the token and includes it in subsequent requests via the Authorization HTTP header: Authorization: Bearer eyJ.... The 'Bearer' prefix is defined by RFC 6750 and identifies this as an OAuth2 bearer token rather than HTTP Basic or Digest authentication. This header is sent on every API request that requires authentication. Fourth, FastAPI's OAuth2PasswordBearer dependency extracts the token from the Authorization header automatically. The get_current_user dependency receives the raw token string, calls jwt.decode() to validate the signature and expiry, extracts the 'sub' claim, and returns the user object. If validation fails for any reason, a 401 Unauthorized response is returned with a WWW-Authenticate: Bearer header, which signals to standards-compliant clients that Bearer authentication is required.

Frequently Asked Questions

What is the difference between authentication and authorisation?

Authentication (AuthN) verifies identity — it answers 'who are you?' by checking credentials against a known record. Authorisation (AuthZ) determines permissions — it answers 'what are you allowed to do?' after identity is confirmed.

In FastAPI, get_current_user handles authentication: it validates the JWT and returns a verified user object. Authorisation is a separate concern implemented as a chained dependency — a require_admin function that calls Depends(get_current_user) to get the verified user and then checks whether that user's role permits the requested action. Keeping these as separate dependencies prevents the common mistake of mixing identity verification with permission checking inside route handlers, which makes both harder to test and harder to reuse.

How do I implement refresh tokens in FastAPI?

Issue two tokens at login: an access_token (short-lived JWT, 15-30 minutes) and a refresh_token (long-lived opaque random string, 7-30 days). Store the refresh token server-side in a database or Redis — unlike JWTs, refresh tokens cannot be self-validated, so they require a server-side lookup.

Create a /refresh endpoint that accepts the refresh token, validates it against the stored record, issues a new access_token, and invalidates the submitted refresh token while issuing a new one. This rotation pattern is essential: each refresh token is single-use, and reuse of a consumed token triggers full session revocation for that user — the strongest signal you have that a token was stolen.

The exchange must be atomic. Two concurrent refresh requests with the same token must not both succeed. Use a database transaction or Redis SET NX to ensure the 'mark as used' and 'issue new token' operations happen together.

Is python-jose still the recommended library for FastAPI JWT in 2026?

python-jose is still widely used and functional, but its maintenance pace has slowed and it has had historical dependency issues with older versions of cryptography. The FastAPI community has increasingly moved toward PyJWT (actively maintained, simpler API) and Authlib (full OAuth2 framework, well-maintained) for new projects.

For existing codebases using python-jose, there is no urgent reason to migrate — the library is stable for standard HS256 and RS256 operations. For new projects, PyJWT is a reasonable default: pip install pyjwt[crypto] for RS256 support. The API differences are minor (jwt.encode / jwt.decode in both), and PyJWT's exception hierarchy is slightly cleaner to catch.

Regardless of library choice, always install with the cryptography extras to ensure secure signing support rather than relying on PyCrypto, which is unmaintained.

Can I use JWTs with FastAPI's WebSocket endpoints?

WebSocket connections initiated from browsers do not support custom HTTP headers during the initial handshake — the Upgrade request is controlled by the browser and custom Authorization headers cannot be added to it. This is a browser security constraint, not a FastAPI limitation.

The standard workaround is to pass the JWT as a query parameter: ws://host/ws?token=eyJ.... Validate the token in the WebSocket handler before calling await websocket.accept(). If validation fails, close the connection with code 1008 (Policy Violation) before accepting it.

Be aware that query parameters appear in server access logs, proxy logs, and browser history. To mitigate this, use single-use short-lived tokens specifically for WebSocket authentication — generate a WebSocket ticket at the HTTP layer (valid for 30-60 seconds, single use), pass it as the query parameter, and validate it before promoting the connection to a WebSocket. This avoids exposing your long-lived access token in logs.

🔥
Naren Founder & Author

Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.

← PreviousFastAPI Dependency Injection — How and Why to Use ItNext →FastAPI Background Tasks and Async Endpoints
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged