
Authentication in FastAPI with OAuth2 and JWT
Most APIs need to know who's calling them. FastAPI doesn't ship a complete user system, but it gives you well-designed building blocks: OAuth2 helpers that read credentials and bearer tokens, a dependency system that makes "this route requires a logged-in user" a single parameter, and automatic docs that know how to log in. Combine those with a password hashing library and a JWT library and you have a solid token-based auth setup in a few small files.
This guide builds that setup step by step: hashing passwords properly, a login endpoint that follows the OAuth2 password flow, signed JWT access tokens with expiry, a get_current_user dependency that protects routes, and scope-based permissions. It finishes with tests and a checklist of the security details that are easy to get wrong.
The code was tested with FastAPI 0.142, PyJWT 2.15, and pwdlib 0.3 on Python 3.13. If you're new to FastAPI itself, start with building a REST API with FastAPI.
How the Pieces Fit
The flow you're building:
- The client sends a username and password to
POST /auth/token. - The server checks the password against a stored hash. If it's right, it returns a signed JWT access token.
- The client sends that token with every later request in an
Authorization: Bearer <token>header. - A dependency on protected routes verifies the token's signature and expiry, loads the user, and hands it to the endpoint.
The server never stores the token. It can trust a token because only the server knows the key used to sign it.
What's Actually in a JWT?
A JSON Web Token is three base64url-encoded parts joined by dots: header.payload.signature. Decoding the header and payload of the tokens this app issues gives:
{"alg": "HS256", "typ": "JWT"} # header
{"sub": "ada", "scopes": [...], "iat": ..., "exp": ..., "iss": "tasks-api"} # payload
The payload is encoded, not encrypted: anyone holding the token can read it. Never put secrets or sensitive personal data in it. What the signature guarantees is that the payload hasn't been changed; editing a single character makes verification fail.
The registered claims used here are sub (subject, the user it's for), iat (issued at), exp (expiry), and iss (issuer). scopes is a custom claim.
Installing
python -m pip install "fastapi[standard]" pyjwt "pwdlib[argon2]" pydantic-settings
fastapi[standard]includes Uvicorn andpython-multipart, which FastAPI needs to read the login form.pyjwtcreates and verifies tokens.pwdlibhashes passwords, with Argon2 as the algorithm.pydantic-settingsloads the signing secret from the environment.
The project layout:
app/
├── __init__.py
├── config.py
├── deps.py
├── main.py
├── security.py
└── users.py
tests/
└── test_auth.py
Configuration and the Signing Secret
The JWT secret is the key to your whole auth system: anyone who has it can mint valid tokens for any user. Keep it out of your code and load it from the environment:
# app/config.py
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env")
jwt_secret: str = Field(min_length=32)
jwt_algorithm: str = "HS256"
jwt_issuer: str = "tasks-api"
access_token_minutes: int = 15
settings = Settings()
Generate a strong random value with Python's secrets module and put it in .env (which belongs in .gitignore) or your deployment's secret store:
python -c "import secrets; print(secrets.token_urlsafe(32))"
echo "JWT_SECRET=<paste the value here>" >> .env
If JWT_SECRET is missing, or shorter than 32 characters, the app refuses to start with a clear validation error instead of running with a weak key. Data validation with Pydantic covers how pydantic-settings works in more detail.
Hashing Passwords
Never store passwords, and never store them with a fast hash like SHA-256 either. Password hashing algorithms such as Argon2 are deliberately slow and memory-hungry, so an attacker who steals your database can't test billions of guesses per second. They also add a random salt to every hash, so two users with the same password get different hashes.
# app/security.py
from datetime import datetime, timedelta, timezone
from typing import Any
import jwt
from pwdlib import PasswordHash
from app.config import settings
password_hash = PasswordHash.recommended()
# Verified against when the username doesn't exist, so failed logins
# take the same time whether or not the user is real.
DUMMY_HASH = password_hash.hash("not-a-real-password")
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(password: str, hashed: str) -> bool:
return password_hash.verify(password, hashed)
PasswordHash.recommended() uses Argon2id with sensible parameters. A hash looks like $argon2id$v=19$m=65536,t=3,p=4$...: the algorithm, its cost settings, the salt, and the hash are all stored together, so you can raise the cost later and old hashes still verify. (pwdlib also offers verify_and_update() to rehash old passwords with new parameters on login.)
The DUMMY_HASH matters for the login endpoint below. If you return immediately when a username doesn't exist, but spend 50 ms hashing when it does, an attacker can tell which usernames are real from response times alone.
Creating and Verifying Tokens
The rest of security.py creates and decodes JWTs:
# app/security.py (continued)
def create_access_token(subject: str, scopes: list[str] | None = None) -> str:
now = datetime.now(timezone.utc)
payload: dict[str, Any] = {
"sub": subject,
"scopes": scopes or [],
"iat": now,
"exp": now + timedelta(minutes=settings.access_token_minutes),
"iss": settings.jwt_issuer,
}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict[str, Any]:
return jwt.decode(
token,
settings.jwt_secret,
algorithms=[settings.jwt_algorithm],
issuer=settings.jwt_issuer,
options={"require": ["exp", "sub", "iat"]},
)
PyJWT converts datetime values for iat and exp into the numeric timestamps the JWT spec requires. When decoding, it checks:
- the signature, using the secret;
- that the token's algorithm is in the
algorithmslist you allow. Always pass this list explicitly; letting the token choose its own algorithm has caused real vulnerabilities; exp, rejecting expired tokens withExpiredSignatureError;issmatches, so tokens from another system using the same secret aren't accepted;- that the required claims are present.
Every failure raises a subclass of jwt.InvalidTokenError, so callers only need to catch one exception type.
Keep access tokens short-lived. Fifteen minutes is a common choice: there's no simple way to revoke a JWT before it expires, so the expiry is your main limit on how long a stolen token stays useful.
Users
A real app stores users in a database (see SQLAlchemy 2.0). To keep the focus on auth, a dictionary stands in here:
# app/users.py
from pydantic import BaseModel
from app.security import hash_password
class User(BaseModel):
username: str
full_name: str
disabled: bool = False
scopes: list[str] = []
class UserInDB(User):
hashed_password: str
# A stand-in for a real users table.
FAKE_USERS_DB: dict[str, UserInDB] = {
"ada": UserInDB(
username="ada",
full_name="Ada Lovelace",
hashed_password=hash_password("correct horse battery staple"),
scopes=["tasks:read", "tasks:write"],
),
"bob": UserInDB(
username="bob",
full_name="Bob Reader",
hashed_password=hash_password("another long passphrase"),
scopes=["tasks:read"],
),
}
def get_user(username: str) -> UserInDB | None:
return FAKE_USERS_DB.get(username)
Two models keep the hash from leaking: UserInDB is for internal use, and User is what endpoints return. Because FastAPI filters responses through the declared return type, even an accidental return user_in_db from an endpoint annotated -> User won't include hashed_password.
The Login Endpoint
The OAuth2 "password" flow specifies that the client sends username and password as form data (not JSON) to a token URL. FastAPI's OAuth2PasswordRequestForm reads exactly that:
# app/main.py
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from app.deps import CurrentUser, require_scope
from app.security import DUMMY_HASH, create_access_token, verify_password
from app.users import User, get_user
app = FastAPI(title="Tasks API")
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
@app.post("/auth/token")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
user = get_user(form.username)
hashed = user.hashed_password if user else DUMMY_HASH
password_ok = verify_password(form.password, hashed)
if user is None or not password_ok or user.disabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return Token(access_token=create_access_token(user.username, user.scopes))
Details worth noting:
- The response shape,
{"access_token": "...", "token_type": "bearer"}, is what the OAuth2 spec and FastAPI's docs UI expect. - The error message is identical for "no such user" and "wrong password", and both paths run a password verification. Don't help attackers enumerate accounts.
loginis a plaindef, notasync def. Argon2 verification is deliberately CPU-heavy; in anasync defendpoint it would block the event loop and stall every other request. FastAPI runsdefendpoints in a thread pool, which keeps the server responsive.
Try it with curl:
curl -X POST http://127.0.0.1:8000/auth/token \
-d "username=ada" -d "password=correct horse battery staple"
Protecting Routes with a Dependency
Now the important part: a dependency that turns a bearer token into a user, or rejects the request.
# app/deps.py
from typing import Annotated
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app.security import decode_access_token
from app.users import User, get_user
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
try:
payload = decode_access_token(token)
except jwt.InvalidTokenError:
raise credentials_exception
user = get_user(payload["sub"])
if user is None or user.disabled:
raise credentials_exception
return User(**user.model_dump(exclude={"hashed_password"}))
CurrentUser = Annotated[User, Depends(get_current_user)]
How it works:
OAuth2PasswordBeareris itself a dependency. It reads theAuthorizationheader, checks it starts withBearer, and returns the token string. If the header is missing, it responds with 401{"detail": "Not authenticated"}before your code runs. ItstokenUrltells the docs UI where to log in.get_current_userdepends on that scheme, verifies the token, and loads the user fresh. Loading the user on each request (rather than trusting everything in the token) means disabling an account takes effect right away, even for tokens that haven't expired.CurrentUseris anAnnotatedalias, so protecting a route is a single typed parameter.
Using it:
# app/main.py (continued)
@app.get("/users/me")
async def read_me(user: CurrentUser) -> User:
return user
TOKEN=$(curl -s -X POST http://127.0.0.1:8000/auth/token \
-d "username=ada" -d "password=correct horse battery staple" | python -c "import sys, json; print(json.load(sys.stdin)['access_token'])")
curl http://127.0.0.1:8000/users/me -H "Authorization: Bearer $TOKEN"
{
"username": "ada",
"full_name": "Ada Lovelace",
"disabled": false,
"scopes": ["tasks:read", "tasks:write"]
}
FastAPI resolves dependencies once per request and caches them, so if several dependencies in the same request each depend on get_current_user, the token is decoded only once.
To protect every route in a router at once, put the dependency on the router: APIRouter(dependencies=[Depends(get_current_user)]).
Login in the Interactive Docs
Because the security scheme is declared with OAuth2PasswordBearer, the generated docs at /docs show an Authorize button. Enter a username and password, and the docs UI calls /auth/token and attaches the token to every request you try. It's the quickest way to poke at a protected API by hand.
Permissions with Scopes
Authentication answers "who are you?" Authorization answers "what are you allowed to do?" A simple, effective approach is to put the user's permissions in the token as scopes and check them per route:
# app/deps.py (continued)
def require_scope(scope: str):
async def checker(user: CurrentUser) -> User:
if scope not in user.scopes:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required scope: {scope}",
)
return user
return checker
require_scope is a dependency factory: calling it returns a new dependency that requires one specific scope. Because checker depends on CurrentUser, it also inherits authentication. It's a closure over scope.
# app/main.py (continued)
@app.get("/tasks")
async def list_tasks(user: Annotated[User, Depends(require_scope("tasks:read"))]) -> list[str]:
return [f"{user.username}: write the report", f"{user.username}: review PRs"]
@app.post("/tasks", status_code=201)
async def create_task(user: Annotated[User, Depends(require_scope("tasks:write"))]) -> dict[str, str]:
return {"created_by": user.username}
Bob has only tasks:read, so he can list tasks but gets a 403 when creating one:
{ "detail": "Missing required scope: tasks:write" }
Note the status codes: 401 means "not authenticated" (missing, invalid or expired token; the client should log in again), while 403 means "authenticated, but not allowed". Keep them distinct so clients can react correctly.
Here the user's scopes are checked from the freshly loaded user record. You could instead check the scopes claim from the token, which avoids a lookup but means permission changes only take effect when the token expires. FastAPI also has a SecurityScopes mechanism that integrates scopes into the OpenAPI docs, if you want the docs UI to request specific scopes.
Testing Authentication
Auth code deserves tests for the failure paths as much as the happy path. With TestClient and pytest:
# tests/test_auth.py
from datetime import datetime, timedelta, timezone
import jwt
from fastapi.testclient import TestClient
from app.config import settings
from app.main import app
client = TestClient(app)
def login(username: str, password: str):
return client.post("/auth/token", data={"username": username, "password": password})
def auth_header(username: str, password: str) -> dict[str, str]:
token = login(username, password).json()["access_token"]
return {"Authorization": f"Bearer {token}"}
def test_wrong_password_is_rejected():
response = login("ada", "nope")
assert response.status_code == 401
assert response.headers["www-authenticate"] == "Bearer"
def test_me_requires_a_token():
assert client.get("/users/me").status_code == 401
def test_me_returns_current_user():
response = client.get("/users/me", headers=auth_header("ada", "correct horse battery staple"))
assert response.json()["full_name"] == "Ada Lovelace"
assert "hashed_password" not in response.json()
def test_scope_is_enforced():
headers = auth_header("bob", "another long passphrase")
assert client.get("/tasks", headers=headers).status_code == 200
assert client.post("/tasks", headers=headers).status_code == 403
def test_expired_token_is_rejected():
past = datetime.now(timezone.utc) - timedelta(hours=1)
token = jwt.encode(
{"sub": "ada", "iat": past, "exp": past + timedelta(minutes=5), "iss": settings.jwt_issuer},
settings.jwt_secret,
algorithm="HS256",
)
response = client.get("/users/me", headers={"Authorization": f"Bearer {token}"})
assert response.status_code == 401
def test_tampered_token_is_rejected():
token = login("ada", "correct horse battery staple").json()["access_token"]
header, payload, signature = token.split(".")
forged = f"{header}.{payload}.{signature[::-1]}"
response = client.get("/users/me", headers={"Authorization": f"Bearer {forged}"})
assert response.status_code == 401
Run them with a test secret in the environment:
JWT_SECRET=test-secret-that-is-at-least-32-chars python -m pytest -q
Running pytest through python -m puts the project root on sys.path, so import app works from the tests/ folder. Note data= rather than json= for the login request; the OAuth2 password flow uses form encoding. For tests of other endpoints that shouldn't care about auth at all, you can bypass it with app.dependency_overrides[get_current_user] = lambda: some_user.
Refresh Tokens and Logout
Short-lived access tokens raise an obvious question: does the user have to type their password every 15 minutes? The standard answer is a refresh token: a longer-lived credential (days or weeks) that's only accepted by a /auth/refresh endpoint, which issues a new access token.
Unlike access tokens, refresh tokens are usually stored server-side (or at least tracked by ID in a database), which buys you what plain JWTs lack:
- Logout deletes or revokes the refresh token. The access token still works until it expires, which is why it's short.
- Rotation: issue a new refresh token on every refresh and invalidate the old one. If an old token is ever reused, you know it was stolen and can revoke the whole session.
For browser apps, consider whether you need bearer tokens at all. A session cookie marked HttpOnly, Secure, and SameSite keeps the credential out of reach of JavaScript (and therefore XSS), at the cost of needing CSRF protection. Bearer tokens shine for mobile apps, CLIs, and service-to-service calls.
Security Checklist
- Serve everything over HTTPS. Bearer tokens and passwords are plain text on the wire otherwise.
- Hash passwords with Argon2 (or bcrypt), never a fast hash, and never log raw passwords or tokens.
- Load the JWT secret from the environment; make it long and random; rotate it if it leaks (which invalidates every token).
- Always pass an explicit
algorithms=[...]list tojwt.decode(). - Keep access tokens short-lived and validate
exp,iss(andaudif you issue tokens for several services). - Use the same error message and similar timing for unknown users and wrong passwords.
- Rate-limit the login endpoint to slow down password guessing.
- Don't put sensitive data in the payload; it's readable by anyone with the token.
- If several services need to verify tokens, consider asymmetric signing (RS256 or EdDSA): only the auth service holds the private key, and the others verify with the public key.
- For anything large, or if you need social login, SSO, or MFA, consider a dedicated identity provider and verify its tokens in FastAPI instead of building it all yourself.
Conclusion
FastAPI's OAuth2 helpers and dependency injection make token auth feel native. Hash passwords with Argon2 through pwdlib, issue short-lived JWTs signed with a secret from the environment, and verify them with PyJWT in a get_current_user dependency that loads the user fresh on each request. Protecting a route is then a single CurrentUser parameter, and dependency factories like require_scope layer permissions on top. Add tests for the failure cases, refresh tokens when you need longer sessions, and you have an auth setup you can understand end to end.


