So you want to add login to your FastAPI project. Sounds simple, right? Then you actually sit down to build it and suddenly you've got a million questions. Sessions or tokens? Is JWT the same as OAuth2? How do you stop a regular customer from hitting an endpoint that's only supposed to work for admins? And why does every tutorial online still tell you to pip install passlib, a library that basically nobody's touched in years?
This post covers FastAPI authentication (figuring out who someone is) and authorization (deciding what they're allowed to do) using the stuff that's actually current right now: PyJWT for tokens, pwdlib for password hashing, and FastAPI's built-in OAuth2PasswordBearer. We'll wire it all together using a real example — a food-delivery API where customers and restaurant owners both log into the same system, but only one of them should be allowed to touch the menu.
Authentication vs Authorization — these are NOT the same thing
People use these two words like they mean the same thing. They don't.
- Authentication = "who are you?" A user logs in with an email and password, and the server checks they really are who they say they are.
- Authorization = "okay cool, but what are you actually allowed to do?" Once we know who someone is, do they have permission for this one specific action?
A logged-in customer is authenticated. That does NOT mean they're authorized to go update a restaurant's menu. Mixing these two up is literally how APIs end up with broken access control — which OWASP has ranked the #1 API security risk for years now. It's super common: getting login working is easy to test ("can this person log in?"), but people forget to test the second, more important question — "can this person do that specific thing?"
OAuth2 and JWT — what's the actual difference
These two get confused all the time too:
- OAuth2 is a framework — basically a rulebook for how a client gets a token and hands it to your API. FastAPI's
OAuth2PasswordBeareruses OAuth2's "password flow," which is what runs your own login form (different from "Login with Google," which uses a different OAuth2 flow entirely). - JWT (JSON Web Token) is the actual token — a compact signed string carrying the user's identity and stuff like their role. This is literally what OAuth2 hands back to the client after they log in successfully.
So in a normal FastAPI app: OAuth2 is the rulebook for how login works and how that "Authorize" button shows up in Swagger docs. JWT is the actual key the client keeps holding onto afterward.
What to actually install (skip passlib)
Almost every tutorial you'll find uses passlib with CryptContext. Don't. Here's why — passlib hasn't been properly maintained in years, throws deprecation warnings on modern Python, and its bcrypt backend has actually broken with newer bcrypt versions. Even FastAPI's own official project template moved away from it.
Use pwdlib instead — it's built by one of FastAPI's own maintainers specifically to replace passlib, and it defaults to Argon2, which is the actual password-hashing competition winner and stronger than bcrypt by default.
pip install "pwdlib[argon2]" pyjwt "fastapi[standard]"Step 1: Hashing passwords and creating tokens
# app/security.py
from datetime import datetime, timedelta, timezone
import jwt
from pwdlib import PasswordHash
SECRET_KEY = "change-this-to-a-real-secret-loaded-from-env"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 20
REFRESH_TOKEN_EXPIRE_DAYS = 7
password_hash = PasswordHash.recommended() # Argon2 under the hood
def hash_password(plain_password: str) -> str:
return password_hash.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return password_hash.verify(plain_password, hashed_password)
def create_access_token(subject: str, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": subject, "role": role, "exp": expire, "type": "access"}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
payload = {"sub": subject, "exp": expire, "type": "refresh"}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])Quick breakdown of the choices here:
- Access tokens expire in 20 minutes. On purpose, kept short. JWTs can't really be revoked before they expire unless you build extra stuff (like a Redis blocklist), so keeping the window small limits how much damage a leaked token can do.
- Refresh tokens last 7 days — they exist purely so you're not forced to log in again every 20 minutes. They can only be swapped for a fresh access token, never used directly on protected routes. That's why both token types carry a
"type"field. - The role gets baked right into the access token (
role: "customer"orrole: "restaurant_owner"). This means checking permissions later doesn't need a database call — the role literally travels with the token.
That SECRET_KEY up there is hardcoded just so it's readable in this post. In a real app, load it from an environment variable. Never, ever commit it.
Step 2: A user model with a role field
# app/models.py
import enum
from sqlalchemy import Column, Integer, String, Enum
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class UserRole(str, enum.Enum):
customer = "customer"
restaurant_owner = "restaurant_owner"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, unique=True, index=True, nullable=False)
hashed_password = Column(String, nullable=False)
role = Column(Enum(UserRole), nullable=False, default=UserRole.customer)This is basically the smallest role setup possible — just two roles — but the same pattern scales to way more (admin, delivery_rider, whatever) without touching any of the authorization logic below.
Step 3: Dependencies that handle both auth AND permissions
This is where FastAPI's dependency injection stuff really earns its place. Instead of manually checking a token inside every single route, you write the check once and just attach it with Depends(...).
# app/deps.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
import jwt
from app.security import decode_token
from app.models import UserRole
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
credentials_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
if payload.get("type") != "access":
raise credentials_error
return {"email": payload["sub"], "role": payload["role"]}
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError:
raise credentials_error
def require_role(*allowed_roles: UserRole):
"""
Dependency factory: require_role(UserRole.restaurant_owner) restricts
an endpoint to only that role, while every route still shares the
same get_current_user authentication check underneath.
"""
def checker(current_user: dict = Depends(get_current_user)) -> dict:
if current_user["role"] not in [r.value for r in allowed_roles]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to perform this action",
)
return current_user
return checkerget_current_user deals with authentication — is this a real, non-expired access token? require_role(...) deals with authorization — okay, we know who this is, but are they actually allowed to be here? Keeping these as two separate little dependencies (instead of one messy tangled function) is exactly what makes it easy to add a third role later without rewriting everything.
Also notice the status codes here — an auth failure returns 401 Unauthorized ("I have no idea who you are"), while a permissions failure returns 403 Forbidden ("I know exactly who you are, and no"). People mix these two up constantly and it makes debugging client-side auth issues way more annoying than it needs to be.
Step 4: Actually wiring it into endpoints
# app/main.py
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel, EmailStr
import jwt
from app.deps import get_current_user, require_role
from app.models import UserRole
from app.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
app = FastAPI(title="Foodie")
# Stand-in for a real DB table — swap for your SQLAlchemy session in production.
fake_users_db: dict[str, dict] = {}
class SignupRequest(BaseModel):
email: EmailStr
password: str
role: UserRole = UserRole.customer
class RefreshRequest(BaseModel):
refresh_token: str
@app.post("/auth/signup", status_code=status.HTTP_201_CREATED)
def signup(payload: SignupRequest):
if payload.email in fake_users_db:
raise HTTPException(status_code=400, detail="Email already registered")
fake_users_db[payload.email] = {
"email": payload.email,
"hashed_password": hash_password(payload.password),
"role": payload.role.value,
}
return {"email": payload.email, "role": payload.role.value}
@app.post("/auth/login")
def login(form_data: OAuth2PasswordRequestForm = Depends()):
# OAuth2PasswordRequestForm expects fields named "username" and
# "password" even though we treat "username" as the user's email —
# that's the OAuth2 spec's naming, not a design choice of ours.
user = fake_users_db.get(form_data.username)
if not user or not verify_password(form_data.password, user["hashed_password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(subject=user["email"], role=user["role"])
refresh_token = create_refresh_token(subject=user["email"])
return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
}
@app.post("/auth/refresh")
def refresh(payload: RefreshRequest):
try:
decoded = decode_token(payload.refresh_token)
if decoded.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Refresh token expired, please log in again")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid refresh token")
user = fake_users_db.get(decoded["sub"])
if not user:
raise HTTPException(status_code=401, detail="User no longer exists")
new_access_token = create_access_token(subject=user["email"], role=user["role"])
return {"access_token": new_access_token, "token_type": "bearer"}
@app.get("/orders/mine")
def get_my_orders(current_user: dict = Depends(get_current_user)):
"""Any authenticated user — customer or restaurant owner — can hit this."""
return {"email": current_user["email"], "orders": []}
@app.post("/restaurants/{restaurant_id}/menu")
def update_menu(
restaurant_id: int,
current_user: dict = Depends(require_role(UserRole.restaurant_owner)),
):
"""Only restaurant owners can update a menu — customers get a 403."""
return {"restaurant_id": restaurant_id, "updated_by": current_user["email"]}/orders/mine only needs get_current_user — literally any logged-in user, doesn't matter what role, can check their own orders. /restaurants/{id}/menu needs require_role(UserRole.restaurant_owner) — so a customer with a perfectly valid, working token still gets bounced here, because being authenticated doesn't automatically mean you're authorized.
Does it actually work? Let's check
Running this against real requests (not just staring at the code hoping it's right) gives you exactly what you'd want:
| Request | Expected | Actual |
|---|---|---|
Customer signs up, logs in, hits /orders/mine | 200 OK | ✅ 200 |
Customer tries POST /restaurants/5/menu | 403 Forbidden | ✅ 403 — "You don't have permission to perform this action" |
| Restaurant owner hits the same menu route | 200 OK | ✅ 200 |
| No token at all on a protected route | 401 Unauthorized | ✅ 401 — "Not authenticated" |
| Garbage/tampered token | 401 Unauthorized | ✅ 401 — "Could not validate credentials" |
| Wrong password on login | 401 Unauthorized | ✅ 401 — "Incorrect email or password" |
| Valid refresh token → new access token | 200 OK, fresh token issued | ✅ 200 |
That's basically the whole auth lifecycle right there: sign up, log in, get rejected with no token, get rejected with the wrong role, succeed with the right role, and refresh your token without having to type your password again.
Mistakes people actually make with this
- Storing plaintext passwords, or using MD5/SHA256 directly. These hash functions are fast, which is exactly the opposite of what you want for passwords — fast means cheap to brute-force. Argon2 (through pwdlib) and bcrypt are deliberately slow, on purpose.
- Never checking the token's
typeclaim. If you don't tell access tokens and refresh tokens apart, nothing stops a client from using a long-lived refresh token to hit protected routes directly — which completely defeats the point of having short-lived access tokens in the first place. - Putting the role check inside the endpoint body instead of a dependency. It technically works, but now every single dev touching this codebase has to remember to manually add that check on every new restricted route. A missed check like that is literally how broken access control bugs happen. Using
Depends(require_role(...))makes the restriction visible right there in the function signature — hard to miss. - Assuming JWT is automatically "more secure." JWT is stateless and fast to verify — that's its advantage, not some automatic security upgrade. If you need to instantly log someone out mid-session, you need extra infrastructure like a Redis-backed blocklist. JWT alone can't do that on its own.
- Hardcoding the secret key in your source code. The example above does it just to keep things readable for this post — in any real deployment, pull
SECRET_KEYfrom an environment variable or a proper secrets manager. Never commit it.
Where to go from here
This setup is kept pretty minimal on purpose so the authentication/authorization split stays easy to see. From here, natural next steps are:
- Swap the fake in-memory
fake_users_dbfor an actual SQLAlchemy session using theUsermodel above. - Add a Redis-backed blocklist so you can instantly revoke tokens on logout.
- Add "Login with Google" using OAuth2's authorization-code flow (a different flow from the password flow used here) with something like Authlib.
- Move from a flat role string to more fine-grained permissions/scopes once two roles stop being enough.
But the core idea — authenticate first, authorize second, and never let one substitute for the other — stays the same no matter how big this gets.