Cheat SheetsFastAPIAuth & Security

Auth & Security — Cheat Sheet

FastAPI · 4 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
Auth & Security
FastAPI4 topicsQuick revision reference
1

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
Form in, {access_token, token_type} out, Bearer on every call
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>
2

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
Slow + salted, or it is not password hashing
# 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.
3

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
sub + exp + signature: readable by all, forgeable by none
# 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.
4

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
One policy map; endpoints declare capabilities they need
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.
Learn this free with Aria, your AI tutor → AiCanCode.org/learn/fastapi