FASTAPI / 5. JWT AUTHENTICATION

JWT Authentication — Signup, Login, Protected Routes

Stateless authentication with JSON Web Tokens and bcrypt


EXPLANATION

JWT (JSON Web Token) is the standard for stateless authentication in REST APIs. No sessions, no server-side state — the token itself carries the user's identity.

How JWT auth works end-to-end:
① User sends POST /auth/token with username + password (form data)
② Server verifies password against bcrypt hash in DB
③ Server creates a JWT: header.payload.signature (base64 encoded, dot separated)
   Payload contains: {"sub": "username", "exp": timestamp}
   Signature: HMAC-SHA256(header + payload, SECRET_KEY) — cannot be forged
④ Server returns {"access_token": "...", "token_type": "bearer"}
⑤ Client sends every subsequent request with: Authorization: Bearer <token>
⑥ Server verifies signature + checks expiry → extracts username → fetches user from DB

Why it's stateless: the server never stores the token. It only stores SECRET_KEY. Any server that knows SECRET_KEY can verify any token — perfect for horizontal scaling.

Security rules you must follow:
• Store SECRET_KEY in environment variables, never in code
• Set short expiry (15–60 minutes for access tokens)
• Use HTTPS always — tokens in transit must be encrypted
• Never store tokens in localStorage (XSS risk) — use httpOnly cookies in production
• Hash passwords with bcrypt — it's intentionally slow to resist brute force

The OAuth2PasswordBearer scheme tells FastAPI to expect a Bearer token in the Authorization header. It adds a padlock icon to /docs automatically.

ARCHITECTURE

SIGNUP FLOW:
  POST /auth/signup  { username, password }
       ↓
  hash_password(password) → bcrypt hash
       ↓
  Save user to DB with hashed password
       ↓
  Return { message: "User created" }

  LOGIN FLOW:
  POST /auth/token  { username, password }  (form data)
       ↓
  Fetch user from DB by username
       ↓
  verify_password(plain, hash) → True/False
       ↓
  create_access_token({"sub": username, "exp": now+15min})
       ↓
  Return { access_token: "eyJ...", token_type: "bearer" }

  PROTECTED ROUTE:
  GET /me   Authorization: Bearer eyJ...
       ↓
  Extract token → decode with SECRET_KEY
       ↓
  Check exp not expired → get "sub" → fetch user
       ↓
  Inject user into route function

CODE

PYTHON
1from fastapi import FastAPI, Depends, HTTPException, status
2from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
3from passlib.context import CryptContext
4from jose import JWTError, jwt
5from datetime import datetime, timedelta, timezone
6from typing import Annotated
7from pydantic import BaseModel
8
9# ── Config ─────────────────────────────────────────────
10SECRET_KEY = "your-secret-key-store-in-env" # os.getenv("SECRET_KEY")
11ALGORITHM = "HS256"
12ACCESS_TOKEN_EXPIRE_MINUTES = 30
13
14app = FastAPI()
15pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
16oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
17
18# fake db
19fake_users_db: dict = {}
20
21# ── Models ─────────────────────────────────────────────
22class UserCreate(BaseModel):
23 username: str
24 password: str
25
26class Token(BaseModel):
27 access_token: str
28 token_type: str
29
30class TokenData(BaseModel):
31 username: str | None = None
32
33# ── Helpers ────────────────────────────────────────────
34def hash_password(password: str) -> str:
35 return pwd_context.hash(password)
36
37def verify_password(plain: str, hashed: str) -> bool:
38 return pwd_context.verify(plain, hashed)
39
40def create_access_token(data: dict) -> str:
41 to_encode = data.copy()
42 expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
43 to_encode["exp"] = expire
44 return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
45
46# ── Auth dependency ────────────────────────────────────
47def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
48 credentials_exception = HTTPException(
49 status_code=status.HTTP_401_UNAUTHORIZED,
50 detail="Could not validate credentials",
51 headers={"WWW-Authenticate": "Bearer"},
52 )
53 try:
54 payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
55 username = payload.get("sub")
56 if username is None:
57 raise credentials_exception
58 except JWTError:
59 raise credentials_exception
60
61 user = fake_users_db.get(username)
62 if user is None:
63 raise credentials_exception
64 return user
65
66# ── Routes ─────────────────────────────────────────────
67@app.post("/auth/signup")
68def signup(user: UserCreate):
69 if user.username in fake_users_db:
70 raise HTTPException(status_code=400, detail="Username already taken")
71 fake_users_db[user.username] = {
72 "username": user.username,
73 "hashed_password": hash_password(user.password),
74 }
75 return {"message": "User created"}
76
77@app.post("/auth/token", response_model=Token)
78def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
79 user = fake_users_db.get(form.username)
80 if not user or not verify_password(form.password, user["hashed_password"]):
81 raise HTTPException(status_code=401, detail="Incorrect username or password")
82 access_token = create_access_token({"sub": form.username})
83 return {"access_token": access_token, "token_type": "bearer"}
84
85@app.get("/me")
86def read_me(current_user: Annotated[dict, Depends(get_current_user)]):
87 return {"username": current_user["username"]}
← PREV4. Dependency InjectionNEXT →6. Database with SQLModel