Skip to content

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

pip install fastapi uvicorn "python-jose[cryptography]" "passlib[bcrypt]"
  • 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

curl -X POST http://127.0.0.1:8000/login \
  -F "username=alice" -F "password=secret123"

Response:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6Ikp...",
  "token_type": "bearer"
}

2. Use the token

TOKEN="eyJhbGciOi..."  # paste from step 1

curl http://127.0.0.1:8000/me \
  -H "Authorization: Bearer $TOKEN"

Response:

{ "username": "alice", "role": "admin" }

3. Wrong token / expired token

curl http://127.0.0.1:8000/me \
  -H "Authorization: Bearer not-a-real-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:

  1. uvicorn receives the request.
  2. FastAPI sees Depends(get_current_user) → calls it first.
  3. oauth2_scheme(token) extracts the Authorization: Bearer ... header.
  4. get_current_user decodes the JWT with SECRET_KEY + checks exp.
  5. If valid → loads the user from the DB and returns it.
  6. If invalid → raises HTTPException(401).
  7. If valid, your endpoint runs with user already 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:

eyJhbGciOiJI...  .  eyJzdWIiOiJhbG...  .  signature
   header              payload
  • 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_KEY in 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 exp claim — 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

# JWT has 3 dot-separated parts: header.payload.signature
token = "eyJhbG.eyJzdWI.signature"
print(len(token.split(".")))

Hash passwords (don't store plaintext)

Expected: True

stored_password = "alice123"     # bug: plaintext password storage — never do this
is_hashed = stored_password.startswith("$2b$") or stored_password.startswith("$argon2")
print(not is_hashed)

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.

Deploy an ML Model with FastAPI