Auth & Security — Cheat Sheet
FastAPI · 4 topics. Download the PDF or the Instagram carousel and share it.
OAuth2 Password Flow — Login That Issues Tokens
OAuth2PasswordRequestForm receives username/password at /token, the app returns a bearer token, and OAuth2PasswordBearer extracts it on every protected call — the login skeleton behind the Swagger Authorize button.
- ✓OAuth2PasswordRequestForm parses the form-encoded login; response is {access_token, token_type}
- ✓OAuth2PasswordBearer extracts the bearer token and 401s when it is missing
- ✓Tokens are stateless — any instance verifies; no sticky sessions, no CSRF cookies
- ✓Login failures stay vague (no user enumeration) and carry WWW-Authenticate: Bearer
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
app = FastAPI()
# Extracts "Authorization: Bearer xyz" → "xyz"; 401 if header missing
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
FAKE_DB = {"asha@nitk.edu.in": {"name": "Asha", "hashed": "bcrypt$..."}}
@app.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends()):
user = FAKE_DB.get(form.username) # form fields: username, password
password_ok = user is not None # verify_password() → next chapter
if not user or not password_ok:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="incorrect username or password", # deliberately vague:
headers={"WWW-Authenticate": "Bearer"}, # never reveal WHICH was wrong
)
return {
"access_token": "fake-token-" + form.username, # real JWT → next chapter
"token_type": "bearer", # spec-required shape
}
@app.get("/profile")
def profile(token: str = Depends(oauth2_scheme)):
return {"your_token": token} # decoding it comes next
# The wire format:
# POST /token Content-Type: application/x-www-form-urlencoded
# username=asha%40nitk.edu.in&password=s3cret
# → {"access_token": "...", "token_type": "bearer"}
# GET /profile Authorization: Bearer <access_token>Password Hashing — bcrypt/Argon2 Done Right
Store only slow, salted hashes (Argon2 or bcrypt via pwdlib) — never plaintext, never MD5/SHA-256. hash() on registration, verify() on login, and rehash transparently as algorithms age.
- ✓Only slow + salted algorithms: Argon2id (preferred) or bcrypt — via pwdlib
- ✓Same password hashes differently every time; verify() reads the salt from the hash
- ✓verify_and_update enables transparent rehashing as parameters age
- ✓Hashing protects the stored secret; rate-limiting protects the login endpoint
# pip install "pwdlib[argon2,bcrypt]"
from pwdlib import PasswordHash
pwd = PasswordHash.recommended() # Argon2id today; updated as research moves
# ── registration ──
h1 = pwd.hash("MySecret@2026")
h2 = pwd.hash("MySecret@2026")
print(h1 == h2) # False — unique salt baked into each hash
print(h1) # $argon2id$v=19$m=65536,t=3,p=4$...salt...$...
# ── login ──
print(pwd.verify("MySecret@2026", h1)) # True
print(pwd.verify("MySecret@2026", h2)) # True — salt read from the hash itself
print(pwd.verify("wrong-guess", h1)) # False (constant-time comparison inside)
# Why NOT sha256:
# import hashlib; hashlib.sha256(b"pass").hexdigest()
# - designed to be FAST → GPU rigs try tens of billions of guesses/second
# - unsalted → one rainbow table cracks every user with "password@123"
# Argon2/bcrypt: ~50-300ms EACH by design, unique salt per hash →
# the same leak takes centuries instead of a weekend.
# Cost tuning: aim ~100-300ms per hash on YOUR production hardware —
# slow enough to hurt attackers, fast enough for login UX.JWT — Creating and Verifying Tokens
A JWT is signed claims: jwt.encode() issues it with sub + exp, jwt.decode() verifies signature and expiry, and get_current_user turns all of it into one reusable dependency — with revocation as the honest trade-off.
- ✓JWT = signed claims: readable by anyone, tamper-proof via the signature
- ✓jwt.decode verifies signature and exp together; pin algorithms=[...] always
- ✓Short access tokens + refresh tokens is the standard UX/security balance
- ✓Instant revocation needs state: token_version claim checked against the DB
# pip install pyjwt
from datetime import datetime, timedelta, timezone
import jwt
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import OAuth2PasswordBearer
SECRET = "from-Settings-never-hardcoded" # settings.jwt_secret, 32+ random bytes
ALGO = "HS256"
TTL = timedelta(minutes=30)
app = FastAPI()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
def create_access_token(email: str, role: str) -> str:
now = datetime.now(timezone.utc)
claims = {
"sub": email, # subject — whose token
"role": role, # custom claim (readable by anyone!)
"iat": now, # issued at
"exp": now + TTL, # pyjwt REJECTS after this, automatically
}
return jwt.encode(claims, SECRET, algorithm=ALGO)
def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
try:
claims = jwt.decode(token, SECRET, algorithms=[ALGO]) # verifies sig + exp
except jwt.ExpiredSignatureError:
raise HTTPException(401, "token expired",
headers={"WWW-Authenticate": "Bearer"})
except jwt.InvalidTokenError: # bad signature, malformed, wrong algo
raise HTTPException(401, "invalid token",
headers={"WWW-Authenticate": "Bearer"})
return {"email": claims["sub"], "role": claims["role"]}
@app.get("/profile")
def profile(user: dict = Depends(get_current_user)):
return user
# eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhc2hhQG5pdGsu... ← header.payload.signature
# The payload is only base64 — decodable by ANYONE (jwt.io).
# The signature is what stops modification. Secrets do NOT belong in claims.RBAC & Scopes — Roles, Permissions, Ownership
Layer authorization: coarse roles via require_role, fine-grained permissions via a role→permission map, OAuth2 scopes via SecurityScopes when tokens must carry grants — plus the ownership checks RBAC alone misses.
- ✓Layer it: role gate → permission map → object ownership, each catching what the last misses
- ✓Check permissions ("drives:create"), not roles — policy changes touch one map
- ✓Scopes are per-token grants enforced via SecurityScopes/Security(); ideal for machine access
- ✓IDOR: valid users reading others' objects — ownership check on every access, return 404
from fastapi import Depends, FastAPI, HTTPException
app = FastAPI()
ROLE_PERMISSIONS = {
"student": {"drives:read", "applications:create", "profile:write"},
"tpo": {"drives:read", "drives:create", "drives:close",
"students:read", "reports:read"},
"admin": {"*"}, # everything
}
def require_permission(permission: str):
def checker(user: dict = Depends(get_current_user)) -> dict: # JWT chapter
perms = ROLE_PERMISSIONS.get(user["role"], set())
if "*" not in perms and permission not in perms:
raise HTTPException(403, f"requires permission: {permission}")
return user
return checker
@app.post("/drives", status_code=201)
def create_drive(user: dict = Depends(require_permission("drives:create"))):
return {"created_by": user["email"]}
@app.get("/reports/placements")
def placement_report(user: dict = Depends(require_permission("reports:read"))):
return {"placed": 212, "total": 260}
# Why permissions beat raw roles:
# "TPOs can now also close drives" → add one string to the tpo set. Done.
# New "placement-coordinator" role → one new dict entry. Done.
# With require_role("tpo") sprinkled on endpoints, both changes mean
# hunting every endpoint that mentions a role. The map is the policy.
# At scale the map moves to the DB (editable per tenant), and the
# gyaan-api pattern applies: org_id scoping + role per membership.