Authentication is the part of an API that everything else depends on. Get it wrong and every endpoint behind it is open.
FastAPI makes this easier than most frameworks, because its security helpers plug into the same dependency injection system as everything else, and they document themselves in the generated OpenAPI schema. A protected endpoint is an ordinary function with one extra parameter.
This guide covers the three authentication schemes you will actually reach for, when each one fits, and the security practices that apply to all of them:
Basic HTTP authentication, for internal APIs, service scripts, and prototypes
API key authentication, for public APIs, third-party integrations, and machine clients
Session-based authentication, for browser applications with a login form
Token-based authentication with JSON Web Tokens is covered separately in our guide to securing FastAPI APIs with JWT, because it needs more space than a section here allows.
Authentication vs Authorization
These get confused constantly, and FastAPI handles them at different points.
Authentication answers "who are you?" It runs when a request arrives, checks a credential, and either identifies the caller or rejects the request with a 401.
Authorization answers "what are you allowed to do?" It runs after authentication succeeds, checks the identified caller against a permission rule, and either allows the action or rejects it with a 403.
Authentication gets you into the building. Authorization decides which rooms you can enter. The status codes matter: 401 means "we do not know who you are", 403 means "we know who you are and the answer is no". Returning the wrong one makes client debugging much harder. Our guide to HTTP status codes for clear API responses covers the wider set.
Setting Up the Project
Create the project directory:
mkdir fastapi-auth-demo && cd fastapi-auth-demo
Create and activate a virtual environment:
python -m venv venv
# macOS and Linux
source venv/bin/activate
# Windows
.\venv\Scripts\activate
Add the dependencies to requirements.txt:
fastapi==0.115.0
uvicorn[standard]==0.30.6
python-multipart==0.0.9
passlib[bcrypt]==1.7.4
itsdangerous==2.2.0
pydantic-settings==2.5.2
Install them:
pip install -r requirements.txt
Create the application at main.py:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="FastAPI Authentication Demo", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["https://yourfrontend.example"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Note the allow_origins value. A wildcard combined with allow_credentials=True is rejected by browsers and is a security problem besides. Name your origins.
Run the server:
uvicorn main:app --reload
Basic HTTP Authentication
Basic auth sends a username and password with every request, base64 encoded in the Authorization header. Base64 is encoding, not encryption, so this scheme is only safe over HTTPS.
It fits internal APIs, cron jobs, and prototypes. It does not fit anything user-facing, because there is no way to log out and no way to revoke one client without changing the password.
Add this to main.py:
import secrets
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from passlib.context import CryptContext
security = HTTPBasic()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# In production these come from your database, never from a dictionary
users_db = {
"john": {"username": "john", "hashed_password": pwd_context.hash("secret123"), "role": "user"},
"admin": {"username": "admin", "hashed_password": pwd_context.hash("supersecret"), "role": "admin"},
}
def authenticate_user(credentials: HTTPBasicCredentials = Depends(security)):
user = users_db.get(credentials.username)
# Hash a dummy value when the user is missing so the response time
# does not reveal which usernames exist
hashed = user["hashed_password"] if user else pwd_context.hash("dummy")
password_ok = pwd_context.verify(credentials.password, hashed)
if not user or not password_ok:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
return user
@app.get("/basic/profile")
def read_profile(user: dict = Depends(authenticate_user)):
return {"message": f"Welcome {user['username']}"}
Two details are doing real work here. The WWW-Authenticate header is what tells a browser or client which scheme to retry with, and leaving it out breaks well-behaved clients. Verifying against a dummy hash when the username is missing keeps the response time constant, so an attacker cannot enumerate valid usernames by timing the endpoint.
Test it:
curl -u john:secret123 http://localhost:8080/basic/profile
Adding Authorization
Authentication told us who the caller is. Authorization decides what they can do. Build it as a separate dependency so the rule is written once and reused:
from typing import Callable
def require_role(required_role: str) -> Callable:
def role_checker(user: dict = Depends(authenticate_user)) -> dict:
if user["role"] != required_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions",
)
return user
return role_checker
@app.get("/basic/admin")
def admin_area(user: dict = Depends(require_role("admin"))):
return {"message": "Admin area"}
The endpoint now declares its own permission requirement in its signature, and the check cannot be forgotten. Scattering if user.role != "admin" through handler bodies is how endpoints end up unprotected.
API Key Authentication
API keys identify a client application rather than a person. They suit public APIs, server-to-server calls, and anything automated, because there is no login flow and no session to expire.
FastAPI ships APIKeyHeader for exactly this. Keys travel in a custom header:
from fastapi.security import APIKeyHeader
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
# Store hashes, never raw keys. Show the raw key to the user once, at creation
api_keys_db = {
pwd_context.hash("demo_key_do_not_use"): {"client": "demo", "scopes": ["read"]},
}
def validate_api_key(api_key: str = Depends(api_key_header)) -> dict:
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API key",
)
for stored_hash, client in api_keys_db.items():
if pwd_context.verify(api_key, stored_hash):
return client
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid API key",
)
@app.get("/apikey/data")
def read_data(client: dict = Depends(validate_api_key)):
return {"client": client["client"], "data": ["item1", "item2"]}
@app.post("/apikey/items")
def create_item(item: dict, client: dict = Depends(validate_api_key)):
if "write" not in client["scopes"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Write scope required",
)
return {"message": "Item created", "item": item}
Call it:
curl -H "X-API-Key: demo_key_do_not_use" http://localhost:8080/apikey/data
Store hashes rather than raw keys, the same way you store passwords. A database leak then exposes hashes instead of working credentials. Show the raw key once at creation and never again.
Bearer Tokens With HTTPBearer
HTTPBearer is a different scheme, and it gets confused with API keys because both are opaque strings in a header. Bearer tokens travel in the standard Authorization: Bearer <token> header:
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
bearer_scheme = HTTPBearer()
def validate_bearer_token(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> dict:
token = credentials.credentials
# Verify the token here. For JWTs, decode and check the signature and expiry
if token != "a-token-you-verified":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"},
)
return {"client": "bearer-client"}
Use APIKeyHeader for long-lived keys a developer pastes into a config file. Use HTTPBearer for short-lived tokens issued by a login flow, which is where JWTs belong.
Session-Based Authentication
Sessions suit browser applications. The user logs in once, the server sets a cookie, and the browser sends it automatically on every subsequent request. Because the server holds the session state, logging out actually invalidates it, which is the one thing stateless tokens cannot do without extra machinery.
from fastapi import Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from starlette.middleware.sessions import SessionMiddleware
from config import settings
app.add_middleware(
SessionMiddleware,
secret_key=settings.secret_key,
https_only=True,
same_site="lax",
)
class LoginSchema(BaseModel):
username: str
password: str
@app.post("/login")
async def login(request: Request, payload: LoginSchema):
user = users_db.get(payload.username)
hashed = user["hashed_password"] if user else pwd_context.hash("dummy")
if not user or not pwd_context.verify(payload.password, hashed):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
request.session["user"] = payload.username
return JSONResponse({"message": "Login successful"})
def get_current_user(request: Request) -> str:
user = request.session.get("user")
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
)
return user
@app.get("/dashboard")
async def dashboard(user: str = Depends(get_current_user)):
return {"message": f"Welcome to your dashboard, {user}"}
@app.post("/logout")
async def logout(request: Request):
request.session.clear()
return {"message": "Logged out"}
https_only=True stops the cookie travelling over plain HTTP. same_site="lax" blocks the cookie on cross-site POST requests, which removes the simplest class of cross-site request forgery. Both default to weaker values, so set them explicitly.
Choosing Between the Three
| Basic auth | API keys | Sessions | |
|---|---|---|---|
| Identifies | A user | A client application | A user |
| State on the server | None | Key records | Session store |
| Can be revoked individually | No | Yes | Yes |
| Works in a browser login flow | Poorly | No | Yes |
| Good for machine clients | Yes | Yes | No |
| Credential sent every request | Yes | Yes | Cookie only |
| Typical use | Internal APIs, scripts | Public APIs, integrations | Web applications |
If none of these fit, you probably want token-based authentication, which our token-based vs session-based authentication guide compares directly.
Consistent Error Handling
Authentication failures should look identical whether the username was wrong, the password was wrong, or the account does not exist. Different messages let an attacker enumerate accounts.
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(
status_code=exc.status_code,
content={
"error": {
"message": exc.detail,
"type": "authentication_error" if exc.status_code == 401 else "authorization_error",
"status_code": exc.status_code,
}
},
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"error": {
"message": "Validation error",
"type": "validation_error",
"details": exc.errors(),
}
},
)
Security Practices That Apply to All Three
Hash Passwords, Never Store Them
bcrypt through passlib is the standard choice, and it is deliberately slow so that brute-forcing a stolen hash stays expensive:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
Keep Secrets Out of the Code
Load configuration from the environment. Create config.py:
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
secret_key: str
algorithm: str = "HS256"
access_token_expire_minutes: int = 30
class Config:
env_file = ".env"
settings = Settings()
Declaring secret_key without a default means the application refuses to start when the variable is missing, rather than silently running on a placeholder. Add .env to your .gitignore before the first commit.
Rate Limit Authentication Endpoints
Login and token endpoints need a request cap, or they become a password-guessing service:
import time
from collections import defaultdict
from fastapi import Request
# In-memory only. Use Redis in production so the limit holds across processes
request_log = defaultdict(list)
def rate_limit(request: Request, max_requests: int = 5, window_seconds: int = 60):
client_ip = request.client.host
now = time.time()
window_start = now - window_seconds
request_log[client_ip] = [t for t in request_log[client_ip] if t > window_start]
if len(request_log[client_ip]) >= max_requests:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many requests",
)
request_log[client_ip].append(now)
An in-memory counter resets on restart and does not hold across workers. Redis is the standard fix, and our caching guide covers the setup pattern, which is the same regardless of language.
Always Use HTTPS in Production
Basic auth credentials, API keys, and session cookies are all readable in transit without TLS. This is not optional for any of the three schemes.
Use Constant-Time Comparison
Comparing secrets with == leaks information through timing. Use secrets.compare_digest for raw string comparison, or a hashing library's own verify function, which already does this.
Frequently Asked Questions
What Is the Best Authentication Method for FastAPI?
It depends on the client. Browser applications with a login form are best served by sessions or JWTs. Public APIs consumed by other servers want API keys. Internal tools and scripts can use basic auth over HTTPS. There is no single answer, and any guide that gives you one is skipping the question.
What Is the Difference Between Authentication and Authorization in FastAPI?
Authentication identifies the caller and returns 401 when it fails. Authorization checks what that caller is permitted to do and returns 403 when it fails. In FastAPI both are dependencies, and authorization dependencies usually depend on the authentication one.
How Do I Add an API Key to FastAPI?
Use APIKeyHeader from fastapi.security, give it the header name your clients will send, and write a dependency that looks the key up and raises a 401 when it is missing or invalid. Store hashed keys rather than raw ones.
Should I Use HTTPBearer or APIKeyHeader?
HTTPBearer reads the standard Authorization: Bearer <token> header and suits short-lived tokens issued by a login flow. APIKeyHeader reads a custom header and suits long-lived keys a developer configures once. The mechanics are similar. The lifecycle is not.
Is Session Authentication Still Worth Using?
Yes, particularly for server-rendered applications and anywhere immediate logout matters. Sessions can be invalidated on the server the moment a user logs out. A stateless token cannot, without adding a revocation list that reintroduces the state you were trying to avoid.
How Do I Test Protected FastAPI Endpoints?
Use FastAPI's TestClient and override the authentication dependency with app.dependency_overrides. That lets you test the endpoint logic without constructing real credentials, and lets you test the auth dependency separately with its own cases.
Summary
FastAPI gives you three practical authentication schemes, and choosing between them is a question about your clients rather than about the framework.
Basic auth is the simplest and the most limited. Use it over HTTPS for internal APIs and nothing else. API keys identify machine clients well and revoke cleanly, which makes them the right default for public APIs. Sessions fit browser applications and are the only one of the three where logging out genuinely ends access.
Whichever you pick, the practices underneath do not change: hash every stored credential, load secrets from the environment, rate limit the login endpoint, compare secrets in constant time, and serve everything over TLS. Those five apply to token-based authentication too, and they are what separates a working authentication system from a secure one.
To build one against a real schema rather than a dictionary, our Python backend projects include auth systems with users, roles, and permissions already specified.
Building an API? Learn to ship it and scale it.
Stop Being A Junior Developer takes you through one real build, from an empty repo to surviving 50,000 requests a second, in a weekend. Real code, real diagrams, and a circle of engineers doing it with you.


