60.6 Authentication: OAuth2, JWT, and API Keys
Right, let’s talk about keeping the barbarians at the gate. You’ve built this fantastic API with FastAPI, and now you need to decide who gets to play with it and what they’re allowed to do. This isn’t just about security; it’s about accountability, rate limiting, and knowing who to blame when someone requests /api/delete-all-production-data at 3 AM.
The three big players in this space are API Keys, OAuth2, and JWTs. They’re not mutually exclusive; in fact, they often work together. An API key might get you in the door, but OAuth2 dictates what rooms you can enter, and a JWT is the temporary, holographic ID card you get at the front desk that proves it.
The Humble API Key: Your First Bouncer
Don’t let its simplicity fool you. For server-to-server communication, machine-to-machine (M2M) stuff, or giving a specific client long-term access, an API key is often the perfect, no-nonsense tool. It’s just a string, a shared secret. You send it, I check it against my database, and if it matches, you’re in. The downside? If it gets leaked, anyone has the keys to the kingdom until you manually revoke it.
Here’s how you implement a basic API key dependency in FastAPI. We’ll stick it in a header because that’s the conventional place for it.
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import APIKeyHeader
from sqlalchemy.orm import Session
from .database import get_db # your local session maker
from . import models # your SQLAlchemy models
api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
app = FastAPI()
def get_user_from_api_key(api_key: str = Depends(api_key_header), db: Session = Depends(get_db)):
if not api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing API Key"
)
# Look up the key in your database
db_key = db.query(models.APIKey).filter(models.APIKey.key == api_key).first()
if not db_key or not db_key.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or inactive API Key"
)
return db_key.user
@app.get("/protected/")
async def read_protected_data(user: models.User = Depends(get_user_from_api_key)):
return {"message": f"Hello, {user.username}", "data": "super_secret_data"}
The auto_error=False is crucial. It lets us check for the key’s existence ourselves and throw a much nicer error than the default. Now, any request to /protected/ must include a header like X-API-Key: some-long-random-string.
OAuth2 with Password Flow: The Delegated Dance
OAuth2 is a framework for delegation, not strictly authentication. Its goal is to let a user grant your application (the client) limited access to their data on another service (like Google or GitHub) without handing over their password. The “password” flow we often use for our own login forms is actually the least recommended OAuth2 flow by the spec itself—it’s a legacy concession for first-party clients you absolutely trust. FastAPI’s OAuth2PasswordBearer sets up the expectation for this flow.
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
# Configuration - put these in environment variables, I'm serious.
SECRET_KEY = "your-secret-key" # Use `openssl rand -hex 32` to get a good one
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # This points to your login endpoint
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict):
to_encode = data.copy()
# Always set an expiration! JWTs are meant to be short-lived.
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# Decode and validate the JWT
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(models.User).filter(models.User.username == username).first()
if user is None:
raise credentials_exception
return user
@app.post("/token")
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.username == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# The 'sub' key is a standard for the "subject" of the token
access_token = create_access_token(data={"sub": user.username})
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/users/me/")
async def read_users_me(current_user: models.User = Depends(get_current_user)):
return current_user
Notice the dance? You POST credentials to /token, it gives you a JWT. You then send that JWT in the Authorization: Bearer <token> header to access protected routes. get_current_user becomes your universal dependency for any endpoint that requires a logged-in user.
JWTs: The Self-Contained Truth (Mostly)
A JSON Web Token (JWT) is the star of the show above. It’s a compact, URL-safe string that contains claims (like the user’s ID and an expiry). The beauty is that it’s stateless. Your API can verify its integrity and read the data without making a database call every time, because it’s cryptographically signed with your SECRET_KEY. This is fantastic for performance and scalability.
The catch? You can’t revoke them before they expire. They’re like a movie ticket. Once you tear it and walk in, you can’t just tell the usher to make that specific ticket invalid until the show is over. This is why you keep the expiration time short (15-30 minutes) and pair it with a refresh token pattern if you need longer sessions. The other massive pitfall is thinking the payload is secure. It’s only encoded and signed, not encrypted (unless you use JWE, which almost nobody does). Anyone can decode the payload and read the contents. Never put sensitive information like passwords or credit card numbers in a JWT.