FastAPI Authentication with OAuth2 and JWT

OAuth2PasswordBearer, issuing and validating JWTs, and a complete protected-route example.

OAuth2 password flow with OAuth2PasswordBearer

FastAPI ships built-in support for the OAuth2 "password" flow purely as a way to standardize how a client sends a token, not as a full OAuth2 provider — OAuth2PasswordBearer tells FastAPI (and the /docs page) that protected endpoints expect an Authorization: Bearer <token> header, and gives you the token as a plain string to validate however you like.

Python
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

tokenUrl="token" just tells the interactive /docs page which endpoint issues tokens, so its "Authorize" button knows where to send a login request from the browser — it has no effect on how your actual /token endpoint is implemented.

Issuing a JWT on login

Python
from datetime import datetime, timedelta, timezone
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from passlib.context import CryptContext

SECRET_KEY = "change-this-to-a-long-random-value-in-production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI()

fake_users_db = {
    "ada": {"username": "ada", "hashed_password": pwd_context.hash("s3cret-pass")},
}

def authenticate_user(username: str, password: str):
    user = fake_users_db.get(username)
    if not user or not pwd_context.verify(password, user["hashed_password"]):
        return None
    return user

def create_access_token(data: dict, expires_delta: timedelta):
    to_encode = data.copy()
    to_encode["exp"] = datetime.now(timezone.utc) + expires_delta
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

@app.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
    user = authenticate_user(form_data.username, form_data.password)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    token = create_access_token(
        data={"sub": user["username"]},
        expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
    )
    return {"access_token": token, "token_type": "bearer"}

OAuth2PasswordRequestForm expects the standard OAuth2 form fields (username, password, sent as form data, not JSON) — this is what lets the /docs page's "Authorize" button work with no extra client code. passlib's CryptContext hashes and verifies passwords with bcrypt; a real user table stores only hashed_password, never the plaintext. "sub" (subject) is the conventional JWT claim for "who this token is about" — here, the username.

Validating the token and protecting a route

Python
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 username is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception

    user = fake_users_db.get(username)
    if user is None:
        raise credentials_exception
    return user

@app.get("/users/me")
def read_current_user(current_user: dict = Depends(get_current_user)):
    return {"username": current_user["username"]}

Depends(oauth2_scheme) extracts the bearer token from the Authorization header (raising a 401 automatically if it's missing entirely). jwt.decode() both verifies the token's signature against SECRET_KEY and checks its expiry — a tampered token or one signed with a different key raises JWTError immediately, and an expired token fails the same way. Because get_current_user is itself a dependency, any number of routes can require authentication just by adding current_user=Depends(get_current_user) to their signature — no logic is duplicated.

Common mistakes

  • Storing plaintext passwords, or comparing them with ==, instead of hashing with something like bcrypt via passlib — a leaked database directly exposes every user's real password.
  • Hardcoding SECRET_KEY in source control instead of loading it from an environment variable — anyone with repository access can then forge valid tokens for any user.
  • Treating a valid JWT signature as proof the token hasn't expired — signature verification and expiry are two separate checks; jwt.decode() does check exp for you, but a hand-rolled decode-only-no-verify approach would miss it entirely.
  • Putting sensitive data (a password, a permission list that changes often) inside the JWT payload itself — anything base64-decoded from the token is readable by the client, and a JWT can't be "edited" server-side once issued the way a session record can.

Interview questions

Q: What does OAuth2PasswordBearer actually provide — is it a full OAuth2 implementation? No — it's a small utility that tells FastAPI to expect a bearer token in the Authorization header, extract it, and document that requirement on the /docs page. It doesn't implement token issuance, refresh flows, or an authorization server; those are entirely up to your own /token endpoint and token logic, as shown by writing create_access_token and get_current_user yourself.

Q: Why is a JWT verified rather than just decoded, and what happens if that check is skipped? A JWT's payload is only base64-encoded, not encrypted — anyone can read its contents without a key. What makes it trustworthy is its signature, computed with a secret (or private) key the server controls; verifying the signature (and the exp claim) confirms the token was actually issued by the server and hasn't expired or been tampered with. Skipping verification and just decoding the payload would let anyone hand-craft a token claiming to be any user at all.