Free lesson · GenAI Agent Engineering

Build OAuth2 password flow with JWT tokens

You will implement the OAuth2 password grant flow. Build POST /auth/token that accepts OAuth2PasswordRequestForm, verifies credentials using passlib.verify(), and returns a JWT access token. Create create_access_token() using python-jose that encodes user_id, email, role, and exp claims with HS256 signing. Set token expiry to 30 minutes. Build get_current_user() dependency that extracts the Bearer token, decodes it, and returns the User from the database. Handle expired and invalid tokens with 401 responses.

Course: Web APIs & Services for GenAI Engineers · Chapter 4 · Authentication & Authorization

Free to read — no subscription required.

Introduction

When you ship a GenAI platform's first login endpoint, three subsystems must agree before a single byte of user data is safe: the OAuth2 protocol layer that defines how credentials arrive and how tokens leave, the bcrypt hashing layer that verifies a password without ever storing it in readable form, and the JWT signing layer that hands the client a stateless ticket for every subsequent request. Get any one wrong — store passwords with SHA-256, leak which emails exist via distinct error messages, sign tokens with a guessable key — and an attacker who reaches your database or your traffic can impersonate every user on day one.

By the end of this lesson you'll be able to wire FastAPI's OAuth2PasswordBearer and OAuth2PasswordRequestForm into a /token endpoint, hash and verify passwords with passlib's bcrypt scheme, and issue HS256 JWTs that a get_current_user dependency validates on every protected route.

Key Terminology

  • OAuth2 Password Grant — the RFC 6749 grant where the client posts username and password as application/x-www-form-urlencoded to a token endpoint and receives an access token; the simplest grant FastAPI ships helpers for, and the right starting point when you own both the client and the server.
  • Bcrypt — an adaptive password hash with a tunable cost factor (default 12 ⇒ 2¹² rounds) and a per-password salt embedded in the output string; slow on purpose so a stolen hash dump can't be brute-forced cheaply.
  • JWT (JSON Web Token) — a base64url-encoded header.payload.signature triple that carries the user identity (sub), expiry (exp), and any custom claims; verifying the signature with the server's secret is sufficient to trust the payload without a session lookup.
  • OAuth2PasswordBearer — FastAPI's security dependency that declares a token-bearing scheme, points clients at a tokenUrl, and extracts the Bearer token from the Authorization header on every protected route.
  • HS256 — HMAC-SHA256, a symmetric JWT signing algorithm where the same secret signs and verifies; correct for a single-service API, wrong if you need third parties to verify tokens (use RS256 then).

Concepts

OAuth2 Password Grant on FastAPI

The password grant is a single round-trip: the client posts form-encoded username and password to /token, the server replies with {"access_token": "...", "token_type": "bearer"} on success or HTTP 401 on failure. FastAPI gives you two cooperating classes — OAuth2PasswordRequestForm parses the incoming form into a typed object with .username / .password, and OAuth2PasswordBearer declares the scheme so Swagger UI knows where to log in and protected routes know how to extract the bearer token. The strict order — load user, then verify password, then sign token — matters because every failure branch must collapse into the same 401 with the same body, or you've handed attackers an account-enumeration oracle (see Code Walkthrough).

Loading diagram...

Bcrypt Hashing and Verification

Plaintext passwords and fast hashes (MD5, SHA-256) are equivalent failure modes once a database leaks: GPU rigs crack billions of fast hashes per second. Bcrypt is intentionally slow — the cost factor controls iteration count, the per-password salt is embedded in the 60-character $2b$12$... output, and passlib's CryptContext does constant-time comparison so verification can't be timing-attacked. Cost 12 takes ~250 ms per hash on modern hardware: comfortable for human logins, painful for test suites that create hundreds of users (drop a separate context to rounds=4 for tests, never for production).

JWT Issuance and the get_current_user Dependency

Once the password checks out, you mint a JWT. The token is stateless: every protected request carries it in Authorization: Bearer ..., and the server validates it by re-computing the HMAC against SECRET_KEY — no DB round-trip needed. The key claims are sub (subject — the user's email), exp (expiry — a timezone-aware UTC datetime), and any custom claims like role for downstream RBAC. A reusable FastAPI dependency, get_current_user, decodes the token, looks up the user, checks is_active, and either returns a User or raises HTTP 401 with the WWW-Authenticate: Bearer header that RFC 6750 requires.

Code Walkthrough

The two snippets below demonstrate every concept above end to end: the first defines the User model and the bcrypt helpers; the second wires the /token endpoint and the get_current_user dependency.

Code snippetpython
1from sqlalchemy import Column, Integer, String, Boolean 2from sqlalchemy.orm import declarative_base 3from passlib.context import CryptContext 4 5Base = declarative_base() 6pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") 7 8class User(Base): 9 __tablename__ = "users" 10 11 id = Column(Integer, primary_key=True, index=True) 12 email = Column(String, unique=True, index=True, nullable=False) 13 hashed_password = Column(String, nullable=False) 14 role = Column(String, default="viewer") 15 is_active = Column(Boolean, default=True) 16 17def hash_password(plain_password: str) -> str: 18 return pwd_context.hash(plain_password) 19 20def verify_password(plain_password: str, hashed_password: str) -> bool: 21 return pwd_context.verify(plain_password, hashed_password)
  • CryptContext(schemes=["bcrypt"], deprecated="auto") lets you add a stronger scheme later and have passlib auto-rehash on next login.
  • hashed_password stores the full $2b$12$<22-char-salt><31-char-hash> string — scheme, cost, salt, and hash in one portable column.
  • verify_password extracts salt + cost from the stored hash, recomputes, and compares in constant time; it never returns the stored hash.
Code snippetpython
1from datetime import datetime, timedelta, timezone 2from fastapi import APIRouter, Depends, HTTPException, status 3from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm 4from jose import jwt, JWTError 5from sqlalchemy.orm import Session 6 7SECRET_KEY = "load-from-env-never-hardcode" # os.environ["SECRET_KEY"] in prod 8ALGORITHM = "HS256" 9ACCESS_TOKEN_EXPIRE_MINUTES = 30 10 11oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token") 12router = APIRouter() 13 14def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: 15 to_encode = data.copy() 16 expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15)) 17 to_encode.update({"exp": expire}) 18 return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 19 20async def get_current_user( 21 token: str = Depends(oauth2_scheme), 22 db: Session = Depends(get_db), 23) -> User: 24 credentials_exception = HTTPException( 25 status_code=status.HTTP_401_UNAUTHORIZED, 26 detail="Could not validate credentials", 27 headers={"WWW-Authenticate": "Bearer"}, 28 ) 29 try: 30 payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) 31 email: str | None = payload.get("sub") 32 if email is None: 33 raise credentials_exception 34 except JWTError: 35 raise credentials_exception 36 37 user = db.query(User).filter(User.email == email).first() 38 if user is None or not user.is_active: 39 raise credentials_exception 40 return user 41 42@router.post("/token") 43async def login( 44 form_data: OAuth2PasswordRequestForm = Depends(), 45 db: Session = Depends(get_db), 46): 47 user = db.query(User).filter(User.email == form_data.username).first() 48 if not user or not verify_password(form_data.password, user.hashed_password): 49 raise HTTPException( 50 status_code=status.HTTP_401_UNAUTHORIZED, 51 detail="Incorrect email or password", 52 headers={"WWW-Authenticate": "Bearer"}, 53 ) 54 access_token = create_access_token( 55 data={"sub": user.email, "role": user.role}, 56 expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES), 57 ) 58 return {"access_token": access_token, "token_type": "bearer"}
  • datetime.now(timezone.utc) — never datetime.utcnow(); the latter returns a naive datetime that breaks expiry checks across timezones.
  • The if not user or not verify_password(...) short-circuit collapses "no such user" and "wrong password" into one identical 401, killing the enumeration oracle.
  • get_current_user re-raises the same credentials_exception on missing sub, JWTError, missing user, and inactive user — four failure modes, one response, zero leaked signal.

You'll know it works when (a) curl -X POST -d 'username=alice@x.com&password=secret' /token returns a JSON body with access_token and token_type: bearer, (b) the same call with a wrong password returns HTTP 401 with WWW-Authenticate: Bearer and a body indistinguishable from the unknown-user case, and (c) curl -H "Authorization: Bearer <token>" /protected resolves get_current_user to the right User row.

Do's and Don'ts

Do's

  1. Do load SECRET_KEY from the environment — generate ≥32 bytes with openssl rand -hex 32 and read it from .env or your secret store; never commit it.
  2. Do use datetime.now(timezone.utc) for exp — timezone-aware UTC is the only safe expiry; naive datetimes silently misbehave when the server moves regions.
  3. Do return one identical 401 for every auth failure — same status, same body, same WWW-Authenticate: Bearer header, whether the user is missing, the password is wrong, or the token is expired.

Don'ts

  1. Don't substitute SHA-256 or MD5 for bcrypt — fast hashes are crackable at GPU speeds; the work factor is the point.
  2. Don't omit the WWW-Authenticate: Bearer header on 401s — OAuth2 clients and API gateways depend on it to trigger re-auth, and RFC 6750 requires it.
  3. Don't lower the bcrypt cost in production to speed up logins — drop it only in test fixtures; production stays at 12 or higher.

This lesson is free to read. Its 2 hands-on labs — real code, in a cloud IDE — are part of the GenAI Agent Engineering subscription.

From · cancel anytime · Already a subscriber? Sign in →

More free lessons in Web APIs & Services for GenAI Engineers

All free lessons in GenAI Agent Engineering