FastAPI · lesson 11 of 19
Beginner
6Intermediate
10- 7 FastAPI Pydantic Validation: Field Constraints and Nested Models
- 8 FastAPI Validators and Serialization with Pydantic
- 9 FastAPI CRUD with SQLModel and SQLite
- 10 FastAPI Database Migrations with Alembic and SQLModel
- 11 FastAPI Authentication: Password Hashing, OAuth2 and JWT
- 12 FastAPI Roles, CORS and Settings from Environment Variables
- 13 FastAPI Dependency Injection: Depends, yield and Overrides
- 14 Testing FastAPI with pytest, TestClient and httpx
- 15 FastAPI with Async PostgreSQL: Pools, Transactions, and Real Errors
- 16 Sync vs Async Endpoints in FastAPI, Measured
Advanced
3Also part of FastAPI
6Also part of: Security Fundamentals Cryptography Web Security (OWASP Top 10, fix-side)
FastAPI Authentication: Password Hashing, OAuth2 and JWT
Hash passwords with pwdlib and Argon2, log in through FastAPI's OAuth2 password flow, issue JWTs, and see wrong, expired and tampered tokens get 401s.
By SkillByExample · Published
Who Is Calling?
Headers and Cookies showed that a client can send any header or cookie it likes, so a value alone proves nothing. This lesson builds login properly in three steps. First, passwords are stored as slow, salted hashes. Then a login route checks a password and issues a signed JWT. Finally, every protected route verifies that token. Along the way you’ll see each weak shortcut fail, and the fix.
Outputs come from FastAPI 0.143.0, Pydantic 2.14.1, Starlette 1.7.0, pwdlib 0.3.1 (with argon2-cffi 25.1.0) and PyJWT 2.15.1 on Python 3.12. Install with pip install fastapi uvicorn "pwdlib[argon2]" pyjwt python-multipart httpx2. python-multipart lets FastAPI read the login form, and httpx2 is for TestClient. Every secret below is generated fresh on each run and is for local development only.
Never Store the Password Itself
If the users table holds plain passwords, one leaked backup exposes every account, along with any other site where the same password is reused. A fast hash such as SHA-256 isn’t much better. Save as plain_vs_hash.py:
import hashlib
import time
from pwdlib import PasswordHash
hasher = PasswordHash.recommended()
users = {"ada": "correct-horse", "bob": "correct-horse"}
for name, password in users.items():
print(name, "sha256", hashlib.sha256(password.encode()).hexdigest()[:24])
for name, password in users.items():
print(name, "argon2", hasher.hash(password)[:58])
N = 1000
start = time.perf_counter()
for _ in range(N):
hashlib.sha256(b"correct-horse").hexdigest()
print(f"sha256: {(time.perf_counter() - start) / N * 1000:.4f} ms per hash")
start = time.perf_counter()
stored = hasher.hash("correct-horse")
print(f"argon2: {(time.perf_counter() - start) * 1000:.0f} ms per hash")
print(hasher.verify("correct-horse", stored), hasher.verify("Correct-horse", stored))
$ python plain_vs_hash.py
ada sha256 9dca666eb54730714630d151
bob sha256 9dca666eb54730714630d151
ada argon2 $argon2id$v=19$m=65536,t=3,p=4$6vsARY6qAVYeifDQ03VLog$VECG
bob argon2 $argon2id$v=19$m=65536,t=3,p=4$faMEcA1WawTThOG5LZsOHw$w+Vn
sha256: 0.0005 ms per hash
argon2: 51 ms per hash
True False
SHA-256 gives Ada and Bob the same digest, so anyone holding the table can see they share a password, and a precomputed table of common passwords cracks both at once. It also takes half a microsecond, so a single CPU core can try about two million guesses a second, and an attacker can spread the work over many cores. Argon2 fixes both problems. Each hash embeds a random salt (the part after p=4$), so equal passwords give different strings. It also costs about 50 ms and 64 MiB of memory (m=65536) per attempt, which doesn’t matter for one login but makes guessing slow. The hash string records its own algorithm and parameters, so verify needs no other settings. It is also case-sensitive: Correct-horse fails.
PasswordHash.recommended() is what FastAPI’s security tutorial uses. pwdlib can also verify bcrypt hashes if you’re migrating an older user table.
JWTs: Signed, Not Secret
After login, the server hands the client a JSON Web Token. A JWT is three base64url parts, header.payload.signature. The signature is an HMAC of the first two parts, made with a key that only the server knows. Save as tokens.py:
import secrets
from datetime import datetime, timedelta, timezone
import jwt
SECRET_KEY = secrets.token_hex(32) # dev-only: a fresh key on every run
def make(minutes: int) -> str:
exp = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return jwt.encode({"sub": "ada", "exp": exp}, SECRET_KEY, algorithm="HS256")
token = make(15)
print(token.count("."), "dots,", len(token), "chars")
print(jwt.get_unverified_header(token))
print(jwt.decode(token, SECRET_KEY, algorithms=["HS256"]))
for label, tok, key in [
("expired", make(-1), SECRET_KEY),
("wrong key", token, secrets.token_hex(32)),
]:
try:
jwt.decode(tok, key, algorithms=["HS256"])
except jwt.InvalidTokenError as exc:
print(f"{label}: {type(exc).__name__}: {exc}")
$ python tokens.py
2 dots, 121 chars
{'alg': 'HS256', 'typ': 'JWT'}
{'sub': 'ada', 'exp': 1791877876}
expired: ExpiredSignatureError: Signature has expired
wrong key: InvalidSignatureError: Signature verification failed
sub (subject) names the user, and exp is the expiry, which PyJWT turns from a datetime into Unix seconds. jwt.decode checks the signature and the expiry, so a token made with minutes=-1 fails even though its signature is perfect. Both errors are subclasses of jwt.InvalidTokenError, so one except covers them. In production the key must stay the same across restarts and between servers, so it comes from configuration; FastAPI’s docs generate one with openssl rand -hex 32. RBAC, CORS and Settings loads it from an environment variable.
The Login Flow: OAuth2PasswordBearer
The app is split into three files so that each stays short. security.py holds the hashing and the tokens:
import secrets
from datetime import datetime, timedelta, timezone
import jwt
from pwdlib import PasswordHash
SECRET_KEY = secrets.token_hex(32) # dev-only: a fresh key on every start
hasher = PasswordHash.recommended()
DUMMY_HASH = hasher.hash("dummy-password")
USERS = {
"ada": {"hashed": hasher.hash("correct-horse"), "role": "admin"},
"bob": {"hashed": hasher.hash("battery-staple"), "role": "viewer"},
}
def authenticate(username: str, password: str) -> str | None:
user = USERS.get(username)
if user is None:
hasher.verify(password, DUMMY_HASH) # same work, so timing doesn't leak
return None
return username if hasher.verify(password, user["hashed"]) else None
def create_token(username: str, minutes: int = 15) -> str:
exp = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return jwt.encode({"sub": username, "exp": exp}, SECRET_KEY, algorithm="HS256")
deps.py holds the dependency that turns a token back into a username:
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from security import SECRET_KEY
oauth2 = OAuth2PasswordBearer(tokenUrl="token")
BEARER = {"WWW-Authenticate": "Bearer"}
REQUIRED = {"require": ["exp", "sub"]} # a token without exp would never expire
def current_user(token: Annotated[str, Depends(oauth2)]) -> str:
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=["HS256"], options=REQUIRED)
except jwt.InvalidTokenError:
raise HTTPException(401, "Could not validate credentials", headers=BEARER)
return claims["sub"]
app.py holds the routes:
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from deps import BEARER, current_user
from security import USERS, authenticate, create_token
app = FastAPI()
@app.post("/token")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
username = authenticate(form.username, form.password)
if username is None:
raise HTTPException(401, "Incorrect username or password", headers=BEARER)
return {"access_token": create_token(username), "token_type": "bearer"}
@app.get("/me")
def me(username: Annotated[str, Depends(current_user)]):
return {"username": username, "role": USERS[username]["role"]}
OAuth2PasswordRequestForm reads username and password from a form body, which is what the OAuth2 “password” flow requires and what the Authorize button on /docs sends. OAuth2PasswordBearer is a dependency that reads the Authorization: Bearer <token> header and returns just the token. tokenUrl="token" tells /docs where to log in. current_user builds on it, and any route that declares Depends(current_user) is protected.
PyJWT checks exp only when the token has one. A token minted without exp would otherwise never expire, so options={"require": ["exp", "sub"]} rejects any token missing either claim.
Run it with uvicorn app:app --port 8011 and log in with curl:
$ curl -si http://127.0.0.1:8011/me
HTTP/1.1 401 Unauthorized
date: Tue, 13 Oct 2026 11:19:11 GMT
server: uvicorn
www-authenticate: Bearer
content-length: 30
content-type: application/json
{"detail":"Not authenticated"}
$ curl -si http://127.0.0.1:8011/token -d 'username=ada&password=wrong'
HTTP/1.1 401 Unauthorized
date: Tue, 13 Oct 2026 11:19:12 GMT
server: uvicorn
www-authenticate: Bearer
content-length: 43
content-type: application/json
{"detail":"Incorrect username or password"}
$ curl -s http://127.0.0.1:8011/token -d 'username=nobody&password=wrong'
{"detail":"Incorrect username or password"}
$ curl -s http://127.0.0.1:8011/token -d 'username=ada'
{"detail":[{"type":"missing","loc":["body","password"],"msg":"Field required","input":null}]}
$ curl -s http://127.0.0.1:8011/token -d 'username=ada&password=correct-horse'
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZGEiLCJleHAiOjE3OTE4OTEyNTJ9.d7oEtO5FWjElhRpXS3Fr5DvVGz-TIfEJG24T23dYQQc","token_type":"bearer"}
$ curl -s http://127.0.0.1:8011/me -H "Authorization: Bearer $TOKEN"
{"username":"ada","role":"admin"}
($TOKEN holds the access_token from the previous response.)
- With no
Authorizationheader,OAuth2PasswordBeareranswers 401 withwww-authenticate: Bearerbeforecurrent_usereven runs. Older FastAPI versions answered 403 here, so older tutorials and tests may expect that. - A wrong password and an unknown user get the same message. Saying “no such user” would tell a stranger which usernames exist. Practice task 3 shows why
authenticatealso verifies the dummy hash. - A missing form field is an ordinary 422. A missing field reports
input: null, so the password isn’t echoed here, but a JSON body can echo it; Practice task 2 covers that. curl -dsendsapplication/x-www-form-urlencoded, which is the format OAuth2 requires. A JSON body would get a 422.
Expired, Tampered and alg=none Tokens
The server must reject every token it didn’t issue, or no longer accepts. rejections.py imports the same app and drives it with TestClient:
import base64
import json
import jwt
from fastapi.testclient import TestClient
from app import app
from security import SECRET_KEY, create_token
client = TestClient(app)
good = create_token("bob")
header, payload, signature = good.split(".")
claims = jwt.decode(good, options={"verify_signature": False})
print("anyone can read:", claims)
forged = base64.urlsafe_b64encode(json.dumps({**claims, "sub": "ada"}).encode())
tokens = {
"good": good,
"expired": create_token("bob", minutes=-1),
"tampered": f"{header}.{forged.rstrip(b'=').decode()}.{signature}",
"alg=none": jwt.encode({**claims, "sub": "ada"}, None, algorithm="none"),
"no exp": jwt.encode({"sub": "ada"}, SECRET_KEY, algorithm="HS256"),
}
for label, token in tokens.items():
r = client.get("/me", headers={"Authorization": f"Bearer {token}"})
print(f"{label:9}", r.status_code, r.json())
$ python rejections.py
anyone can read: {'sub': 'bob', 'exp': 1791891222}
good 200 {'username': 'bob', 'role': 'viewer'}
expired 401 {'detail': 'Could not validate credentials'}
tampered 401 {'detail': 'Could not validate credentials'}
alg=none 401 {'detail': 'Could not validate credentials'}
no exp 401 {'detail': 'Could not validate credentials'}
The first line is the important caveat: anyone holding a token can read its claims without the key. A JWT is signed, not encrypted, so never put a password, card number or other secret in it. What the holder can’t do is change it. Bob rewrote sub to ada but kept the old signature, which no longer matches. The alg=none token claims to need no signature at all. The no exp token is correctly signed with the real key, but require turns it away: PyJWT raises MissingRequiredClaimError: Token is missing the "exp" claim. Without require it would decode fine and work forever. All of them get the same 401, which tells the caller nothing useful about which check failed.
The alg=none rejection relies on one argument. Here is what happens without it. Save as weak_decode.py:
import secrets
import jwt
SECRET_KEY = secrets.token_hex(32) # dev-only
forged = jwt.encode({"sub": "ada"}, key=None, algorithm="none")
print(forged)
print("weak:", jwt.decode(forged, options={"verify_signature": False}))
for algorithms in (None, ["HS256"]):
try:
print(jwt.decode(forged, SECRET_KEY, algorithms=algorithms))
except jwt.InvalidTokenError as exc:
print(f"algorithms={algorithms}: {type(exc).__name__}: {exc}")
$ python weak_decode.py
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZGEifQ.
weak: {'sub': 'ada'}
algorithms=None: DecodeError: It is required that you pass in a value for the "algorithms" argument when calling decode().
algorithms=['HS256']: InvalidAlgorithmError: The specified alg value is not allowed
The forged token ends in a bare . because it has no signature. Code that reads claims with verify_signature: False, perhaps copied from a debugging snippet, accepts it as Ada. PyJWT won’t decode without an algorithms list. With ["HS256"] it rejects any token whose header names another algorithm, before it gets as far as the signature. The fix is the line current_user already uses: always pass the key and a fixed algorithms list, and never let the token pick.
401 vs 403
A 401 means “I don’t know who you are”. A 403 Forbidden means “I know who you are, and you aren’t allowed”. admin.py adds an admin-only route to the same app:
from typing import Annotated
from fastapi import Depends, HTTPException
from app import app
from deps import current_user
from security import USERS
@app.get("/admin/stats")
def stats(username: Annotated[str, Depends(current_user)]):
if USERS[username]["role"] != "admin":
raise HTTPException(403, "Admins only")
return {"users": len(USERS)}
Run uvicorn admin:app --port 8011, log in as each user, and call it. $ADA and $BOB hold their tokens:
$ curl -s http://127.0.0.1:8011/admin/stats -H "Authorization: Bearer $ADA"
{"users":2}
$ curl -si http://127.0.0.1:8011/admin/stats -H "Authorization: Bearer $BOB"
HTTP/1.1 403 Forbidden
date: Tue, 13 Oct 2026 11:19:23 GMT
server: uvicorn
content-length: 24
content-type: application/json
{"detail":"Admins only"}
$ curl -si http://127.0.0.1:8011/me -H 'Authorization: Bearer not-a-jwt'
HTTP/1.1 401 Unauthorized
date: Tue, 13 Oct 2026 11:19:23 GMT
server: uvicorn
www-authenticate: Bearer
content-length: 43
content-type: application/json
{"detail":"Could not validate credentials"}
Bob’s token was valid, so he isn’t asked to log in again: a 403 has no www-authenticate header. A client should treat the two differently. On a 401 it sends the user to the login screen; on a 403 it shows “you don’t have access”, because logging in again won’t help. Writing the role check inside each route doesn’t scale. RBAC, CORS and Settings turns it into a reusable dependency.
Practice
- Issue a token for Bob, then delete Bob from
USERS(as an admin removing an account would) and call/mewith the old token. What happens? Makecurrent_useranswer 401 instead.
Solution
Save as deleted_user.py:
from fastapi.testclient import TestClient
from app import app
from security import USERS, create_token
client = TestClient(app, raise_server_exceptions=False)
token = create_token("bob")
del USERS["bob"]
r = client.get("/me", headers={"Authorization": f"Bearer {token}"})
print(r.status_code, r.text)
$ python deleted_user.py
500 Internal Server Error
The signature is still valid, so current_user returns "bob", and USERS["bob"] in the route raises KeyError. Check the user still exists inside current_user. In deps.py, import USERS too (from security import SECRET_KEY, USERS) and add the check:
def current_user(token: Annotated[str, Depends(oauth2)]) -> str:
try:
claims = jwt.decode(token, SECRET_KEY, algorithms=["HS256"], options=REQUIRED)
except jwt.InvalidTokenError:
raise HTTPException(401, "Could not validate credentials", headers=BEARER)
if claims["sub"] not in USERS:
raise HTTPException(401, "Could not validate credentials", headers=BEARER)
return claims["sub"]
$ python deleted_user.py
401 {"detail":"Could not validate credentials"}
A JWT stays valid until it expires, whatever happens to the account. That’s why access tokens are short-lived, and why the lookup belongs in the dependency, where every protected route gets it.
- Add
POST /signuptaking JSON{"username", "password"}. It should require at least 12 characters, store only the Argon2 hash, and return just the username. A too-short password must not be echoed in the 422.
Solution
Save as signup.py:
from fastapi import FastAPI
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from security import USERS, hasher
app = FastAPI()
class Signup(BaseModel):
username: str
password: str = Field(min_length=12)
@app.exception_handler(RequestValidationError)
async def without_input(request, exc):
errors = [{k: v for k, v in e.items() if k != "input"} for e in exc.errors()]
return JSONResponse({"detail": errors}, status_code=422)
@app.post("/signup", status_code=201)
def signup(s: Signup):
USERS[s.username] = {"hashed": hasher.hash(s.password), "role": "viewer"}
return {"username": s.username}
Try it from try_signup.py:
from fastapi.testclient import TestClient
from security import USERS
from signup import app
client = TestClient(app)
print(client.post("/signup", json={"username": "cy", "password": "hunter2"}).json())
print(client.post("/signup", json={"username": "cy", "password": "four-random-words"}).json())
print(USERS["cy"]["hashed"][:31])
$ python try_signup.py
{'detail': [{'type': 'string_too_short', 'loc': ['body', 'password'], 'msg': 'String should have at least 12 characters', 'ctx': {'min_length': 12}}]}
{'username': 'cy'}
$argon2id$v=19$m=65536,t=3,p=4$
Without the handler, the 422 would include 'input': 'hunter2', as Validators and Serialization showed, and so would any log of error responses. The handler keeps type, loc, msg and ctx, which is all the client needs to fix the request.
- Time
authenticate()for a real user and for an unknown one, each with a wrong password. Then comment out the dummyhasher.verifyline insecurity.pyand time it again.
Solution
Save as timing.py:
import time
from security import authenticate
for username in ["ada", "nobody"]:
start = time.perf_counter()
authenticate(username, "wrong-password")
print(f"{username:7} {(time.perf_counter() - start) * 1000:.0f} ms")
$ python timing.py
ada 56 ms
nobody 49 ms
With # hasher.verify(password, DUMMY_HASH) commented out:
$ python timing.py
ada 44 ms
nobody 0 ms
The error messages are identical either way, but the response time gives the answer away: an instant failure means “no such user”. Verifying a dummy hash makes both paths do the same Argon2 work, which is why FastAPI’s tutorial includes it.