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) receivesusernameandpasswordasapplication/x-www-form-urlencoded, parsed by theOAuth2PasswordRequestFormdependency. -
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 theAuthorization: Bearer <token>header on later requests. Yourget_current_userdependency decodes and verifies the token and loads the user.
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.
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
-
SecretStrmakes Pydantic printinstead of the value inrepr(), logs and tracebacks. Read the real value withget_secret_value()only where you sign or verify. -
env_prefix="BOOKSHELF_"means the variable isBOOKSHELF_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
.envfile 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
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)andverify(password, hash)are the two calls you need (note the argument order inverify: password first, hash second). -
verifyreturnsFalseon a mismatch and raisespwdlib.exceptions.UnknownHashErrorif 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 defpath function it stalls the event loop. For a busy login endpoint, define the function withdefso FastAPI runs it in the thread pool, or userun_in_threadpool/anyio.to_thread.run_sync; see Async & Concurrency. -
Tune cost with
Argon2Hasher(time_cost=…, memory_cost=…, parallelism=…)when you buildPasswordHashyourself; 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/UserInDBsplit 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:
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,
),
}
Source: FastAPI: Get Current User
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
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 adatetimeinexp/iatto a numeric date (seconds since the epoch, RFC 7519NumericDate) and compares against the current UTC time when decoding. The old idiomdatetime.utcnow()returns a naive datetime, has been deprecated since Python 3.12 and should not be used. -
DUMMY_HASHcloses 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)
Source: PyJWT documentation
Encode, Decode and the Claims
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}
Source: PyJWT: Usage Examples
The claims used here are registered claims from RFC 7519:
| Claim | Meaning | Notes |
|---|---|---|
|
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 |
|
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 |
|
Issued at |
Useful for audits and for rejecting tokens issued before a password change. PyJWT rejects a token whose |
|
Not before |
Optional; rarely needed for session tokens. |
|
Issuer and audience |
Needed when several services issue or accept tokens. Pass |
|
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 |
|---|---|---|
|
|
Signature has expired |
Signed with a different key |
|
Signature verification failed |
Not a JWT at all |
|
Invalid header padding |
No |
|
It is required that you pass in a value for the "algorithms" argument when calling decode(). |
Token’s |
|
The specified alg value is not allowed |
Required claim absent |
|
Token is missing the "sub" claim |
|
|
Subject must be a string |
|
|
Audience doesn’t match |
Source: PyJWT: Usage Examples
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 validatesexponly if the claim is present, so a token that omitsexpwould otherwise be accepted and live forever. -
leeway=10(seconds or atimedelta) tolerates small clock differences between servers; a token expired one second ago decodes withleeway=10. -
Do not call
jwt.decode(…, options={"verify_signature": False})to authenticate anybody. It is for debugging (readingexpin 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 |
Install |
|
|
Rotation |
Replace the secret everywhere at once; all sessions end |
Publish the new public key under a new |
Both work with the same two calls; only the key material changes. This was executed with a freshly generated 2048-bit RSA key pair:
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"]))
Source: PyJWT: Usage Examples
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 |
|---|---|
|
The URL (relative to the API) of the endpoint that issues tokens, i.e. the path operation with
|
|
The scheme’s name in |
|
A |
|
Free text shown in the OpenAPI document and the Authorize dialog. |
|
|
|
Documentation only: advertises a refresh endpoint in OpenAPI. FastAPI does not implement refreshing. |
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="token",
scheme_name="BookshelfBearer",
description="Log in with a Bookshelf username and password.",
refreshUrl="token/refresh",
auto_error=False,
)
Source: FastAPI: Security Tools
{
"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 |
|---|---|---|
|
yes |
The spec requires this exact name, even if your login is an e-mail address. |
|
yes |
Marked |
|
no |
The spec says it must be the fixed string |
|
no |
One string of space-separated scopes. The dependency exposes it as the list |
|
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):
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:
-
/tokenreceives the form, authenticates, and answers{"access_token": …, "token_type": "bearer"}. Thetoken_typevaluebearerand 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_userdepends onoauth2_scheme(the token string) and the settings. It verifies the signature and expiry, then loads the user named bysub. Every failure maps to one 401 withWWW-Authenticate: Bearer, as RFC 6750 requires, so clients know to authenticate. -
get_current_active_useris 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 declaresget_current_active_usergets both checks; routes that only need a logged-in user, whatever their state, can useget_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)], thendef read_me(user: CurrentUser) → User. Router-wide protection is possible withdependencies=[Depends(get_current_active_user)]when a route does not need the user object. -
SettingsDepis 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 |
|---|---|---|
|
401 |
|
|
401 |
|
|
401 |
Identical to the wrong-password response |
|
422 |
|
Tampered signature, expired token, no |
401 |
|
Valid token of a disabled user |
400 |
|
Testing the Login Flow
Override the settings dependency in tests so no real key is involved, and use TestClient as described in
Testing:
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
usernameandpassword(and, if you declaredscopes, a checkbox per scope). Swagger UI posts a form totokenUrl, stores the returnedaccess_token, and sendsAuthorization: Bearer <token>on every later Try it out request. Your API is never asked to render a login page. -
/tokenitself appears as an ordinary operation whose request body isapplication/x-www-form-urlencodedwith the fields from the table above (grant_typepattern,passwordformat, optionalscope,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
expunless the server keeps extra state, so a short life limits the damage of theft. The 30 minutes inSettingsis 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 deprecatedutcnow(), 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
leewayand 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
jtior a per-user "tokens valid after" timestamp compared withiat, check it inget_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
localStorageexposes 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 ( |
Endpoint |
|
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 |
Storage on the client |
HttpOnly, Secure, SameSite cookie for browsers; the platform’s secure storage for native apps. Never |
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']
Source: PyJWT: Usage Examples
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 ( |
FastAPI and OpenAPI; the class is |
Password grant ( |
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), notdatetime.utcnow()ordatetime.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;OAuth2PasswordRequestFormwill answer 422. -
Plain HTTP. Passwords and bearer tokens in clear text can be captured; terminate TLS in front of the app.
-
Trusting
get_current_useralone 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
Authorizationheader. -
persistAuthorizationin 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-josefor 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, withjwt.exceptions.InvalidTokenErrorreplacingJWTError). Use PyJWT, and useencode/decodeas shown, with an explicitalgorithms=list. -
passlib to pwdlib with Argon2.
passlib(withCryptContext(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()todatetime.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 usesAnnotated[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.securityclasses such asOAuth2PasswordBearerreturned 403 "Not authenticated" when the header was missing. They now return 401 with aWWW-Authenticateheader, 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,BaseSettingsfrompydantic-settings,model_config). Old code importedBaseSettingsfrompydanticand usedclass Config. -
Hardcoded demo secrets. The official tutorial still shows a literal sample
SECRET_KEYand a pasted password hash for readability. Never do that outside a tutorial; generate withopenssl rand -hex 32and read it from the environment as shown here. The[standard]extra installspydantic-settingsfor exactly this reason.