Skip to main content

FastAPI JWT Authentication: Step-by-Step

Implementing JWT authentication in FastAPI requires three steps: hash passwords, issue tokens on login, and validate tokens on protected routes using FastAPI's Depends and Security utilities. FastAPI's built-in security modules (HTTPBearer, HTTPAuthorizationCredentials) make token extraction and validation declarative. I've built this pattern in production systems handling thousands of daily API calls, and the pattern here is battle-tested.

Prerequisites and Setup

Install the required packages:

pip install fastapi uvicorn python-jose[cryptography] passlib[bcrypt] pydantic python-multipart

Create a config module to store your settings (secret key, token expiration). In production, load the secret from an environment variable or secrets manager:

# config.py
from datetime import timedelta
import os

SECRET_KEY = os.getenv("SECRET_KEY", "dev-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15

Never hardcode secrets. Use environment variables, AWS Secrets Manager, or a tool like python-dotenv.

Password Hashing with Passlib

User passwords must be hashed, never stored in plaintext. Passlib with bcrypt is the standard. Create a utility module:

# security.py
from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(password: str) -> str:
"""Hash a plaintext password."""
return pwd_context.hash(password)

def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify plaintext password against stored hash."""
return pwd_context.verify(plain_password, hashed_password)

Test locally: hash_password("mypassword") returns a bcrypt hash like $2b$12$.... Every call to hash_password with the same input produces a different hash (due to random salt), but verify_password correctly matches it.

Creating and Issuing Tokens

Use the python-jose library to create JWT tokens:

# security.py (continued)
from jose import JWTError, jwt
from datetime import datetime, timedelta
from config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES

def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""Create a JWT access token with optional custom expiration."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)

to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt

def decode_token(token: str) -> dict | None:
"""Decode a JWT token; return None if invalid or expired."""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None

Building the Login Endpoint

Define Pydantic models for request/response:

# schemas.py
from pydantic import BaseModel

class LoginRequest(BaseModel):
username: str
password: str

class TokenResponse(BaseModel):
access_token: str
token_type: str

class UserResponse(BaseModel):
username: str
email: str

Create a login endpoint that validates credentials and returns a token:

# main.py
from fastapi import FastAPI, HTTPException, status
from datetime import timedelta
from security import hash_password, verify_password, create_access_token
from schemas import LoginRequest, TokenResponse
from config import ACCESS_TOKEN_EXPIRE_MINUTES

app = FastAPI()

# Mock user database (replace with real DB in production)
fake_users = {
"alice": {
"username": "alice",
"email": "[email protected]",
"hashed_password": hash_password("secret123")
}
}

@app.post("/login", response_model=TokenResponse)
async def login(credentials: LoginRequest):
"""Authenticate user and issue JWT token."""
user = fake_users.get(credentials.username)

if not user or not verify_password(credentials.password, user["hashed_password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password"
)

access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user["username"], "email": user["email"]},
expires_delta=access_token_expires
)

return {"access_token": access_token, "token_type": "bearer"}

Test the login endpoint with curl:

curl -X POST http://localhost:8000/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"secret123"}'
# Returns: {"access_token":"eyJhbGc...","token_type":"bearer"}

Protecting Routes with Token Validation

Create a dependency that extracts and validates the token:

# security.py (continued)
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

bearer_scheme = HTTPBearer()

async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)
) -> dict:
"""Extract and validate JWT from Authorization header."""
token = credentials.credentials
payload = decode_token(token)

if payload is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)

username = payload.get("sub")
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token missing user claim"
)

return {"username": username, "email": payload.get("email")}

Use this dependency on protected endpoints:

# main.py (continued)
@app.get("/me", response_model=UserResponse)
async def read_current_user(current_user: dict = Depends(get_current_user)):
"""Return authenticated user's details."""
return current_user

@app.get("/protected")
async def protected_resource(current_user: dict = Depends(get_current_user)):
"""Example protected endpoint."""
return {"message": f"Hello {current_user['username']}!"}

Test with your token:

TOKEN="eyJhbGc..." # From /login response
curl -X GET http://localhost:8000/me \
-H "Authorization: Bearer $TOKEN"
# Returns: {"username":"alice","email":"[email protected]"}

Complete Working Example

Here's a minimal, runnable FastAPI app combining all pieces:

# main.py (full example)
from fastapi import FastAPI, HTTPException, Depends, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel
from passlib.context import CryptContext
from jose import jwt, JWTError
from datetime import datetime, timedelta
import os

app = FastAPI()

# Config
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 15

# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# Security
bearer_scheme = HTTPBearer()

# Models
class LoginRequest(BaseModel):
username: str
password: str

class TokenResponse(BaseModel):
access_token: str
token_type: str

# Users DB
users_db = {
"alice": {
"username": "alice",
"hashed_password": pwd_context.hash("secret123"),
"email": "[email protected]"
}
}

# Utilities
def create_access_token(data: dict) -> str:
to_encode = data.copy()
to_encode.update({"exp": datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

def verify_token(token: str) -> dict:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None

async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)) -> dict:
token = credentials.credentials
payload = verify_token(token)
if payload is None:
raise HTTPException(status_code=401, detail="Invalid token")
return {"username": payload.get("sub")}

# Endpoints
@app.post("/login", response_model=TokenResponse)
async def login(creds: LoginRequest):
user = users_db.get(creds.username)
if not user or not pwd_context.verify(creds.password, user["hashed_password"]):
raise HTTPException(status_code=401, detail="Invalid credentials")

token = create_access_token({"sub": creds.username})
return {"access_token": token, "token_type": "bearer"}

@app.get("/me")
async def me(current_user: dict = Depends(get_current_user)):
return current_user

Run it: uvicorn main:app --reload. Visit http://localhost:8000/docs (FastAPI's auto-generated Swagger UI) and test login/me endpoints interactively.

Key Takeaways

  • Hash passwords with Passlib's bcrypt before storing; verify on login.
  • Use python-jose to create and decode JWTs with your secret key.
  • FastAPI's Depends and HTTPBearer make token extraction and validation declarative.
  • Short token expiration (15 min) limits exposure if a token is leaked.
  • Always use HTTPS in production to protect tokens in transit.

Frequently Asked Questions

What's the difference between HTTPBearer and HTTPAuthorizationCredentials?

HTTPBearer is a FastAPI security scheme that extracts the token from the Authorization: Bearer <token> header. It returns an HTTPAuthorizationCredentials object with the token value in the credentials attribute. You use it in Depends() to inject validated credentials into route handlers.

Why not use FastAPI's Security instead of Depends?

Security is identical to Depends but signals that the dependency involves security (used by OpenAPI docs). Functionally, they're the same. Use Security for clarity on protected endpoints.

Can I add custom claims to the JWT payload?

Yes. In create_access_token, add any dict keys before encoding: data={"sub": username, "email": email, "role": "admin"}. Decode them from the payload in get_current_user.

How do I handle token refresh?

Store a longer-lived refresh token in a database or httpOnly cookie. The /refresh endpoint validates the refresh token and issues a new access token. See the next article in this series for a complete implementation.

Further Reading