OAuth2 Password Flow & JWT

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13, Starlette 1.7, PyJWT 2.15 and pwdlib 0.3, checked on 2026-10-02; the code was executed on Python 3.13. It builds the login system of the Bookshelf API end to end: a /token endpoint that checks a username and password against an Argon2 hash and returns a signed JWT, and dependencies that turn the Authorization: Bearer header of later requests back into a user. The previous page, Security Fundamentals, covers the security building blocks this page relies on (HTTPS first, the fastapi.security classes, 401 versus 403); the next, OAuth2 Scopes & Authorization, adds permissions on top of the token created here. The protocol theory (grants, token types, JOSE) lives in the OAuth Reference and is linked, not repeated.

The Big Picture

A password flow login in FastAPI has four moving parts, each one small:

  • A form endpoint (POST /token) receives username and password as application/x-www-form-urlencoded, parsed by the OAuth2PasswordRequestForm dependency.

  • A password hasher (pwdlib with Argon2) checks the password against the stored hash. Plaintext passwords are never stored.

  • A token signer (PyJWT) packs the user’s identity (sub) and expiry (exp) into a JWT signed with a secret key and returns it as {"access_token": …​, "token_type": "bearer"}.

  • A security scheme dependency (OAuth2PasswordBearer) reads the Authorization: Bearer <token> header on later requests. Your get_current_user dependency decodes and verifies the token and loads the user.

sequenceDiagram autonumber actor U as Client (first-party app) participant A as FastAPI app participant H as pwdlib (Argon2) participant J as PyJWT participant DB as User store U->>A: POST /token (form: username, password) A->>DB: load user by username A->>H: verify(password, stored hash) H-->>A: match A->>J: encode(sub, iat, exp) with secret from settings J-->>A: signed JWT A-->>U: 200 {access_token, token_type: bearer} Note over U: keeps the token in memory U->>A: GET /users/me (Authorization: Bearer token) A->>J: decode(token, secret, algorithms=[HS256]) J-->>A: claims (sub, exp) or InvalidTokenError A->>DB: load user by sub DB-->>A: user A-->>U: 200 user (or 401 + WWW-Authenticate: Bearer)
Figure 1. Login, token, then an authenticated request

The OpenAPI document gets an oauth2 security scheme with a password flow, so Swagger UI shows an Authorize button; see Swagger UI and the Authorize button.

The Signing Key Comes from Settings

A JWT signature is only as secret as the key behind it: anyone who knows the key can mint a token for any user. The key therefore must never appear in source code, in a repository, in an example, or in a container image. Load it from the environment with pydantic-settings, with no default so that a missing key stops the application at startup instead of silently running with a guessable one. The general settings pattern (.env, lru_cache, environments) is covered in Settings, Lifespan & Background Tasks.

config.py
from functools import lru_cache

from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    # No default: the app refuses to start without BOOKSHELF_JWT_SECRET_KEY.
    jwt_secret_key: SecretStr
    jwt_algorithm: str = "HS256"
    access_token_expire_minutes: int = 30

    model_config = SettingsConfigDict(env_prefix="BOOKSHELF_", env_file=".env")


@lru_cache
def get_settings() -> Settings:
    return Settings()

Generate a random key once per environment and hand it to the process through its environment (a secret manager, a Kubernetes Secret, a CI variable), not through a file in the repository:

# Generate a 256-bit random key (the official docs use exactly this command)
openssl rand -hex 32

# Run the app with the key in the environment (placeholder, use your own generated value)
export BOOKSHELF_JWT_SECRET_KEY="<paste the generated key here>"
fastapi dev main.py
  • SecretStr makes Pydantic print instead of the value in repr(), logs and tracebacks. Read the real value with get_secret_value() only where you sign or verify.

  • env_prefix="BOOKSHELF_" means the variable is BOOKSHELF_JWT_SECRET_KEY. Environment variable names are case-insensitive by default.

  • Without the variable, Settings() fails with a Pydantic validation error that names the field (Field required), so the process exits at the first request that builds the settings; it never signs tokens with an empty key.

  • Never commit a .env file that holds the key; add it to .gitignore. Rotating the key invalidates every token signed with the old one (see RS256 and key rotation).

  • An HS256 key should have at least 256 bits of entropy (32 random bytes); a short human-chosen string is brute-forceable offline from any token the attacker observes.

Password Hashing with pwdlib and Argon2

Store a one-way hash of each password, so a stolen database does not disclose the passwords (which users reuse across sites). A general-purpose hash such as SHA-256 is far too fast: use a password hashing function that is deliberately slow and memory-hard, with a random per-password salt. Argon2 (RFC 9106; argon2id variant) is the current recommendation of the FastAPI documentation and of the OWASP Password Storage Cheat Sheet.

FastAPI’s tutorial uses pwdlib, installed with its Argon2 extra:

uv add "pwdlib[argon2]" pyjwt
# or: pip install "pwdlib[argon2]" pyjwt
hashing.py
from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()   # Argon2id with default parameters

stored = password_hash.hash("example-password-1")
print(stored.split("$")[1:4])                # ['argon2id', 'v=19', 'm=65536,t=3,p=4']

print(password_hash.verify("example-password-1", stored))   # True
print(password_hash.verify("wrong-guess", stored))          # False
print(stored == password_hash.hash("example-password-1"))   # False: random salt

Output from a real run:

['argon2id', 'v=19', 'm=65536,t=3,p=4']
True
False
False

What the hash string contains: the algorithm (argon2id), its version, the cost parameters (m memory in KiB, t iterations, p parallelism), the random salt and the digest, all in one string, so verify needs only the password and the stored string. Points to remember:

  • PasswordHash.recommended() returns an instance with Argon2 and default parameters. hash(password) and verify(password, hash) are the two calls you need (note the argument order in verify: password first, hash second).

  • verify returns False on a mismatch and raises pwdlib.exceptions.UnknownHashError if the stored string is not a hash format any configured hasher recognizes.

  • Hashing is intentionally expensive (tens of milliseconds and 64 MiB of memory with the defaults). It is blocking, CPU-bound code: inside an async def path function it stalls the event loop. For a busy login endpoint, define the function with def so FastAPI runs it in the thread pool, or use run_in_threadpool / anyio.to_thread.run_sync; see Async & Concurrency.

  • Tune cost with Argon2Hasher(time_cost=…​, memory_cost=…​, parallelism=…​) when you build PasswordHash yourself; the defaults (time_cost=3, memory_cost=65536, parallelism=4, hash_len=32, salt_len=16, Argon2id) are reasonable. Raise them until a hash takes the longest you can afford on your hardware.

  • Never log or return the hash, and keep it out of response models (see the User / UserInDB split below).

Migrating Existing bcrypt Hashes

Older tutorials hash with bcrypt through passlib. You can keep verifying those hashes and silently upgrade each to Argon2 the next time its owner logs in. Install the bcrypt extra (pwdlib[argon2,bcrypt]) and list the hashers with the current one first; verify_and_update returns whether the password matched and, if the stored hash is not in the current algorithm (or needs a rehash), a fresh hash to save:

migrate_hashes.py
from pwdlib import PasswordHash
from pwdlib.exceptions import UnknownHashError
from pwdlib.hashers.argon2 import Argon2Hasher
from pwdlib.hashers.bcrypt import BcryptHasher

# First hasher is the current one (used by hash()); the rest only verify.
migrating = PasswordHash((Argon2Hasher(), BcryptHasher()))

legacy = BcryptHasher().hash("example-password-1")   # stands in for a stored $2b$... hash

ok, new_hash = migrating.verify_and_update("example-password-1", legacy)
print(ok, new_hash.startswith("$argon2id$"))   # True True: save new_hash for this user

ok, new_hash = migrating.verify_and_update("example-password-1", new_hash)
print(ok, new_hash)                            # True None: already current

print(migrating.verify_and_update("wrong-guess", legacy))   # (False, None)

try:
    PasswordHash.recommended().verify("example-password-1", legacy)
except UnknownHashError:
    print("recommended() alone cannot read bcrypt hashes")

In the login endpoint this becomes: verify with verify_and_update, and if the returned hash is not None, write it back to the user row in the same request. Accounts that never log in keep their bcrypt hash; decide a deadline after which you force a password reset for them. Hash strings from other systems (Django, Flask extensions) are another reason to configure extra hashers; pwdlib does not include legacy algorithms beyond bcrypt, so for exotic legacy formats the FastAPI tutorial points to passlib for the migration period only.

The User Models and a Demo Store

The tutorial keeps users in a dictionary, which is enough to learn the flow; the Bookshelf API later swaps it for the SQLModel table from SQL Databases with SQLModel. Two models separate what leaves the API from what stays inside:

from pydantic import BaseModel

from security import password_hash


class User(BaseModel):                 # safe to return to clients
    username: str
    email: str | None = None
    full_name: str | None = None
    disabled: bool = False


class UserInDB(User):                  # adds the secret part, stays server-side
    hashed_password: str


# Demo "database". The hashes are computed when the module loads, never pasted.
fake_users_db: dict[str, UserInDB] = {
    "reader": UserInDB(
        username="reader",
        email="reader@example.com",
        full_name="Ada Reader",
        hashed_password=password_hash.hash("example-password-1"),
    ),
    "sleeper": UserInDB(
        username="sleeper",
        hashed_password=password_hash.hash("example-password-2"),
        disabled=True,
    ),
}

Because the path functions declare → User as the return type, FastAPI filters the response through User even when the object returned is a UserInDB: the hashed_password field can never leak (see Response Models & Return Types). Hash the demo passwords at startup as above instead of pasting a hash string into the code; a pasted hash looks like a secret to scanners, ages badly when you change parameters, and tempts people to reuse it.

Hashing and Token Helpers

security.py
from datetime import datetime, timedelta, timezone

import jwt
from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()
# Hash of a throwaway value, computed at startup. Verifying against it for
# unknown usernames keeps the response time close to the "wrong password" case.
DUMMY_HASH = password_hash.hash("not-a-real-password")


def create_access_token(
    subject: str,
    secret: str,
    algorithm: str = "HS256",
    expires_delta: timedelta = timedelta(minutes=15),
) -> str:
    now = datetime.now(timezone.utc)
    claims = {"sub": subject, "iat": now, "exp": now + expires_delta}
    return jwt.encode(claims, secret, algorithm=algorithm)
  • datetime.now(timezone.utc) is timezone-aware. PyJWT converts a datetime in exp / iat to a numeric date (seconds since the epoch, RFC 7519 NumericDate) and compares against the current UTC time when decoding. The old idiom datetime.utcnow() returns a naive datetime, has been deprecated since Python 3.12 and should not be used.

  • DUMMY_HASH closes a timing side channel: if "unknown user" returned instantly while "wrong password" took tens of milliseconds in Argon2, an attacker could enumerate valid usernames by measuring response time. Verifying a password against a dummy hash for unknown users equalizes the work. The login code below does this.

JWT with PyJWT

A JWT (JSON Web Token, RFC 7519) is three Base64URL strings joined by dots: a header (alg, typ, optionally kid), a payload of claims, and a signature. It is signed, not encrypted: anybody holding the token can read the claims, so never put anything secret (a password, a hash, personal data you would not show the client) into it. What the signature guarantees is that nobody can change the claims without the key. The OAuth Reference explains the format and its attacks in JWT & the JOSE Family; here is how PyJWT exposes it.

pip install pyjwt              # HS256
pip install "pyjwt[crypto]"    # adds RS256, ES256 and friends (pulls in cryptography)

Encode, Decode and the Claims

jwt_basics.py
import secrets
from datetime import datetime, timedelta, timezone

import jwt

secret = secrets.token_urlsafe(48)           # throwaway key for this demo only
now = datetime.now(timezone.utc)

token = jwt.encode(
    {"sub": "reader", "iat": now, "exp": now + timedelta(minutes=30)},
    secret,
    algorithm="HS256",
)
print(jwt.get_unverified_header(token))      # {'alg': 'HS256', 'typ': 'JWT'}

claims = jwt.decode(token, secret, algorithms=["HS256"])
print(claims)                                # {'sub': 'reader', 'iat': 1790931288, 'exp': 1790933088}

The claims used here are registered claims from RFC 7519:

Claim Meaning Notes

sub

Subject: who the token is about

Use a stable, unique identifier (a username that never changes, or better the user’s id) as a string. PyJWT 2.10 and later rejects a non-string sub with InvalidSubjectError. FastAPI’s docs add that sub should be unique across the whole application.

exp

Expiration time: after this instant the token must be rejected

Mandatory in practice. Without it a stolen token is valid forever. Make PyJWT insist on it with options={"require": ["exp", "sub"]}.

iat

Issued at

Useful for audits and for rejecting tokens issued before a password change. PyJWT rejects a token whose iat lies in the future.

nbf

Not before

Optional; rarely needed for session tokens.

iss, aud

Issuer and audience

Needed when several services issue or accept tokens. Pass issuer=…​ and audience=…​ to decode; a token with an aud claim fails with InvalidAudienceError if you pass no audience.

jti

Unique token id

Needed for revocation lists and refresh-token rotation (see refresh tokens).

Verification Failures and InvalidTokenError

Every PyJWT validation failure is an exception derived from jwt.exceptions.InvalidTokenError, so one except clause covers expired, tampered, malformed and incomplete tokens. Executed results:

Situation Exception Message

exp in the past

ExpiredSignatureError

Signature has expired

Signed with a different key

InvalidSignatureError (a DecodeError)

Signature verification failed

Not a JWT at all

DecodeError

Invalid header padding

No algorithms= argument

DecodeError

It is required that you pass in a value for the "algorithms" argument when calling decode().

Token’s alg not in your allow-list (including alg: none)

InvalidAlgorithmError

The specified alg value is not allowed

Required claim absent

MissingRequiredClaimError

Token is missing the "sub" claim

sub not a string

InvalidSubjectError

Subject must be a string

aud mismatch

InvalidAudienceError

Audience doesn’t match

The jwt.decode call has defences you should keep:

  • Always pass algorithms=[…​] (an allow-list; the library refuses to decode without it). Never derive the algorithm from the token’s own header: that is the root of the “alg: none” and HS256-versus-RS256 key-confusion attacks described in RFC 8725. The same list is what makes a token signed with the public RSA key as an HMAC secret fail.

  • Use options={"require": ["exp", "sub"]}. By default PyJWT validates exp only if the claim is present, so a token that omits exp would otherwise be accepted and live forever.

  • leeway=10 (seconds or a timedelta) tolerates small clock differences between servers; a token expired one second ago decodes with leeway=10.

  • Do not call jwt.decode(…​, options={"verify_signature": False}) to authenticate anybody. It is for debugging (reading exp in a log line), never for access decisions.

HS256 versus RS256

HS256 (HMAC-SHA-256) RS256 (RSA-SHA-256) and similar (ES256, EdDSA)

Key

One shared secret

A private key signs; a public key verifies

Who can mint tokens

Everyone who can verify (they hold the same secret)

Only the holder of the private key

Best for

One application that both issues and verifies its own tokens (the Bookshelf API)

An identity provider issuing tokens that many separate services (or third parties) verify, and anything using JWKS and key rotation with kid

Install

pyjwt

pyjwt[crypto]

Rotation

Replace the secret everywhere at once; all sessions end

Publish the new public key under a new kid; keep the old one until old tokens expire

Both work with the same two calls; only the key material changes. This was executed with a freshly generated 2048-bit RSA key pair:

rs256.py
from datetime import datetime, timedelta, timezone

import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa

key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = key.private_bytes(
    serialization.Encoding.PEM,
    serialization.PrivateFormat.PKCS8,
    serialization.NoEncryption(),
)
public_pem = key.public_key().public_bytes(
    serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo
)

now = datetime.now(timezone.utc)
token = jwt.encode(
    {"sub": "reader", "exp": now + timedelta(minutes=5)},
    private_pem,
    algorithm="RS256",
    headers={"kid": "key-2026-10"},       # which key signed this
)
print(jwt.get_unverified_header(token))   # {'alg': 'RS256', 'kid': 'key-2026-10', 'typ': 'JWT'}
print(jwt.decode(token, public_pem, algorithms=["RS256"]))

In a real deployment the private key is loaded from a secret store (for example as a PEM string in a setting typed SecretStr), generated once with a tool rather than at startup, and the verifying services fetch the public key from a JWKS endpoint. Verifying third-party tokens with JWKS is covered in OAuth2 Scopes & Authorization. If the same app both signs and verifies, HS256 with a strong key is simpler and just as safe.

OAuth2PasswordBearer and OAuth2PasswordRequestForm

Two classes in fastapi.security implement the password flow. They do different jobs and should not be confused.

OAuth2PasswordBearer is a security scheme: a dependency that reads Authorization: Bearer <token> and returns the token string (or fails with 401). It also declares the scheme in OpenAPI.

Parameter Effect

tokenUrl (required)

The URL (relative to the API) of the endpoint that issues tokens, i.e. the path operation with OAuth2PasswordRequestForm. Swagger UI posts the login form there. It is relative: "token" resolves against the app’s URL, which matters behind a proxy (see Middleware, CORS & Proxies).

scheme_name

The scheme’s name in components.securitySchemes (default OAuth2PasswordBearer).

scopes

A dict[str, str] of scope names and descriptions, listed in Swagger UI’s Authorize dialog (see the scopes page).

description

Free text shown in the OpenAPI document and the Authorize dialog.

auto_error

True (default): a missing or non-Bearer header ends the request with 401. False: the dependency returns None instead, which is how you implement optional authentication (for example public books that show extra fields to signed-in users) or accept the token from alternative places such as a cookie.

refreshUrl

Documentation only: advertises a refresh endpoint in OpenAPI. FastAPI does not implement refreshing.

options.py (OpenAPI output of the options above)
oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="token",
    scheme_name="BookshelfBearer",
    description="Log in with a Bookshelf username and password.",
    refreshUrl="token/refresh",
    auto_error=False,
)
{
  "BookshelfBearer": {
    "type": "oauth2",
    "description": "Log in with a Bookshelf username and password.",
    "flows": {
      "password": {
        "refreshUrl": "token/refresh",
        "scopes": {},
        "tokenUrl": "token"
      }
    }
  }
}

OAuth2PasswordRequestForm is a form dependency for the token endpoint. It declares these form fields (it needs python-multipart, part of fastapi[standard]):

Field Required Notes

username

yes

The spec requires this exact name, even if your login is an e-mail address.

password

yes

Marked format: password in OpenAPI so Swagger UI masks it.

grant_type

no

The spec says it must be the fixed string password; this class is permissive and accepts it missing. OpenAPI shows the pattern ^password$.

scope

no

One string of space-separated scopes. The dependency exposes it as the list form_data.scopes.

client_id, client_secret

no

Optional client credentials in the body. The spec prefers HTTP Basic for these.

OAuth2PasswordRequestFormStrict is the same class with grant_type required (it must be sent as password). Without it, a client that posts only username and password is accepted; with it, the same request gets a 422 error. Executed check:

@app.post("/strict")
async def strict(form: Annotated[OAuth2PasswordRequestFormStrict, Depends()]) -> dict:
    return {"user": form.username, "scopes": form.scopes}
# data={"username": "u", "password": "p"}                          -> 422
# data={..., "grant_type": "password", "scope": "books:read books:write"}
#   -> {"user": "u", "scopes": ["books:read", "books:write"]}

The request body must be form-encoded, not JSON: the OAuth2 specification defines the token request that way. Posting JSON to /token fails with a 422 listing body.username and body.password as missing, a frequent surprise for people who learned REST with JSON bodies.

The Complete Login App

All the pieces together, using the config.py, security.py and user models above (saved as main.py):

main.py
from datetime import timedelta
from typing import Annotated

import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pydantic import BaseModel

from config import Settings, get_settings
from security import DUMMY_HASH, create_access_token, password_hash

SettingsDep = Annotated[Settings, Depends(get_settings)]


class Token(BaseModel):
    access_token: str
    token_type: str


# User, UserInDB and fake_users_db as defined in the previous section

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
app = FastAPI(title="Bookshelf API")


def authenticate_user(username: str, password: str) -> UserInDB | None:
    user = fake_users_db.get(username)
    if user is None:
        password_hash.verify(password, DUMMY_HASH)   # equalize timing
        return None
    if not password_hash.verify(password, user.hashed_password):
        return None
    return user


async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)], settings: SettingsDep
) -> UserInDB:
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(
            token,
            settings.jwt_secret_key.get_secret_value(),
            algorithms=[settings.jwt_algorithm],
            options={"require": ["exp", "sub"]},
        )
    except InvalidTokenError:
        raise credentials_exception
    user = fake_users_db.get(payload["sub"])
    if user is None:
        raise credentials_exception
    return user


async def get_current_active_user(
    current_user: Annotated[UserInDB, Depends(get_current_user)],
) -> UserInDB:
    if current_user.disabled:
        raise HTTPException(status_code=400, detail="Inactive user")
    return current_user


@app.post("/token")
async def login_for_access_token(
    form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
    settings: SettingsDep,
) -> Token:
    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(
        subject=user.username,
        secret=settings.jwt_secret_key.get_secret_value(),
        algorithm=settings.jwt_algorithm,
        expires_delta=timedelta(minutes=settings.access_token_expire_minutes),
    )
    return Token(access_token=token, token_type="bearer")


@app.get("/users/me")
async def read_users_me(
    current_user: Annotated[UserInDB, Depends(get_current_active_user)],
) -> User:
    return current_user

How the pieces fit:

  • /token receives the form, authenticates, and answers {"access_token": …​, "token_type": "bearer"}. The token_type value bearer and the field names come from the OAuth2 specification; Swagger UI and other clients expect exactly these. Any failure returns the same message ("Incorrect username or password") for both an unknown user and a wrong password, so the endpoint does not reveal which usernames exist.

  • get_current_user depends on oauth2_scheme (the token string) and the settings. It verifies the signature and expiry, then loads the user named by sub. Every failure maps to one 401 with WWW-Authenticate: Bearer, as RFC 6750 requires, so clients know to authenticate.

  • get_current_active_user is a second layer that depends on the first: authentication succeeded, but a disabled account is refused. The tutorials answer 400 ("Inactive user"); many teams prefer 403 because the caller is identified but not allowed. Pick one and use it consistently. Because it is a dependency of a dependency, any route that declares get_current_active_user gets both checks; routes that only need a logged-in user, whatever their state, can use get_current_user. See Dependency Injection for how FastAPI resolves and caches the chain.

  • Reusable alias. Declare once and reuse everywhere: CurrentUser = Annotated[UserInDB, Depends(get_current_active_user)], then def read_me(user: CurrentUser) → User. Router-wide protection is possible with dependencies=[Depends(get_current_active_user)] when a route does not need the user object.

  • SettingsDep is an ordinary dependency, so tests can override it (next section).

Trying It: Executed Runs

The responses below come from running this exact app with uvicorn and curl (the JWT value is replaced by <jwt>):

# No token
curl -i http://127.0.0.1:8000/users/me
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
content-type: application/json

{"detail":"Not authenticated"}
# Log in: form-encoded body, -d implies application/x-www-form-urlencoded
curl -X POST http://127.0.0.1:8000/token -d username=reader -d password=example-password-1
{"access_token": "<jwt>", "token_type": "bearer"}
# Use the token (assuming TOKEN holds the value from the previous response)
curl http://127.0.0.1:8000/users/me -H "Authorization: Bearer $TOKEN"
{"username": "reader", "email": "reader@example.com", "full_name": "Ada Reader", "disabled": false}

The same login with HTTPie, which sends JSON by default and needs --form here:

http --form POST :8000/token username=reader password=example-password-1

All the failure modes from TestClient runs:

Request Status Body / headers

GET /users/me without a header, or with Basic abc

401

{"detail": "Not authenticated"} plus WWW-Authenticate: Bearer

POST /token with a wrong password

401

{"detail": "Incorrect username or password"} plus WWW-Authenticate: Bearer

POST /token for an unknown user

401

Identical to the wrong-password response

POST /token with a JSON body

422

missing errors for body.username and body.password

Tampered signature, expired token, no sub, or sub naming a deleted user

401

{"detail": "Could not validate credentials"} plus WWW-Authenticate: Bearer

Valid token of a disabled user

400

{"detail": "Inactive user"}

Testing the Login Flow

Override the settings dependency in tests so no real key is involved, and use TestClient as described in Testing:

test_auth.py
from fastapi.testclient import TestClient
from pydantic import SecretStr

from config import Settings, get_settings
from main import app


def override_settings() -> Settings:
    return Settings(jwt_secret_key=SecretStr("test-only-signing-key"), access_token_expire_minutes=1)


app.dependency_overrides[get_settings] = override_settings
client = TestClient(app)


def test_login_and_read_profile():
    r = client.post("/token", data={"username": "reader", "password": "example-password-1"})
    assert r.status_code == 200
    token = r.json()["access_token"]
    me = client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
    assert me.json()["username"] == "reader"


def test_missing_token_is_401_with_challenge():
    r = client.get("/users/me")
    assert r.status_code == 401
    assert r.headers["WWW-Authenticate"] == "Bearer"

Both tests passed under pytest. The key "test-only-signing-key" is a throwaway literal that exists only in the test; production code reads the real key from the environment.

Swagger UI and the Authorize Button

The OpenAPI document produced by the app declares one security scheme and applies it to the protected operation:

{
  "OAuth2PasswordBearer": {
    "type": "oauth2",
    "flows": { "password": { "scopes": {}, "tokenUrl": "token" } }
  }
}

and "security": [{"OAuth2PasswordBearer": []}] on GET /users/me. Effect in Swagger UI (/docs):

  • An Authorize button appears at the top, and a padlock icon next to every operation that depends on the scheme.

  • Clicking Authorize opens a dialog with username and password (and, if you declared scopes, a checkbox per scope). Swagger UI posts a form to tokenUrl, stores the returned access_token, and sends Authorization: Bearer <token> on every later Try it out request. Your API is never asked to render a login page.

  • /token itself appears as an ordinary operation whose request body is application/x-www-form-urlencoded with the fields from the table above (grant_type pattern, password format, optional scope, client_id, client_secret).

  • An endpoint whose security dependency is buried inside a sub-dependency is still marked: FastAPI collects security schemes through the whole dependency tree.

  • Swagger UI keeps the token in the page, so reloading the page forgets it unless you enable swagger_ui_parameters={"persistAuthorization": True}; do that in development only, since it stores the token in the browser’s local storage. See OpenAPI & Interactive Docs.

Expiry, Clock Skew and Logout

  • Keep access tokens short-lived (5 to 30 minutes). A JWT cannot be recalled before exp unless the server keeps extra state, so a short life limits the damage of theft. The 30 minutes in Settings is the tutorial’s choice, not a rule.

  • Timezone-aware dates. Use datetime.now(timezone.utc). Mixing naive local times with aware ones, or using the deprecated utcnow(), produces tokens that expire hours early or late depending on the server’s zone.

  • Clock skew. If several servers verify tokens, allow a few seconds of leeway and keep their clocks synchronized.

  • Logout. Stateless JWTs cannot be revoked by the server. "Logging out" means the client discards the token. If you need server-side revocation (password change, account ban, stolen device), keep a store keyed by jti or a per-user "tokens valid after" timestamp compared with iat, check it in get_current_user, and accept that the token is no longer purely stateless.

  • Where the client keeps the token decides how stealable it is. A browser app that stores a bearer token in localStorage exposes it to any script injected into the page; an in-memory token plus an HttpOnly cookie for refresh is safer. The trade-offs are discussed in Browser-Based Apps and Access & Refresh Tokens.

Refresh Tokens (Design Only)

FastAPI ships no refresh-token machinery and the official tutorial does not implement it, so this section describes the design rather than a drop-in endpoint. The idea: the access token stays short-lived and is sent with every request; a longer-lived refresh token is used only against the token endpoint to obtain a new access token without asking for the password again.

Design decision Recommendation

Token format

Prefer an opaque, random, server-stored value (secrets.token_urlsafe(32) hashed in the database like a password, with the user id, expiry and a family id). A signed JWT with typ: refresh and a jti also works if you store the jti.

Endpoint

POST /token/refresh accepting the refresh token (body field or HttpOnly cookie) and returning a new access token. The refreshUrl parameter of OAuth2PasswordBearer can advertise it in OpenAPI.

Rotation

Issue a new refresh token with each use and invalidate the old one. If an already-used refresh token is presented, a thief or the real user is replaying it: revoke the whole family and force a new login.

Lifetime

Days to weeks, with an absolute cap so a stolen token does not live forever; an idle timeout is also reasonable.

Claims

Never accept a refresh token as an access token or the reverse; check typ. Minimal refresh claims: sub, typ, jti, iat, exp.

Storage on the client

HttpOnly, Secure, SameSite cookie for browsers; the platform’s secure storage for native apps. Never localStorage.

Revocation

Possible, because the refresh token is stored server-side: deleting the row ends the session at the next refresh.

A sketch of the claims of a JWT-formatted refresh token (this builds the dictionary only; no endpoint is implied):

import secrets
from datetime import datetime, timedelta, timezone

now = datetime.now(timezone.utc)
refresh_claims = {
    "sub": "reader",
    "typ": "refresh",                       # never accepted as an access token
    "jti": secrets.token_urlsafe(16),       # looked up and rotated on every use
    "iat": now,
    "exp": now + timedelta(days=14),
}
print(sorted(refresh_claims))   # ['exp', 'iat', 'jti', 'sub', 'typ']

Refresh-token theory (rotation, reuse detection, absolute versus idle expiry) is covered in Access & Refresh Tokens. If you need all of this, consider using an identity provider or one of the third-party packages listed in Security Fundamentals instead of writing it yourself.

Why the Password Flow Is for First-Party Clients Only

In the resource owner password credentials grant the client application collects the user’s password and sends it to the authorization server. That is acceptable only when the client and the server belong to the same trust boundary: your own web or mobile front end talking to your own API. It is not acceptable when:

  • a third-party application wants access to your users' data. The user would have to hand their password to a party that must not have it, and could not revoke that access without changing the password. Third parties use the authorization code grant with PKCE, which keeps the password at the identity provider (Authorization Code & PKCE);

  • you want multi-factor authentication, passkeys or social login. The password flow has no place for a second step or a redirect; those need a browser-based flow (Authentication Methods, 2FA & Passwordless);

  • the client is a public client that cannot keep secrets, such as a single-page app or a native app, where phishing and credential-stuffing are easier.

The current security best practice, RFC 9700, says the password grant must not be used, and OAuth 2.1 drops it. The OAuth Reference explains why in Legacy Implicit & Password Grants. The FastAPI tutorial still uses it because it is the simplest complete example and a first-party login is a legitimate, common case. For a production system with an organization-wide identity provider, delegate login to it (OpenID Connect, see OpenID Connect) and make your API a resource server that only validates tokens. Related theory: Flows Overview and Security Best Practices.

A Note on Naming

The same thing is called three names in the wild, which confuses searches:

Name Where you meet it

Resource owner password credentials (ROPC) grant

The OAuth 2.0 specification, RFC 6749

Password flow (flows.password in OpenAPI)

FastAPI and OpenAPI; the class is OAuth2PasswordBearer

Password grant (grant_type=password)

The form field sent to the token endpoint, and most identity-provider documentation

Do not confuse OAuth2PasswordBearer (the security scheme that reads the bearer token) with the grant: the "bearer" part describes how later requests carry the token, the "password" part how it was obtained.

Pitfalls

  • Secret in the code or repository. The most common and most damaging mistake. Load it from settings, give it no default, and rotate it if it ever leaked. Reusing one key across environments lets a development token work in production.

  • Weak or short HS256 key. Use 32 random bytes (openssl rand -hex 32), not a word.

  • Missing algorithms=. Always pin the allow-list; never accept what the token header says.

  • No exp. Require it, otherwise tokens never expire.

  • Naive datetimes. Use datetime.now(timezone.utc), not datetime.utcnow() or datetime.now().

  • Secrets inside the JWT. The payload is readable by anybody; put only an identifier and the claims you need.

  • Different errors for unknown user and wrong password. Reveals which accounts exist; return one message and verify against DUMMY_HASH.

  • Blocking hash calls in async def. Argon2 takes tens of milliseconds and a lot of memory; see Async & Concurrency for running it in a thread.

  • No rate limiting on /token. Argon2 slows each guess, but without limits an attacker still gets unlimited tries and can use your server’s CPU. Throttle by IP and username at the proxy or with a limiter dependency.

  • JSON instead of form data to /token. The form is part of the spec; OAuth2PasswordRequestForm will answer 422.

  • Plain HTTP. Passwords and bearer tokens in clear text can be captured; terminate TLS in front of the app.

  • Trusting get_current_user alone for authorization. Authentication says who the caller is, not what they may do. Add checks (roles, scopes, ownership) as shown in OAuth2 Scopes & Authorization.

  • Token in a URL. Query strings land in logs and browser history; send it in the Authorization header.

  • persistAuthorization in production docs. It stores the token in the browser; keep it for development.

Where Older Guides Are Outdated, and What Changed Recently

Older tutorials, and the book this section was cross-checked against, were written for an earlier FastAPI ecosystem. Today:

  • python-jose to PyJWT. The tutorials used python-jose for JWT. python-jose has seen little maintenance and has had security advisories; FastAPI 0.111.1 (2024-07-14) switched the documentation to PyJWT (import jwt, with jwt.exceptions.InvalidTokenError replacing JWTError). Use PyJWT, and use encode / decode as shown, with an explicit algorithms= list.

  • passlib to pwdlib with Argon2. passlib (with CryptContext(schemes=["bcrypt"])) is no longer actively maintained and has compatibility problems with recent bcrypt releases; FastAPI 0.118.0 (2025-09-29) moved the docs to pwdlib with Argon2. See the bcrypt migration section to upgrade stored hashes without forcing resets.

  • datetime.utcnow() to datetime.now(timezone.utc). utcnow() returns a naive datetime and is deprecated since Python 3.12; current docs use timezone-aware datetimes.

  • Annotated style. Older code writes token: str = Depends(oauth2_scheme). This page uses Annotated[str, Depends(oauth2_scheme)], recommended since FastAPI 0.95.0; both still work.

  • Responses without a token: 401, not 403. Before FastAPI 0.122.0 (2025-11-24) the fastapi.security classes such as OAuth2PasswordBearer returned 403 "Not authenticated" when the header was missing. They now return 401 with a WWW-Authenticate header, which is what the HTTP specification requires. The official how-to "Use Old 403 Authentication Error Status Codes" shows how to restore the old behaviour for clients that depend on it; Bookshelf tests that asserted 403 for a missing token must be updated.

  • Flow naming. Some guides present the password flow as the generic "OAuth2 login". It is one legacy-leaning grant; see A Note on Naming and why it is first-party only.

  • Pydantic v2. Models in this page use v2 idioms only (SecretStr, BaseSettings from pydantic-settings, model_config). Old code imported BaseSettings from pydantic and used class Config.

  • Hardcoded demo secrets. The official tutorial still shows a literal sample SECRET_KEY and a pasted password hash for readability. Never do that outside a tutorial; generate with openssl rand -hex 32 and read it from the environment as shown here. The [standard] extra installs pydantic-settings for exactly this reason.