Almost every real application eventually needs the same thing: a way to know who is making a request. JSON Web Tokens (JWTs) are the most common answer for APIs, and FastAPI’s dependency-injection system makes them clean to wire in. This is a practical, framework-focused walkthrough — not a product pitch. By the end you will have password hashing, a token issuer, a reusable get_current_user dependency, protected routes, and a clear-eyed look at refresh tokens and the mistakes that bite people in production.
How JWT Auth Works (the 30-second version)
A JWT is a signed, base64-encoded JSON object. The flow is simple: the user logs in with credentials, your server verifies them and returns a signed token, and the client sends that token on every subsequent request (usually in an Authorization: Bearer <token> header). Your server verifies the signature and trusts the claims inside — no database lookup required just to know who is calling.
The signature is the whole point: because the token is signed with a secret only your server knows, a client cannot forge or tamper with it. If the signature checks out, the contents are authentic.
Step 1: Install the Dependencies
You need three things: FastAPI, a JWT library, and a password-hashing library. We will use python-jose for tokens and passlib with bcrypt for passwords.
pip install "fastapi[standard]" "python-jose[cryptography]" "passlib[bcrypt]"
Never store plaintext passwords
Passwords go through a one-way hash (bcrypt) before they touch your database. You verify a login by hashing the input and comparing hashes — you never decrypt a stored password, because a correct implementation makes that impossible.
Step 2: Hash and Verify Passwords
Set up a passlib context once and reuse it. These two helpers are all you need for the password side.
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: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
Step 3: Issue an Access Token
A token issuer takes a payload (the “subject” — typically a user id), adds an expiry, and signs it. Keep the secret in an environment variable, never in source.
import os
from datetime import datetime, timedelta, timezone
from jose import jwt
SECRET_KEY = os.environ["JWT_SECRET_KEY"] # load from env, never hardcode
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": subject, "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
The exp claim is important: it is checked automatically when the token is decoded, so an expired token is rejected for you. Short-lived access tokens (15–30 minutes) limit the damage if one is ever leaked.
Step 4: Build the get_current_user Dependency
This is where FastAPI shines. You write the “who is calling?” logic once as a dependency, then attach it to any route that needs protection. OAuth2PasswordBearer tells FastAPI to pull the token out of the Authorization header.
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_error = 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 = payload.get("sub")
if user_id is None:
raise credentials_error
except JWTError:
raise credentials_error
user = get_user_by_id(user_id) # your DB lookup
if user is None:
raise credentials_error
return user
Step 5: Wire Up Login and Protect Routes
The login route verifies the password and returns a token. Any route that depends on get_current_user is now protected — a missing or invalid token returns a 401 automatically.
from fastapi import FastAPI, Depends
from fastapi.security import OAuth2PasswordRequestForm
app = FastAPI()
@app.post("/login")
async def login(form: OAuth2PasswordRequestForm = Depends()):
user = get_user_by_email(form.username)
if not user or not verify_password(form.password, user.hashed_password):
raise HTTPException(status_code=400, detail="Incorrect email or password")
token = create_access_token(subject=str(user.id))
return {"access_token": token, "token_type": "bearer"}
@app.get("/me")
async def read_me(current_user = Depends(get_current_user)):
return {"id": current_user.id, "email": current_user.email}
That is a complete, working JWT setup. The /me route will only ever run for a request carrying a valid token, and current_user is available to every protected handler with one line.
Step 6: Refresh Tokens (and Why You Want Them)
Short access-token lifetimes are good for security but annoying for users — nobody wants to log in every 30 minutes. The standard fix is a second, longer-lived refresh token that can mint new access tokens without re-entering a password.
Issue a refresh token (days to weeks) alongside the access token, store a reference to it server-side so it can be revoked, and expose a /refresh endpoint that validates it and returns a fresh access token. The key discipline: access tokens are stateless and short; refresh tokens are revocable and long. Keeping those roles separate is what lets you log a user out everywhere by invalidating one refresh token.
Common Pitfalls
Hardcoding the secret (load it from the environment). Long-lived access tokens with no refresh story (a leaked token stays valid for too long). Putting sensitive data in the payload — JWTs are signed, not encrypted, so anyone can read the claims. And no rotation plan: if you ever need to change the secret, a single key forces a mass logout.
Production Hardening: Key Rotation
The one piece this minimal setup is missing is graceful secret rotation. With a single secret, changing it invalidates every active token at once. A dual-key approach — sign new tokens with a primary key while still accepting a fallback key during verification — lets you rotate without logging everyone out. It is a small change to the decode step (try the primary, fall back to the secondary) and it turns a key change from an incident into a routine config update.
If you would rather not assemble all of this from scratch every time you start a project, this exact pattern — JWT with dual-key rotation, password hashing, and protected routes — ships in ShipKit, our FastAPI boilerplate, and we wrote about the design in Inside ShipKit. But the implementation above is complete and yours to use directly; the boilerplate just saves you re-typing it on project number six.
The Bottom Line
JWT auth in FastAPI comes down to four moving parts: hash passwords, sign a token on login, verify it in a dependency, and attach that dependency to protected routes. Add refresh tokens for usability and a rotation plan for production, and you have an auth layer that scales from a weekend project to a real product. The whole thing is maybe sixty lines — the value is in getting each of those lines right.
Skip the Boilerplate
ShipKit ships production-ready FastAPI auth — JWT with rotation, password hashing, and protected routes — so you can start on the part of your product that is actually yours.
Explore ShipKit