FastAPI Authentication — JWT and OAuth2 with Password Flow
- 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.
- 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
jwt.exceptions.ExpiredSignatureError appearing in production logs
date -u && ntpq -ppython3 -c "from jose import jwt; print(jwt.decode('<TOKEN>', '<KEY>', algorithms=['HS256']))"401 on every request immediately after SECRET_KEY rotation
curl -s http://localhost:8000/openapi.json | python3 -m json.tool | head -5docker exec <container> env | grep SECRET_KEYToken is accepted and returns 200 but the user data in the response is wrong or belongs to a different user
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/meSwagger UI /docs does not show the lock icon or the Authorize popup
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.pyProduction Incident
Production Debug GuideFrom symptom to resolution for common JWT authentication issues in production FastAPI services
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.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.os.getenv() and confirm the value is identical across all workers.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.
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()
- 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
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.
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"}
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'.
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}
- 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
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.
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}
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.
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", }
- 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
| Strategy | State | Revocation | Best For |
|---|---|---|---|
| JWT (stateless access token only) | Stateless — no server-side session required | Not possible without additional infrastructure — stolen tokens are valid until expiry | Internal 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 TTL | Immediate — blacklist the JTI on logout or compromise, rejected on next request | Production 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 cookie | Immediate — delete or invalidate the session record | Server-rendered web applications, admin panels, and any context where session auditability and immediate revocation are more important than stateless scalability |
| OAuth2 + Refresh Token Rotation | Semi-stateful — refresh tokens stored server-side, access tokens stateless | Immediate for refresh tokens — rotate and revoke; access tokens expire naturally within their short window | Mobile 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 request | Immediate when database-validated — delete or deactivate the key record | Machine-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
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
- QHow does the
Depends()mechanism help in preventing code duplication for protected routes?Mid-levelReveal - QScenario: a user's laptop is stolen. Using standard JWTs, how would you revoke their active token before it expires?SeniorReveal
- QWhat is the risk of using a weak SECRET_KEY, and how does it undermine the JWT signature?SeniorReveal
- QDescribe the flow of a Bearer token from the client to the server. Which HTTP header carries it?JuniorReveal
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.
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.