Recipe · FastAPI · Security · LangChain
JWT Auth with FastAPI + LangChain
Access tokens, refresh tokens, bcrypt hashing, OAuth2 Password Bearer, FastAPI Depends, and refresh token rotation — all in one guide with real code examples.
Covers fastapi authentication jwt, langchain authentication, and every layer of a production JWT stack. No toy examples.
Why JWT Auth for FastAPI + LangChain Apps
LangChain applications often need user context — conversation history per user, per-user API rate limits, usage tracking, and access control on LLM calls. Stateless JWT authentication solves all of these without server-side session storage.
HTTP-only session cookies work for browser clients but break for mobile apps, API clients, and server-to-server calls. JWTs are the standard for API authentication: self-contained, URL-safe, and verifiable without a database lookup on every request.
For FastAPI specifically, the framework's dependency injection system makes JWT auth clean and composable. A single get_current_user dependency function handles token extraction, signature verification, expiration checking, and user lookup — then you just add it to any route that needs protection.
Access Token + Refresh Token Flow
The two-token pattern separates short-lived access from long-lived refresh credentials. This limits the blast radius of a compromised access token while keeping friction low for users.
The flow, step by step
- User submits username + password to
POST /auth/token. - Server verifies credentials against the bcrypt hash stored in the database.
- Server generates an access token (JWT, 15–30 minute expiry) and a refresh token (opaque or JWT, 7–30 day expiry).
- Both tokens are returned to the client. The access token is stored in memory; the refresh token goes in an HttpOnly cookie or secure storage.
- Client sends the access token in the
Authorization: Bearerheader on every API request. - When the access token expires, client calls
POST /auth/refreshwith the refresh token to get a new access token (and a rotated refresh token). - On logout, the refresh token is invalidated server-side.
Access tokens are stateless — the server validates them using the secret key without a database call. Refresh tokens require state (to be revocable), so store them in a database or Redis with an is_revoked flag.
from datetime import datetime, timedelta, timezone
from jose import jwt
from pydantic import BaseModel
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=7)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)OAuth2 with Password Bearer
FastAPI ships with built-in OAuth2 support. The OAuth2PasswordBearer class is the standard way to handle token extraction in FastAPI apps — it reads the Authorization header, expects the Bearer <token> format, and makes the raw token available to your route dependencies.
Here's the key distinction: OAuth2PasswordBearer handles the protocol (header parsing), not the validation. You still need a dependency to decode and verify the JWT. That separation is by design — it keeps the OAuth2 layer thin and lets you plug in any token format.
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
async def get_current_user(token: str = Depends(oauth2_scheme)):
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])
user_id: str = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
# Look up user in database
user = await db.get_user(user_id)
if user is None:
raise credentials_exception
return userThe tokenUrl="/auth/token" parameter is the OAuth2 convention — it tells the OpenAPI docs where to POST for a token. Even if your actual login URL differs, this URL should return tokens when given valid credentials.
One common mistake: putting Depends(oauth2_scheme) on a route without a corresponding validation dependency. Without the second dependency, you get the raw token string but never verify it. Always pairOAuth2PasswordBearer with a JWT decode + user lookup dependency.
bcrypt Password Hashing
Never store passwords in plaintext. Never use MD5 or SHA-1 for passwords. Use bcrypt via passlib — the standard choice for Python apps. bcrypt applies a cost factor (work factor) that makes brute-force attacks exponentially slower. Modern hardware takes centuries to crack a single bcrypt hash with a cost factor of 12+.
Setting up passlib with bcrypt
The CryptContext is the recommended interface. It manages the hashing algorithm, cost parameters, and migration logic in one place.
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
# Usage in registration:
async def register_user(username: str, email: str, password: str):
hashed = hash_password(password)
user = await db.create_user(
username=username,
email=email,
hashed_password=hashed,
)
return user
# Usage in login:
async def authenticate_user(username: str, password: str):
user = await db.get_user_by_username(username)
if not user or not verify_password(password, user.hashed_password):
return None
return userThe deprecated="auto" setting means passlib will automatically re-hash passwords with the current scheme if you change the underlying algorithm later. This is important for migrations.
Set the bcrypt cost factor based on your security needs vs. performance constraints. Cost factor 12 is a good default (takes ~250ms per hash on modern hardware). Cost factor 10 is faster but less secure. Don't go below 10.
FastAPI Depends for Auth
FastAPI's Depends is its dependency injection system. For auth, it lets you define a reusable get_current_user function that runs before every protected route. If it raises an exception, the route never executes — FastAPI catches it and returns the HTTP error response automatically.
The pattern is: declare the dependency function, then add it as a default parameter in your route function. FastAPI resolves it, passes the result to your handler, and handles cleanup. No middleware boilerplate, no manual header parsing in every route.
Composing dependencies
Dependencies chain naturally. You can have get_current_user depend on get_db, and both will resolve before your route handler. You can also have optional dependencies — if a dependency function returns None, the route can still proceed if the parameter type allows it.
from fastapi import APIRouter, Depends
from auth.dependencies import get_current_user
from auth.models import User
from pydantic import BaseModel
router = APIRouter(prefix="/chat")
class ChatRequest(BaseModel):
message: str
session_id: str | None = None
@router.post("/message")
async def chat(
request: ChatRequest,
current_user: User = Depends(get_current_user),
):
# current_user is guaranteed to be valid here
# LangChain chain call with user context
result = await chain.ainvoke({
"user_id": current_user.id,
"message": request.message,
"session_id": request.session_id,
})
return {"response": result, "user_id": current_user.id}Creating Protected Routes
A protected route is any endpoint that requires a valid, non-expired JWT. In FastAPI, you make a route protected by adding the auth dependency — that's the entire mechanism. The framework handles header extraction, exception raising, and response formatting.
Protected routes in a LangChain app typically fall into two categories: endpoints that invoke a chain (chat, RAG query, tool execution) and endpoints that manage user data (conversation history, vector store permissions, API key management).
Chat endpoint with LangChain
The LangChain integration point is straightforward: the route handler receives the validated user object, builds the chain input (including user_id for conversation memory), and calls the chain. No auth logic lives inside the chain itself — the FastAPI layer handles that.
from fastapi import APIRouter, Depends, HTTPException
from langchain.chat_models import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from auth.dependencies import get_current_user
from auth.models import User
from pydantic import BaseModel
router = APIRouter(prefix="/llm")
llm = ChatOpenAI(model="gpt-4o", temperature=0.7)
@router.post("/complete")
async def complete(
prompt: str,
current_user: User = Depends(get_current_user),
):
# Check rate limit per user
if not await rate_limiter.allow(current_user.id):
raise HTTPException(status_code=429, detail="Rate limit exceeded")
chain = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant."),
("human", "{prompt}"),
]) | llm
response = await chain.ainvoke({"prompt": prompt})
await rate_limiter.record(current_user.id)
return {"completion": response.content, "user": current_user.username}Token Expiration
JWTs are self-contained — they carry their own expiration time in the expclaim. The server verifies this claim on every request without consulting a database. This is efficient, but it means you can't revoke a valid access token until it expires.
Choosing expiration times
- —Access token: 15–30 minutes. Short enough that a stolen token has limited value.
- —Refresh token: 7–30 days. Longer for convenience, but rotation keeps this safe.
- —Short-lived tokens reduce the window of a JWT compromise. Long-lived refresh tokens require rotation.
- —For LangChain apps with expensive LLM calls, 30-minute access tokens prevent unauthorized usage after a user should have lost access.
The exp claim
Always include exp in your JWT payload. The jwt.decode() function from python-jose validates it automatically and raises JWTError if the token is expired. Handle that exception in your get_current_user dependency to return a clean 401.
from datetime import datetime, timedelta, timezone
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({
"exp": expire,
"type": "access",
"iat": datetime.now(timezone.utc), # issued at
})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({
"exp": expire,
"type": "refresh",
"iat": datetime.now(timezone.utc),
})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)Refresh Token Rotation
Refresh token rotation is the practice of issuing a new refresh token every time the client uses one to obtain a new access token. The old refresh token is immediately invalidated. This creates a one-time-use guarantee for refresh tokens.
Why rotate?
Without rotation, a stolen refresh token is valid until expiry — days or weeks of unauthorized access. With rotation, the moment the legitimate owner uses their refresh token, the thief's token becomes useless. Any attempt to use both tokens reveals the theft.
Implementation
Rotation requires state: store each refresh token's jti (JWT ID) claim in your database or Redis with an is_revoked flag. On every refresh request, check that the presented token's jti is not revoked, then mark it revoked and issue a new one. This is a small database write per refresh — worth the security gain.
@router.post("/refresh")
async def refresh_token(
refresh_token: str = Depends(oauth2_scheme_refresh),
db: Session = Depends(get_db),
):
# Decode without verification of specific claims first
payload = jwt.decode(
refresh_token, SECRET_KEY, algorithms=[ALGORITHM]
)
if payload.get("type") != "refresh":
raise HTTPException(status_code=400, detail="Invalid token type")
jti: str = payload.get("jti")
user_id: str = payload.get("sub")
# Check if this refresh token has been revoked
token_record = await db.get_refresh_token(jti)
if not token_record or token_record.is_revoked:
# Token reuse detected — revoke all tokens for this user
await db.revoke_all_user_refresh_tokens(user_id)
raise HTTPException(status_code=401, detail="Token reuse detected")
# Mark old token as revoked (rotation)
await db.revoke_refresh_token(jti)
# Issue new access + refresh tokens
new_access = create_access_token({"sub": user_id, "jti": str(uuid4())})
new_refresh = create_refresh_token({"sub": user_id, "jti": str(uuid4())})
await db.save_refresh_token(new_refresh, user_id)
return {"access_token": new_access, "refresh_token": new_refresh}The Token reuse detectedbranch is the critical security property of rotation. If an attacker steals a refresh token and uses it, the legitimate user's subsequent refresh will fail (old token is now revoked). This immediately invalidates the attacker's token and alerts the system to the compromise.
In practice, upon reuse detection, you should invalidate all refresh tokens for that user and force re-authentication. Log the event for security auditing. This is a rare scenario but important to handle correctly.
Putting It Together — LangChain App Auth Architecture
Here's how all the pieces fit in a LangChain app. The auth stack sits at the FastAPI layer — it runs before any LangChain code executes. LangChain itself knows nothing about JWTs; it receives structured inputs from the route handler, which has already verified the user.
- →Router: defines endpoints, applies Depends(get_current_user).
- →Depends: resolves OAuth2PasswordBearer token, decodes JWT, looks up user.
- →Route handler: builds LangChain input with user context (user_id, permissions).
- →LangChain chain: processes input, returns result to handler.
- →Handler: formats response, updates rate limiter, returns to client.
This separation keeps your LangChain chains portable — they don't import FastAPI or know about JWTs. Swap out the auth layer (move to gRPC, add API key auth) without touching the chain code.
JWT Auth Security Checklist
- ✓Use HS256 or RS256 — never HS512 (not a real standard, sometimes confused with HMAC-SHA-512).
- ✓Store your secret key in environment variables, never in source code.
- ✓Set ACCESS_TOKEN_EXPIRE_MINUTES to 30 or less for production.
- ✓Rotate refresh tokens on every use — implement the jti claim tracking.
- ✓Use bcrypt with cost factor 10+ (12 recommended).
- ✓Validate the token type claim ('access' vs 'refresh') on every decode.
- ✓Return 401 with WWW-Authenticate: Bearer header on all auth failures.
- ✓Use HTTPS in production — JWTs in plain HTTP headers are interceptable.
- ✓Keep refresh token storage HttpOnly + Secure + SameSite=Strict in browser clients.
- ✓Implement logout by revoking the refresh token server-side — don't rely on client-side deletion.
FAQ
Common questions.
What is the difference between an access token and a refresh token?
An access token is a short-lived JWT that grants access to protected resources. A refresh token is a longer-lived token used to obtain new access tokens without re-authenticating. Access tokens typically expire in 15–60 minutes; refresh tokens may last days or weeks.
Why use bcrypt for password hashing in FastAPI?
bcrypt is a slow, salted hash function designed to resist brute-force attacks. It includes a cost factor that makes cracking passwords computationally expensive. In FastAPI, passlib with bcrypt backend handles hashing and verification securely.
How does FastAPI Depends work for authentication?
FastAPI Depends injects a callable into a route's dependency chain. You define a dependency function (e.g., get_current_user) that extracts and validates the JWT from the Authorization header, then declares it as a parameter in your route. FastAPI resolves it before the route handler runs.
What is OAuth2 with Password Bearer flow?
OAuth2 Password flow is a simplified authentication scheme where the client sends username and password directly to the token endpoint. FastAPI's OAuth2PasswordBearer class automatically extracts the bearer token from the Authorization header and makes it available to route dependencies.
What is refresh token rotation and why does it matter?
Refresh token rotation means issuing a new refresh token every time it's used to obtain a new access token. The old token is invalidated immediately. This prevents replay attacks — if a stolen refresh token is used, the legitimate token is already invalidated and the abuse is detectable.
How do you protect LangChain routes with JWT in FastAPI?
You protect LangChain routes by combining FastAPI Depends with a JWT validation dependency. The dependency decodes the access token, verifies the signature and expiration, then returns the current user. LangChain tools and chains are called inside the route handler after the dependency has already resolved.
How long should JWT access tokens and refresh tokens live?
Access tokens should be short-lived: 15 to 60 minutes is the standard range. Refresh tokens should live longer (hours to weeks) but always be stored securely, rotated on use, and invalidated on logout. The exact values depend on your security posture and user session requirements.