Authentication — OAuth2 + JWT¶
Authentication = "Who is this user?" Authorization = "What can they do?"
This chapter shows the most common production pattern: username/password login that returns a JWT (JSON Web Token). The client then sends that token on every subsequent request.
The flow¶
1. Client POSTs username + password to /login
↓
2. Server verifies (checks DB / hashed password)
↓
3. Server creates a JWT (signed token containing user info + expiry)
↓
4. Server returns the JWT to the client
↓
5. Client stores it (localStorage, cookie, app memory)
↓
6. For every API call:
Client sends `Authorization: Bearer <jwt>`
↓
7. Server validates the signature + expiry → trusts the user
JWTs are stateless — the server doesn't store sessions. The token itself proves who the user is.
Install dependencies¶
python-jose— encode/decode JWTs.passlib[bcrypt]— securely hash passwords.
A complete auth flow¶
# main.py
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
# ----- config -----
SECRET_KEY = "CHANGE-ME-USE-A-LONG-RANDOM-STRING-IN-PROD"
ALGORITHM = "HS256"
TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")
# ----- fake user store -----
fake_users = {
"alice": {
"username": "alice",
# bcrypt hash of "secret123"
"hashed_password": pwd_context.hash("secret123"),
"role": "admin",
}
}
# ----- helpers -----
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def authenticate_user(username: str, password: str):
user = fake_users.get(username)
if not user:
return None
if not verify_password(password, user["hashed_password"]):
return None
return user
def create_access_token(data: dict, expires_in_minutes: int = TOKEN_EXPIRE_MINUTES):
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(minutes=expires_in_minutes)
to_encode["exp"] = expire
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
# ----- the "get current user" dependency -----
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])
username = payload.get("sub")
if not username:
raise credentials_exception
except JWTError:
raise credentials_exception
user = fake_users.get(username)
if user is None:
raise credentials_exception
return user
# ----- endpoints -----
app = FastAPI()
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
@app.post("/login", response_model=TokenOut)
def login(form: OAuth2PasswordRequestForm = Depends()):
user = authenticate_user(form.username, form.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="invalid username or password",
)
token = create_access_token({"sub": user["username"], "role": user["role"]})
return {"access_token": token, "token_type": "bearer"}
@app.get("/me")
def me(user: dict = Depends(get_current_user)):
return {"username": user["username"], "role": user["role"]}
@app.get("/admin")
def admin_only(user: dict = Depends(get_current_user)):
if user["role"] != "admin":
raise HTTPException(status_code=403, detail="admin role required")
return {"message": f"Welcome admin {user['username']}"}
Run with uvicorn main:app --reload.
Try it out¶
1. Get a token¶
Response:
2. Use the token¶
TOKEN="eyJhbGciOi..." # paste from step 1
curl http://127.0.0.1:8000/me \
-H "Authorization: Bearer $TOKEN"
Response:
3. Wrong token / expired token¶
→ 401 Unauthorized.
From Swagger UI¶
/docs has an Authorize button (lock icon). Click it, paste your token, then every request includes the header automatically.
How the execution flows¶
When the client hits GET /me with a token:
- uvicorn receives the request.
- FastAPI sees
Depends(get_current_user)→ calls it first. oauth2_scheme(token)extracts theAuthorization: Bearer ...header.get_current_userdecodes the JWT withSECRET_KEY+ checksexp.- If valid → loads the user from the DB and returns it.
- If invalid → raises
HTTPException(401). - If valid, your endpoint runs with
useralready populated.
The endpoint code itself is clean — no auth boilerplate, just Depends(get_current_user).
What's inside a JWT?¶
A JWT has 3 parts, base64-encoded, joined by dots:
- Header — algorithm and type.
- Payload — the data you put in (
sub,role,exp, etc.). - Signature — HMAC of header + payload using
SECRET_KEY.
Anyone can read the payload (it's just base64). The signature is what proves it wasn't tampered with — only someone with SECRET_KEY can produce a valid one.
Never put secrets (passwords, credit cards) in the payload. It's not encrypted.
Role-based access — a cleaner dependency¶
def require_role(allowed: list[str]):
def checker(user = Depends(get_current_user)):
if user["role"] not in allowed:
raise HTTPException(403, "forbidden")
return user
return checker
@app.get("/admin/dashboard")
def dashboard(user = Depends(require_role(["admin"]))):
return {"hello": user["username"]}
@app.get("/reports")
def reports(user = Depends(require_role(["admin", "manager"]))):
return {"reports": [...]}
Refresh tokens (production hint)¶
Short access tokens (15-30 min) + long refresh tokens (days) is the safe pattern:
- Access token — short-lived, sent on every request.
- Refresh token — long-lived, used once to get a new access token.
- If access is stolen → it expires soon.
- If refresh is stolen → harder, because refresh tokens are stored once and used rarely.
For learning, a single access token is fine. For production, look at libraries like fastapi-users that handle this for you.
Common pitfalls¶
- ❗ Storing
SECRET_KEYin code — use an environment variable. If someone reads your code, they can forge any user's token. - ❗ Long-lived access tokens — 30 days = if stolen, attacker has 30 days. Keep short, refresh often.
- ❗ Plain HTTP — JWTs travel in headers. Without HTTPS, anyone on the network can read them.
- ❗ Storing JWTs in localStorage AND vulnerable to XSS — XSS = attacker reads the token. Consider HttpOnly cookies for browser apps.
- ❗ Storing passwords in plaintext — always hash with bcrypt / argon2 (passlib does it).
- ❗ Forgetting
expclaim — tokens that never expire are forever-valid if leaked. - ❗ Using JWT for everything — for browser apps with sensitive sessions, server-side sessions in Redis can be safer (instant revocation).
What's next¶
Practice¶
What does this print?
Expected: 3
Hash passwords (don't store plaintext)
Expected: True
Quiz — Quick check¶
What you remember
Q1. What does a JWT contain?
- Three base64-encoded parts: header (algo info), payload (claims), signature (HMAC or RSA)
- Just a username and password
- Encrypted user data
- A database connection string
Why: The payload is base64-encoded, NOT encrypted — anyone with the JWT can decode and read it. The signature ensures the token wasn't tampered with. Never put secrets in the payload.
Q2. Why hash passwords with bcrypt/argon2 (not SHA-256)?
- They're intentionally slow, making brute-force attacks impractical even with stolen hashes
- SHA-256 is broken
- Required by law
- Smaller output
Why: SHA-256 is fast — an attacker can try billions of guesses per second on a GPU. bcrypt/argon2 take ~100ms per hash, so even with stolen database hashes, brute-forcing is uneconomical.
Q3. What's the typical JWT expiration?
- Access tokens: 15-60 minutes (short-lived); refresh tokens: days/weeks
- Forever
- 1 second
- 1 year
Why: Short-lived access tokens limit the damage of a leaked token. Refresh tokens let users stay logged in without re-entering credentials. Common pattern: 15-min access + 30-day refresh.
Common doubts¶
Should I roll my own auth or use a library?
Library/service. Auth is one of the easiest things to get subtly wrong (timing attacks, weak hashes, missing CSRF). Use Auth0, Clerk, Supabase Auth, or at minimum python-jose + passlib[bcrypt]. Save your novelty for business logic.
What's the difference between OAuth and JWT?
OAuth is a protocol for granting access (the flows: authorization code, implicit, etc.). JWT is a token format. OAuth often uses JWTs but doesn't require them. They're related but distinct concepts.
Where should I store JWTs on the client?
httpOnly cookies for web apps (safer against XSS). Memory or secure storage for mobile apps. Avoid localStorage — vulnerable to XSS attacks. If you must use it, accept the tradeoff and aggressively mitigate XSS.