[FastAPI Series 10] Getting started with token-based authentication

한국어 버전

Now that a database is attached, we have to protect user-specific data. OAuth2 documents the steps for issuing and validating tokens. JWT (JSON Web Token) is a signed string format commonly used inside that standard. FastAPI ships with OAuth2 helpers, and the OAuth2PasswordBearer dependency makes token flows straightforward.

Before you start

  • Reuse the User model, Session, and get_session dependency from Part 9.
  • Keep a password hashing helper ready—passlib with bcrypt is used here.
  • Load security settings (secret key, token lifetime) from environment variables so secrets never hardcode in source control.

Warm-up: authentication in plain language

  • Login is “checking the password,” and the token is the “stamp that says check complete.”
  • Clients obtain the stamp from an endpoint like /auth/token, then attach it to later requests.
  • The server validates the stamp before showing any user data.

Remember this flow and the code will be easier to read.

Key terms

  1. OAuth2: The standard process of issuing and validating tokens after login. It provides the skeleton for this guide.
  2. JWT: A signed JSON string that stores user info and expiry, letting servers detect tampering quickly.
  3. OAuth2PasswordBearer: The FastAPI dependency that implements the OAuth2 Password grant around /auth/token.
  4. Secret key: The private value used to sign and verify JWTs. Store it in .env; if leaked, anyone can forge tokens.

Study memo

  • Estimated time: 60 minutes
  • Prereqs: Part 9 SQLModel example, basics of password hashing and secret management
  • Goal: Build /auth/token, issue JWTs, and secure routes with token checks

Core ideas

  • Understand why token-based auth matters
  • Implement the OAuth2 Password flow to issue tokens
  • Read tokens inside protected routes to resolve the current user

Essential concepts

OAuth2 standardizes “login → token issuance → token verification.” JWTs (JSON Web Tokens) are a popular token format inside OAuth2. A Bearer token is sent in the Authorization header and literally acts as the credential: anyone holding it is treated as the user until it expires. HTTPS is therefore mandatory—the transport layer must keep the header encrypted in flight. The terminology feels heavy, but mentally rehearse “login → get token → send token with other requests” and you are ready.

Why tokens

To separate user data, every request needs an ID. Cookies, sessions, and tokens all solve this. FastAPI works well with session-free JWT tokens: the server does not maintain per-user session rows, but it can still look up the user in the database when necessary. Mobile, web, and CLI clients can reuse the same token format, and HTTP headers keep the structure simple.

Minimal login example

Experience the entire flow—login through secured /me—in one file. The example fetches one user from the DB, issues a token, and validates it.

The app.core.config, app.db.session, and app.db.models imports refer to the modules we built in Parts 8 and 9. Keep your folder names consistent or adjust the import paths accordingly.

from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import jwt
from passlib.context import CryptContext

from app.core.config import settings
from app.db.session import Session, get_session
from app.db.models import User
from app.api.deps import get_current_user
from sqlmodel import select

router = APIRouter(prefix="/auth", tags=["auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def authenticate_user(session: Session, username: str, password: str) -> User | None:
    user = session.exec(select(User).where(User.email == username)).first()
    if not user or not pwd_context.verify(password, user.hashed_password):
        return None
    return user

def create_access_token(*, subject: str, expires_minutes: int = 30) -> tuple[str, datetime]:
    expires_at = datetime.now(UTC) + timedelta(minutes=expires_minutes)
    payload = {"sub": subject, "exp": expires_at}
    token = jwt.encode(payload, settings.secret_key, algorithm="HS256")
    return token, expires_at

@router.post("/token")
def login(
    form: OAuth2PasswordRequestForm = Depends(),
    session: Session = Depends(get_session),
):
    user = authenticate_user(session, form.username, form.password)
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
    token, expires_at = create_access_token(subject=user.email)
    seconds_until_expiry = int((expires_at - datetime.now(UTC)).total_seconds())
    return {"access_token": token, "token_type": "bearer", "expires_in": seconds_until_expiry}

@router.get("/me")
def read_me(current_user: User = Depends(get_current_user)):
    return {"email": current_user.email, "id": current_user.id}

Reuse the session, models, and secret key from previous parts. pwd_context.verify() compares a plaintext password with the bcrypt hash stored in the database. Always pass algorithms=["HS256"] to jwt.encode/decode so the library never accepts tokens signed with weaker algorithms. Validate the entire login → protected route flow in one place before reorganizing modules. OAuth2 Password is ideal when you control the client (internal tools, official apps). For third-party clients, jump to Authorization Code + PKCE or an external identity provider.

Requests and responses at a glance

Open Swagger UI (/docs) and look for the Authorize button and /auth/token form. The curl equivalent looks like this.

Request

POST /auth/token
Content-Type: application/x-www-form-urlencoded

[email protected]&password=correct-horse

Response

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 1800
}

Swagger UI stores the token automatically and attaches it to future calls. Use “Try it out” to perform login and /me back to back. access_token is a JWT string with three sections separated by dots. Paste it into a decoder such as jwt.io to inspect the header (alg, typ) and payload (sub, exp). The trailing ... above simply truncates the long string for readability.

(Optional) Token flow diagram

User / appPOST /auth/tokenUser storeAccess Token (JWT)Authorization: Bearer <token>Protected router (e.g., POST /tasks) Submit username/passwordVerify credentialsCreate signed JWTReturn JSONAttach token to headeroauth2_scheme extracts tokenLookup user by token subReturn business data

Seeing each hop clarifies where authentication happens, where the token lives, and how protected routes behave. If the terms feel new, revisit this diagram later.

(Optional) Reference skeleton

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

SECRET_KEY = os.getenv("SECRET_KEY", "change-me")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
pwd_context = CryptContext(schemes=["bcrypt"])

router = APIRouter(prefix="/auth", tags=["auth"])

def verify_password(plain, hashed):
    return pwd_context.verify(plain, hashed)

def create_access_token(data: dict, expires_delta: timedelta | None = None):
    to_encode = data.copy()
    expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15))
    to_encode.update({"exp": expire})
    return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)

@router.post("/token")
def login(form_data: OAuth2PasswordRequestForm = Depends(), session: Session = Depends(get_session)):
    user = authenticate_user(session, form_data.username, form_data.password)
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
    token = create_access_token({"sub": user.email})
    return {"access_token": token, "token_type": "bearer"}

authenticate_user fetches the user and compares hashed passwords. OAuth2PasswordRequestForm parses application/x-www-form-urlencoded requests that carry username and password. Now that you have seen the complete flow, this section simply catalogs where each helper lives.

This skeleton mirrors the earlier example but keeps every constant in one place. Remember: a JWT stores identity (sub) and expiry (exp), and jwt.decode() will validate both as long as you specify algorithms=[ALGORITHM].

Build protected endpoints

def get_current_user(
    token: str = Depends(oauth2_scheme),
    session: Session = Depends(get_session),
):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except JWTError as exc:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc
    subject = payload.get("sub")
    if subject is None:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing subject claim")
    user = session.exec(select(User).where(User.email == subject)).first()
    if not user:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
    return user

@tasks_router.post("", response_model=Task)
def create_task(
    task: TaskCreate,
    user: User = Depends(get_current_user),
    session: Session = Depends(get_session),
):
    record = Task(**task.model_dump(), owner_id=user.id)
    session.add(record)
    session.commit()
    session.refresh(record)
    return record

oauth2_scheme extracts the Authorization: Bearer <token> header and raises 401 when it is missing. We decode the token to check signature + expiry, then hit the database to ensure the user still exists (and has not been deactivated) before returning control to business logic.

Test with uv and Swagger

Run uv run fastapi dev app/main.py, click Authorize in Swagger UI, and inspect the /auth/token flow. Once you obtain a token, Swagger adds it to the Authorization header automatically.

:::terminal{title="Issue a token via curl and reuse it", showFinalPrompt="false"}

[
  { cmd: "curl -X POST -d 'username=alice&password=secret' -H 'Content-Type: application/x-www-form-urlencoded' http://127.0.0.1:8000/auth/token", output: "{\"access_token\":\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\",\"token_type\":\"bearer\"}", delay: 600 },
  { cmd: "TOKEN=eyJhbGc...; curl -H \"Authorization: Bearer $TOKEN\" http://127.0.0.1:8000/tasks", output: "[{\"id\":1,\"title\":\"Submit report\"}]", delay: 400 }
]

:::

Why this matters

Without tokens, anyone could call any endpoint, and user data would mix together. JWT-based auth enforces “only logged-in users see their data” directly in code. Swagger UI demonstrates the flow visually, making it easy to onboard teammates.

Production considerations

  • Store SECRET_KEY inside .env or a secret manager—rotate it when people leave the team.
  • Plan refresh tokens or blacklist policies so a logout actually invalidates tokens before they naturally expire.
  • Enforce HTTPS so tokens never travel over plain HTTP, and prefer HttpOnly cookies or encrypted storage on clients.
  • Keep server clocks in sync (NTP) or configure JWT leeway to avoid false expirations.
  • When an organization already has an auth server, configure FastAPI to validate their tokens as an OAuth2 client rather than issuing new ones.

Practice

  • Follow along: Implement /auth/token and get_current_user, then log in through Swagger UI’s Authorize button.
  • Extend: Add role claims to the token and restrict certain routers.
  • Debug: Send invalid, expired, or alg":"none" tokens and ensure 401 responses and log messages are clear.
  • Done when: One curl script can issue + use a JWT, and protected endpoints return 401 without it.

Wrap-up

Implementing token auth reveals how security flows really work. Once you understand both issuing and validating JWTs, you can approach deployment planning with the right guardrails. Part 11 covers configuration, env management, and process strategies for running the authenticated app in production.

💬 댓글

이 글에 대한 의견을 남겨주세요