Authentication & Security
Your Book API can now validate requests, shape responses, and talk to a real database. There's one
thing missing that every real service needs: a lock on the door. Right now anyone who can reach
/books can create, edit, or delete a book. This phase puts a guard at the entrance — and the good
news is you already know the guard's job description. In FastAPI, security is built almost entirely on
the dependency system from Phase 5. "This endpoint requires a logged-in
user" is just "this endpoint depends on there being a current user."
Auth has a reputation for being scary. A lot of that fear comes from treating it as one giant blob. We're going to take it apart into four small, separate jobs, and each one will turn out to be approachable on its own. Mental model first, always.
Two different jobs: authentication vs authorization
📝 These two words look almost identical and people mix them up constantly, so pin them down now:
- Authentication (authn) = who you are. Proving your identity. This is logging in: you hand over a username and password, and the server confirms you are who you claim to be.
- Authorization (authz) = what you're allowed to do. Permissions. Once the server knows you're Ada, can Ada delete this book? Maybe only admins can.
A passport proves who you are (authn). A concert ticket says what you're allowed into (authz). You need both, and they're different checks. We dig into the full mental model — sessions, tokens, OAuth — in a dedicated guide: Auth vs Authz. Here we focus on wiring it into FastAPI.
💡 The throughline for this whole phase: both jobs flow through Depends(). Authentication is a
dependency that figures out who is calling. Authorization is a check inside that dependency (or a
second one) that decides whether they may proceed. Same machinery, two questions.
Step 1: never store passwords in plaintext
Before anyone can log in, you need somewhere to keep their credentials — and the single most important rule in this entire guide is this:
⚠️ Never store a password as plaintext. Not in your database, not in a log file, not anywhere. If your database leaks (and databases leak), every plaintext password is instantly stolen — and because people reuse passwords, you've also handed attackers their email and bank logins.
📝 Instead you store a hash: a one-way scramble of the password. You run the password through a slow, deliberate hashing algorithm (bcrypt or argon2 are the standard choices) and store the result. When someone logs in, you hash what they typed and compare it to the stored hash. You never need the original password back — that's the whole point of "one-way." The deeper story of salting, slow hashing, and why these algorithms exist is in How Passwords Are Stored.
In Python, the passlib library wraps all of this. Here's the shape (shown as plain code — passlib
isn't guaranteed in the browser sandbox, so read it, don't run it):
# bcrypt is a solid, battle-tested default
=
return
return
# at signup time:
=
# -> '$2b$12$....' an opaque hash, NOT the password
# at login time:
# True
# False
What just happened: hash_password turns a plaintext password into an opaque string you can safely
store on a User row. verify_password re-hashes the login attempt and checks it against that stored
hash, returning True/False — without ever un-scrambling anything. You store the output of
hash_password, and you only ever compare with verify_password. The plaintext password lives for a
fraction of a second in memory and is never written down.
💡 Notice you didn't write the bcrypt algorithm yourself. That's deliberate — see the last section.
Step 2: the OAuth2 password flow + JWT
So a user can sign up and we store their hash. Now: how do they log in, and how does the server remember them on the next request? HTTP is stateless — each request arrives with no memory of the last one. We can't make the user re-send their password on every call (that would mean storing it in the client, the exact thing we're avoiding).
📝 The standard answer for APIs is the OAuth2 password flow:
- The client POSTs username + password once to a
/tokenendpoint. - The server verifies the password against the stored hash.
- If it checks out, the server hands back a signed access token.
- On every later request, the client sends that token in the header:
Authorization: Bearer <token>— no password needed again.
The token of choice here is a JWT (JSON Web Token). 📝 A JWT is a compact, signed, self-contained string carrying a few claims (small facts, like "subject: ada" and "expires: 3pm"). "Signed" means the server stamps it with a secret key so it can later verify the token wasn't tampered with — change one character and the signature breaks.
⚠️ The trap everyone falls into: a JWT is signed, not encrypted. Anyone holding it can decode and read the payload (it's just base64 — paste one into jwt.io and you'll see). The signature stops people from forging tokens; it does not hide what's inside. So never put secrets (passwords, credit card numbers, anything sensitive) in a JWT payload.
Here's the /token endpoint. It uses OAuth2PasswordRequestForm, FastAPI's helper that reads the
standard username/password form fields the OAuth2 spec expects:
# python-jose, the usual JWT library
=
=
=
# pretend user store; in Phase 7 this is a real DB lookup
=
=
= +
=
return
What just happened: OAuth2PasswordRequestForm = Depends() is itself a dependency — it pulls the
username and password out of the form body for you. You verify the password against the stored hash
with the function from Step 1. On success you build a JWT carrying the username (sub, "subject"), the
role, and an expiry (exp), sign it with your secret, and return it in the shape OAuth2 clients expect:
{"access_token": ..., "token_type": "bearer"}. On failure you return a clean 401 — note we don't say
which field was wrong, so we don't help attackers guess usernames.
A login request and its response look like this:
POST /token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=ada&password=correct+horse+battery+staple
What just happened: the client sent form-encoded credentials (not JSON — the OAuth2 password flow uses
a form body) and got back a token. The client stashes that access_token and attaches it to every
future request. It never sends the password again until the token expires.
Step 3: securing endpoints with a dependency
Now the payoff. We turn "is this caller logged in?" into a dependency, and any endpoint that wants
protection Depends on it.
First we declare the scheme. OAuth2PasswordBearer tells FastAPI where tokens come from (the
Authorization: Bearer header) and which URL issues them (so the /docs page can offer a login button):
=
=
=
# bad signature, expired, or malformed
=
# in Phase 7, load the real row: user = db.get(User, username)
return
What just happened: Depends(oauth2_scheme) extracts the raw token string from the Authorization
header (and auto-rejects requests that have none). jwt.decode verifies the signature and the
expiry against your secret — if the token was forged, tampered with, or expired, it raises JWTError
and we turn that into a 401. If it's valid, we read the sub claim and return the user. This is an
ordinary dependency, exactly like the ones in Phase 5 — it just happens to do auth.
Now protect the write endpoint. Any route that requires a login adds one parameter:
return
What just happened: current_user: dict = Depends(get_current_user) is the entire lock. FastAPI runs
get_current_user before your function body. No valid token? It raises 401 and create_book never
executes. Valid token? Your endpoint receives the authenticated user and can record who created the
book. You wrote zero auth logic inside the handler — it just declares the need.
A request with no token gets stopped cold:
POST /books?title=Dune HTTP/1.1
What just happened: the request arrived with no Authorization header, so oauth2_scheme rejected it
with 401 before get_current_user even ran. Send the header — Authorization: Bearer eyJhbGci... —
and it sails through. 💡 As a bonus, because you declared OAuth2PasswordBearer, FastAPI adds an
Authorize button to /docs: testers paste a token once and the interactive docs send it on every
call.
Authorization: checking what they're allowed to do
That covered authn (who you are). For authz (what you may do), do the check inside a dependency too. Here's one that requires the admin role:
return
return
What just happened: require_admin is a sub-dependency — it depends on get_current_user to
establish who the caller is, then checks their role. A logged-in non-admin gets 403 Forbidden
(note: 401 means "I don't know who you are," 403 means "I know who you are and you can't do this").
Same dependency machinery, now answering the authorization question.
Step 4: the security must-knows
Auth code is the worst place to improvise. A handful of rules carry most of the safety:
- ⚠️ Always HTTPS in production. A Bearer token in a plaintext HTTP request is sitting in the open —
anyone on the network path can copy it and impersonate the user. TLS is what stops that; see
HTTPS & TLS. Locally
http://localhostis fine; in production it is not. - ⚠️ Keep the JWT secret out of your code. That
SECRET_KEYsigns every token. If it leaks, attackers can forge valid tokens for any user. Load it from an environment variable (or a secrets manager), never commit it, and rotate it if it's ever exposed. - ⚠️ Set token expiry, and consider refresh tokens. Short-lived access tokens (minutes to an hour) limit the damage if one is stolen. The common pattern is a short access token plus a longer-lived refresh token used only to mint new access tokens.
- ⚠️ Don't put sensitive data in the JWT payload. It's decodable, remember — username and role are fine; passwords, card numbers, and PII are not.
- 💡 Don't roll your own crypto. Use FastAPI's OAuth2 utilities and battle-tested libraries
(
passlib,python-jose). Hand-written hashing or token signing is how subtle, catastrophic bugs get in. Security is the one area where "clever" is a red flag — boring and standard wins.
Step back and notice how little new machinery this phase actually introduced. Hashing is two function
calls. The token endpoint is a normal POST handler. And protecting routes is the same Depends() you
already knew — get_current_user is just a dependency that reads a header and validates a token. Auth
felt big because it bundles four jobs together; taken one at a time, each is something you can already
do.
Recap
- Authentication (who you are) and authorization (what you may do) are two distinct checks — and in FastAPI both flow through the dependency system from Phase 5.
- Never store plaintext passwords. Hash them with bcrypt/argon2 via
passlib: store the hash, verify login attempts against it, and never recover the original. - The OAuth2 password flow takes username/password at a
/tokenendpoint (OAuth2PasswordRequestForm), verifies the hash, and returns a signed JWT the client resends asAuthorization: Bearer <token>. - A JWT is signed, not encrypted — its payload is readable by anyone, so it stops forgery but hides nothing. Keep secrets out of it.
- Protect endpoints with
OAuth2PasswordBearer+ aget_current_userdependency that decodes and validates the token; addDepends(get_current_user)to require auth (and a role check inside a sub-dependency for authorization). FastAPI even adds the Authorize button to/docs. - Security must-knows: always HTTPS in prod, keep the JWT secret in env vars, set token expiry, no sensitive data in the payload, and lean on standard libraries instead of rolling your own crypto.
Quick check
Make sure the core ideas stuck before moving on:
[
{
"q": "Your database leaks. Which storage choice means the attacker does NOT instantly have everyone's usable passwords?",
"choices": ["Passwords stored as plaintext", "Passwords stored as bcrypt/argon2 hashes", "Passwords stored base64-encoded", "Passwords stored inside the JWT payload"],
"answer": 1,
"explain": "A one-way hash (bcrypt/argon2) can't be reversed to the original password. Plaintext and base64 are both readable; base64 is just encoding, not protection."
},
{
"q": "True or false: because a JWT is signed, the data inside it is hidden from anyone holding the token.",
"choices": ["True — signing encrypts the payload", "False — a JWT is signed, not encrypted; the payload is readable", "True, but only if you use HS256", "False — JWTs have no payload at all"],
"answer": 1,
"explain": "Signing proves the token wasn't tampered with; it does not hide the contents. Anyone can base64-decode and read a JWT payload, so never put secrets in it."
},
{
"q": "In a protected endpoint, what does `current_user: dict = Depends(get_current_user)` accomplish?",
"choices": ["It encrypts the response", "FastAPI runs get_current_user first; an invalid/missing token raises 401 and the handler never runs", "It logs the user out after the request", "It stores the password in the session"],
"answer": 1,
"explain": "Auth is just a dependency. FastAPI resolves get_current_user before the body; if the token is missing or invalid it raises 401 and the endpoint never executes."
}
]
← Phase 7: Databases with SQLModel · Guide overview · Phase 9: Testing & Project Structure →
Check your understanding
1. Your database leaks. Which storage choice means the attacker does NOT instantly have everyone's usable passwords?
2. True or false: because a JWT is signed, the data inside it is hidden from anyone holding the token.
3. In a protected endpoint, what does `current_user: dict = Depends(get_current_user)` accomplish?