FastAPI Authentication — JWT and OAuth2 with Password Flow
Master JWT authentication in FastAPI.
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
FastAPI authentication with JWT and OAuth2 Password Flow is the de facto standard for securing modern Python APIs. It combines JSON Web Tokens (JWT) — stateless, self-contained tokens carrying user claims — with the OAuth2 Resource Owner Password Credentials Grant, where a client exchanges a username and password directly for an access token.
This pattern solves the fundamental problem of authenticating HTTP requests without server-side session storage: every request carries a signed token the server can verify independently, making it horizontally scalable by default. In practice, you'll use FastAPI's OAuth2PasswordBearer dependency to extract tokens from the Authorization: Bearer header, then validate and decode them with libraries like python-jose or PyJWT.
The flow works in three phases: login, token issuance, and request validation. During login, the server hashes the password (typically with bcrypt via passlib), verifies it against the stored hash, then generates a short-lived access token (15-30 minutes) and optionally a longer-lived refresh token (7-30 days).
The access token is a JWT containing the user ID, expiration time, and scopes, signed with a secret key or RSA private key. Protected routes use FastAPI's dependency injection to call a get_current_user function that decodes the token, checks expiration, and returns the authenticated user — if the token is invalid or expired, the endpoint returns 401 immediately without hitting your database.
Where this pattern falls short is token revocation. Since JWTs are stateless, you cannot invalidate them before they expire without introducing server-side state. Common workarounds include maintaining a Redis blacklist of revoked token IDs (jti claims) or using short-lived tokens with refresh token rotation — where each refresh invalidates the previous refresh token.
For most production APIs, you'll pair this with HTTPS-only cookies (not localStorage) to mitigate XSS, and implement rate limiting on the login endpoint to prevent brute force attacks. Alternatives like session-based auth (Flask-Login) or opaque bearer tokens (Django REST Framework) trade scalability for simpler revocation, but JWT+OAuth2 remains the dominant choice for FastAPI services, microservices, and SPAs due to its stateless nature and broad ecosystem support.
Think of JWT authentication like a concert wristband. You show your ID at the gate (login endpoint), the bouncer checks it against the guest list (database), and if you are legitimate, they put a tamper-proof wristband on you — that is the JWT. Every time you want to go backstage (access a protected route), you flash the wristband. The staff can verify it is genuine just by looking at the holographic seal — they do not need to call the gate again. But here is the catch nobody mentions: if someone copies your wristband before it expires, there is no way to cancel it unless you maintain a separate cancelled-wristband list. That list is your Redis blacklist, and most teams skip it until something goes wrong.
JWT with OAuth2 Password Flow is the standard pattern for securing FastAPI services in production, but most tutorials gloss over the failure modes that matter at scale: token revocation, key rotation, and the choice between HS256 and RS256. This article covers the actual implementation—from environment-backed secrets and bcrypt password hashing to dependency injection with get_current_user—with the specific trade-offs you'll need to justify in a production code review.
How JWT + OAuth2 Password Flow Actually Authenticates Your API
FastAPI authentication with JWT and OAuth2 Password Flow is a stateless, token-based mechanism where the client exchanges a username and password for a signed JSON Web Token (JWT), then sends that token in the Authorization header for every subsequent request. The server validates the token's signature and expiration without storing session state — the token itself is the proof of authentication. This is OAuth2's Resource Owner Password Credentials grant adapted for first-party APIs, not third-party delegation.
The flow is synchronous and simple: POST /token with form data, receive an access token (typically short-lived, 15-30 minutes) and optionally a refresh token (long-lived, days). The server signs the JWT with a secret key (HS256) or a private key (RS256). On each protected request, FastAPI's Depends() extracts the token, verifies the signature and expiry, and injects the decoded payload (e.g., user_id, scopes) into the route handler. No database lookup per request — that's the performance win.
Use this pattern for first-party mobile apps, SPAs, or server-to-server communication where you control both the client and the resource server. It's not suitable for third-party app authorization (use Authorization Code flow instead). The critical trade-off: revocation is hard — you must maintain a blocklist or use very short token lifetimes with refresh tokens. In production, always sign with RS256 and rotate keys regularly.
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
Why Your JWT Secret Is the Single Point of Failure (and How to Rotate It Without Killing Sessions)
Your JWT secret is the master key to your entire auth system. If it leaks, attackers forge tokens. If you rotate it carelessly, every logged-in user gets kicked out. We need a strategy that handles both.
The production fix: multiple signing keys with versioned key IDs. Store keys in a KMS (AWS KMS, HashiCorp Vault, even a sealed environment variable). Never hardcode them. The JWT header carries a kid claim; your middleware looks up the right key by that ID.
When rotating, issue new tokens with the new kid. Old tokens remain valid until expiry because the verifier still has the old key for lookup. Set a key rotation schedule — monthly for high-security apps, quarterly for most. Automate it. Don't trust humans to remember.
Why this matters: A leaked secret is a P0 incident. Versioned keys let you revoke only the compromised key, not every session. Your users stay logged in; your security team doesn't panic.
// io.thecodeforge — python tutorial import jwt from datetime import datetime, timedelta from typing import Dict # In production, load these from KMS or an encrypted config SIGNING_KEYS: Dict[str, str] = { "key_v2": "top-secret-key-v2-here", "key_v1": "deprecated-key-v1-here" # kept for validation only } def create_token(user_id: str, active_key_id: str = "key_v2") -> str: """Issue a JWT with a kid header pointing to the active signing key.""" now = datetime.utcnow() payload = { "sub": user_id, "iat": now, "exp": now + timedelta(hours=1), "kid": active_key_id # allows verifier to pick the right key } return jwt.encode(payload, SIGNING_KEYS[active_key_id], algorithm="HS256") def verify_token(token: str) -> dict: """Decode and verify using the kid from the token header.""" # manually decode header first to extract kid header = jwt.get_unverified_header(token) kid = header.get("kid") or "key_v1" # fallback for legacy tokens secret = SIGNING_KEYS.get(kid) if not secret: raise jwt.InvalidTokenError(f"Unknown key ID: {kid}") return jwt.decode(token, secret, algorithms=["HS256"]) # Usage new_token = create_token("user_42", active_key_id="key_v2") data = verify_token(new_token) print(f"Authenticated user: {data['sub']}")
The Right Way to Handle Token Inspection Middleware (Stop Checking Every Request Against Your DB)
Most beginners make the same mistake: on every API request, they decode the JWT, then hit the database to check if the user still exists, if their role changed, if the token was revoked. This destroys performance. Your auth middleware becomes your slowest path.
Why that's wrong: JWTs are self-contained. The signature proves the token hasn't been tampered with. If you trust your short-lived access tokens (15 minutes or less), you don't need a DB check per request. You only need it for refresh tokens and revocation checks.
The production pattern: split your middleware into two stages. Stage one: decode and verify the JWT signature statelessly. Done in microseconds. Stage two: only hit the DB (or cache) for long-lived operations or when you need additional context like user permissions. Use Redis for revocation blacklists — don't kill your primary DB.
Concrete example: In FastAPI, you'd create a get_current_user dependency that does a fast decode. If you need fresh user data, add a separate get_current_user_from_db dependency that also checks the JWT and then fetches from Redis cache.
// io.thecodeforge — python tutorial from fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials import jwt app = FastAPI() bearer_scheme = HTTPBearer() # Hardcoded secret — replace with vault in prod JWT_SECRET = "my-production-secret" def decode_token(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict: """Stage 1: Stateless decode. No DB call. ~50-100 microseconds.""" token = credentials.credentials try: payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) return payload except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="Token expired") except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid token") def get_current_user(payload: dict = Depends(decode_token)) -> dict: """Stage 2 (optional): Add DB check only when needed.""" # In practice, you'd fetch from Redis or DB here # For this example, we trust the token payload user_id = payload.get("sub") if not user_id: raise HTTPException(status_code=401, detail="User not found in token") return {"id": user_id, "role": payload.get("role", "user")} @app.get("/profile") def read_profile(current_user: dict = Depends(get_current_user)): """Only does stateless token decode — no DB hit.""" return {"user_id": current_user["id"], "role": current_user["role"]} # Terminal output would show HTTP 200 with user data
Why You Need pwdlib (Not bcrypt) for Password Hashing in 2024
Stop using bcrypt directly. You're inviting subtle timing attacks and managing argon2 parameters by hand like a caveman. pwdlib is the modern Python password library that wraps argon2id and bcrypt with sane defaults and automatic salt generation. The WHY is simple: your JWT auth is only as secure as the password storage beneath it. Leak your user table, and plain bcrypt without proper work factors gets cracked in hours. pwdlib gives you argon2id out of the box, which is memory-hard and ASIC-resistant. You get automatic salt, configurable time and memory costs, and a single verify() call that handles algorithm upgrades. Production teams at scale use this because it future-proofs credential storage against hardware attacks. Do not hash passwords yourself. Use pwdlib. Dependency injection makes swapping hashers trivial when the next NIST standard drops.
// io.thecodeforge — python tutorial from pwdlib import PasswordHash pwd_context = PasswordHash.recommended() def hash_password(plain: str) -> str: return pwd_context.hash(plain) def verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed)
pwdlib.recommended() picks argon2id with 19 MiB memory cost. That's the 2024 baseline. If your cloud bill can't handle 50ms per hash, upgrade your hardware, not your security.The Only Step-by-Step Implementation That Won't Burn You in Production
You want a step-by-step guide? Fine. But skip the toy examples. Here's the actual order you wire up JWT auth in a FastAPI project without creating a security nightmare. First, your database schema must include a refresh token table with an expiry column — you'll need it for rotation later. Second, your Pydantic models define exactly two schemas: a request body for login (username + password) and a response model with access+refresh tokens. Third, your helper functions live in a module called security.py, not scattered across routes. Fourth, your dependency injection for protected routes calls jwt.decode() inside a reusable Depends() — never inline. Fifth, your startup event pre-creates the token blacklist table in SQLite. This order matters because every piece depends on the one before it. Skip to 'testing' first and you'll leak tokens on day one. Production systems fail on race conditions between token generation and blacklist creation. Fix the order, fix the auth.
// io.thecodeforge — python tutorial from fastapi import FastAPI, Depends, HTTPException, status from pydantic import BaseModel from datetime import datetime, timedelta from .security import create_access_token, verify_token, hash_password, verify_password app = FastAPI() class LoginRequest(BaseModel): username: str password: str class TokenResponse(BaseModel): access_token: str refresh_token: str @app.post("/auth/login", response_model=TokenResponse) def login(req: LoginRequest, db=Depends(get_db)): user = db.query(User).filter(User.username == req.username).first() if not user or not verify_password(req.password, user.hashed_password): raise HTTPException(status_code=401, detail="Invalid credentials") access = create_access_token(data={"sub": user.id}, expires=timedelta(minutes=15)) refresh = create_refresh_token(data={"sub": user.id}) return TokenResponse(access_token=access, refresh_token=refresh)
FastAPI Interactive Docs Are Not Just for Testing — They Prove Your Auth Works
You built the /auth/login endpoint. Now prove it works without Postman. FastAPI's /docs generates an OpenAPI UI that's your first line of defense. Start the server with uvicorn main:app --reload. Hit localhost:8000/docs. Click 'Authorize' — that button exists specifically for your Bearer token flow. Paste a generated access token there. Now every 'Try it out' button on protected routes sends that header automatically. This isn't a toy. It's the fastest way to validate that your OAuth2PasswordBearer dependency actually fires, that your jwt.decode() catches expired tokens, and that your error responses are structured correctly. Production teams run integration tests against this same schema. If the docs show the right 401 response, your CI pipeline passes. If they show a 500, your token middleware is broken. Stop clicking around in cURL. Use the docs.
// io.thecodeforge — python tutorial # Terminal session: $ uvicorn main:app --reload INFO: Uvicorn running on http://127.0.0.1:8000 # Then in browser: # Open http://127.0.0.1:8000/docs # Click "Authorize" button (top-right) # Enter token: "Bearer eyJhbGciOiJIUzI1NiIs..." # Click "Authorize" then "Close" # Now test protected endpoints directly
Install PyJWT
Why install PyJWT instead of the older PyJWT library? Because PyJWT v2+ fixes critical vulnerabilities in signature verification that could let attackers forge tokens. Your authentication chain is only as strong as the JWT library parsing those tokens. The wrong library choice—like using the unmaintained jwt package or old PyJWT versions—opens your API to alg=none attacks and key confusion exploits. FastAPI projects in 2024 should pin PyJWT>=2.8.0 for constant-time comparison, secure default algorithms (RS256/ES256), and explicit algorithm whitelisting. Run pip install pyjwt and verify installation with pip show pyjwt. The library underpins your entire auth flow: token creation, verification, expiration checks, and issuer validation. One bad library choice and your JWT secret—no matter how strong—means nothing against a crafted token attack. Install the right tool first, then build your auth system on top of it.
// io.thecodeforge — python tutorial // Production lockfile fragment pyjwt>=2.8.0 pydantic>=2.0.0 fastapi>=0.110.0 pwdlib>=0.2.0 python-dotenv>=1.0.0
jwt package (v0.x) or PyJWT <2.0.0. They lack algorithms parameter validation, leaving you vulnerable to alg=none JWT attacks. Always pass algorithms=["HS256"] explicitly—never rely on defaults.Step 1: Imports (main.py)
Why start with imports? Because every authentication failure in production traces back to incorrect or missing imports. Importing the wrong jwt package silently breaks token verification. Your main.py imports define the security boundary: they load JWT encoding/decoding, password hashing, OAuth2 schemes, and secret management. A single missing import—like from fastapi.security import OAuth2PasswordBearer—crashes your login endpoint at runtime. Structure imports in three groups: standard library (datetime, os), FastAPI security (FastAPI, Depends, HTTPException, OAuth2PasswordBearer), and third-party auth (jwt, pwdlib). This order prevents circular dependencies and makes audit trails clear. Each import directly powers a step in your auth chain: OAuth2PasswordBearer extracts the token header, jwt.decode validates it, and pwdlib hashes passwords. Get them wrong and your API is either broken or insecure.
// io.thecodeforge — python tutorial from datetime import datetime, timedelta, timezone from os import getenv from fastapi import FastAPI, Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm import jwt from pwdlib import PasswordHasher from pydantic import BaseModel
from jwt import PyJWTError — that exception class was removed in PyJWT 2.0+. Use jwt.PyJWTError directly or catch jwt.ExpiredSignatureError and jwt.InvalidTokenError separately. Catching bare Exception masks critical auth failures.Conclusion: JWT Authentication Isn’t a Library, It’s a Contract
FastAPI authentication with JWTs works when you treat the token as a signed assertion, not a session cookie. Every decision—from dependency injection in route handlers to secret rotation without invalidating active users—reduces to a single principle: trust the signature, distrust the request. Blacklisting and refresh token rotation protect against theft without requiring a database call per request. pwdlib keeps password hashing future-proof with argon2 and bcrypt fallbacks. Your production setup must include middleware that validates expiry and issuer without touching storage, and your interactive docs should prove that protected endpoints reject missing or expired tokens. The biggest mistake is treating JWT as a solved problem; it is a trade-off between statelessness and revocation capability. Accept that trade-off, implement the patterns shown here, and your auth layer will survive load spikes, credential leaks, and key rotations without melting down. FastAPI gives you the tools; this guide gives you the discipline.
// io.thecodeforge — python tutorial from fastapi import FastAPI, Depends, HTTPException from fastapi.security import HTTPBearer from jose import jwt import pwdlib app = FastAPI() bearer = HTTPBearer() SECRET = "rotate-me" @app.post("/login") async def login(user: str, pwd: str): if pwd.bcrypt_check(hashed := get_user_hash(user)): token = jwt.encode({"sub": user}, SECRET, algorithm="HS256") return {"access_token": token} raise HTTPException(401)
The Forged Admin Token: How a Leaked SECRET_KEY Compromised Every User Session
- SECRET_KEY must never touch version control in any form — use a secrets manager and inject at runtime via environment variables that are never logged
- Rotating 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 pressure
- A token blacklist with JTI claims is the only mechanism for revoking specific tokens before their natural expiry — without it, logout is theatre
- Monitor 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
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.date -u && ntpq -ppython3 -c "from jose import jwt; print(jwt.decode('<TOKEN>', '<KEY>', algorithms=['HS256']))"curl -s http://localhost:8000/openapi.json | python3 -m json.tool | head -5docker exec <container> env | grep SECRET_KEYpython3 -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/mecurl -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| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| MultiKeyJwtRotation.py | from datetime import datetime, timedelta | Why Your JWT Secret Is the Single Point of Failure (and How |
| FastAPI_StatelessAuthMiddleware.py | from fastapi import FastAPI, Depends, HTTPException, status | The Right Way to Handle Token Inspection Middleware (Stop Ch |
| security.py | from pwdlib import PasswordHash | Why You Need pwdlib (Not bcrypt) for Password Hashing in 202 |
| main.py | from fastapi import FastAPI, Depends, HTTPException, status | The Only Step-by-Step Implementation That Won't Burn You in |
| terminal.txt | $ uvicorn main:app --reload | FastAPI Interactive Docs Are Not Just for Testing |
| requirements.txt | pyjwt>=2.8.0 | Install PyJWT |
| main.py | from datetime import datetime, timedelta, timezone | Step 1 |
| main.py | from fastapi import FastAPI, Depends, HTTPException | Conclusion |
Key takeaways
Depends() on a route silently makes it publicly accessible with no error or warningCommon mistakes to avoid
5 patternsHardcoding SECRET_KEY in source code or committing it to version control
Using a weak or guessable SECRET_KEY
Missing Depends(get_current_user) on a route that should be protected
Omitting the WWW-Authenticate: Bearer header from 401 responses
Storing sensitive data in JWT payload claims
Interview Questions on This Topic
Explain the stateless nature of JWT. Why does this benefit FastAPI's performance compared to session-based authentication?
How does the Depends() mechanism help in preventing code duplication for protected routes?
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).Scenario: a user's laptop is stolen. Using standard JWTs, how would you revoke their active token before it expires?
What is the risk of using a weak SECRET_KEY, and how does it undermine the JWT signature?
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.Describe the flow of a Bearer token from the client to the server. Which HTTP header carries it?
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
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.
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.
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.
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.
Every FastAPI concept with runnable in-browser examples — params, Pydantic, dependency injection, JWT auth, async, SQLAlchemy, testing, WebSockets, and Docker deployment. The interactive reference for production engineers.
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
That's Python Libraries. Mark it forged?
11 min read · try the examples if you haven't