Authentication as a dependency, what a token actually is, and the handful of mistakes that matter most.
Overview
Authentication is a dependency
Security in an API comes down to two questions. Who is making this request, and are they allowed to do it? The first is authentication, and the second is authorization. FastAPI does not force a particular answer. It gives you building blocks, and the most useful of them is the dependency.
This matters because mistakes here are costly. A missing check, a weak comparison, or a token that is trusted without verification can expose every user's data. Most breaches come from small errors in basic steps, not from clever attacks. Getting the fundamentals right protects you from the majority of them.
This article starts with the idea that authentication is a dependency. You write one function that reads the credentials and returns the user or raises an error, and every protected endpoint asks for it. It then explains the difference between 401 and 403, which are often mixed up. One means the caller is not identified. The other means they are identified but not permitted.
Next it describes what a token actually is, in plain terms. A section follows on a point that surprises many people: a valid signature is not the same as a valid token, since expiry and purpose also matter. The article ends with comparing secrets safely, because an ordinary equality check can leak information through timing.
This is an introduction to the habits that matter, and not a complete security course.
The goal is to give you a sound mental model and a few patterns you can trust. For anything that handles money or health data, you should also rely on well-reviewed libraries and, ideally, a professional review of the design. Each section is short and ends with a habit you can apply straight away. You finish with a clear set of practices for identifying callers, deciding what they may do, and avoiding the small slips that cause most real incidents.
all from the dependencies tier applies here, and this is the case that justifies it.
Stated once. each endpoint that needs a user declares one param. One that doesn't, doesn't. An endpoint can't forget the check. If it declares current_user then the check ran. The request couldn't have reached the handler else.
A section is protected by putting it on the router. A route added next year takes from it rather than needing somebody to note.
Worth knowing
Authentication belongs in a dependency: stated once, impossible for an endpoint to forget, and it appears in the schema.
401 means not authenticated and should carry WWW-Authenticate. 403 means authenticated and not allowed. They are different questions.
A signed token is readable by anyone — signing is not encryption. Never put anything secret in the payload.
A valid signature does not mean a valid token: expiry has to be checked separately, and revocation needs a store.
Compare secrets with hmac.compare_digest, not ==, so the comparison does not leak by timing.
A login should say the same thing whether the user is unknown or the password is wrong, or it enumerates accounts.
Security Basics
Authentication as a dependency, what a token really is. The mistakes that matter most.
Reading a bearer token
The header, parsed in one dependency. Every endpoint that needs a user declares one parameter.
example_01.pyFastAPI
from fastapi import Depends, FastAPI, Header, HTTPException, status
app = FastAPI()
TOKENS = {"tok-ada": {"name": "ada", "scopes": ["modules:read"]}}
def current_user(authorization: str = Header(default="")):
if not authorization.startswith("Bearer "):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing bearer token",
headers={"WWW-Authenticate": "Bearer"})
user = TOKENS.get(authorization[7:])
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token",
headers={"WWW-Authenticate": "Bearer"})
return user
@app.get("/me")
def me(user: dict = Depends(current_user)):
return user
c = TestClient(app)
print("none :", c.get("/me").status_code)
print("wrong :", c.request("GET", "/me", headers={"authorization": "Bearer x"}).status_code)
print("ok :", c.request("GET", "/me",
headers={"authorization": "Bearer tok-ada"}).json())
Output
What a signed token actually is
Payload plus a signature made with a secret. Anyone can read it; only the holder of the secret can produce one.
example_02.pyFastAPI
import base64, hashlib, hmac, json
SECRET = b"a-server-side-secret"
def b64(raw): return base64.urlsafe_b64encode(raw).rstrip(b"=")
def unb64(s): return base64.urlsafe_b64decode(s + b"=" * (-len(s) % 4))
def sign(payload: dict) -> bytes:
body = b64(json.dumps(payload, separators=(",", ":")).encode())
mac = hmac.new(SECRET, body, hashlib.sha256).digest()
return body + b"." + b64(mac)
def verify(token: bytes):
body, _, given = token.partition(b".")
expected = b64(hmac.new(SECRET, body, hashlib.sha256).digest())
if not hmac.compare_digest(given, expected): # constant time
return None
return json.loads(unb64(body))
tok = sign({"sub": "ada", "scopes": ["modules:read"]})
print("token :", tok.decode()[:52], "...")
print("readable:", json.loads(unb64(tok.split(b".")[0])))
print("verified:", verify(tok))
print("tampered:", verify(b"eyJzdWIiOiJyb290In0.abc"))
print()
print("Signed, not encrypted. Never put a secret in the payload.")
Output
Expiry has to be checked
A signature says the token is genuine. It says nothing about whether it is still valid.
example_03.pyFastAPI
import time
from fastapi import Depends, FastAPI, Header, HTTPException
app = FastAPI()
ISSUED = {"tok-fresh": {"sub": "ada", "exp": time.time() + 3600},
"tok-stale": {"sub": "ada", "exp": time.time() - 1}}
def current_user(authorization: str = Header(default="")):
claims = ISSUED.get(authorization.replace("Bearer ", ""))
if claims is None:
raise HTTPException(401, "Invalid token")
if claims["exp"] < time.time():
raise HTTPException(401, "Token expired")
return {"name": claims["sub"]}
@app.get("/me")
def me(user: dict = Depends(current_user)):
return user
c = TestClient(app)
for tok in ["tok-fresh", "tok-stale"]:
r = c.request("GET", "/me", headers={"authorization": "Bearer " + tok})
print("%-10s %s %s" % (tok, r.status_code, r.json()))
Output
Scopes, layered as dependencies
Authentication answers who; authorisation answers whether. They are different questions and different status codes.
example_04.pyFastAPI
from fastapi import Depends, FastAPI, Header, HTTPException, status
app = FastAPI()
TOKENS = {"reader": ["modules:read"], "editor": ["modules:read", "modules:write"]}
def current_user(x_token: str = Header(default="")):
if x_token not in TOKENS:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Sign in")
return {"name": x_token, "scopes": TOKENS[x_token]}
class RequireScope:
def __init__(self, *scopes): self.scopes = set(scopes)
def __call__(self, user: dict = Depends(current_user)):
missing = self.scopes - set(user["scopes"])
if missing:
raise HTTPException(status.HTTP_403_FORBIDDEN,
"Missing scope: %s" % ", ".join(sorted(missing)))
return user
can_read = RequireScope("modules:read")
can_write = RequireScope("modules:write")
@app.get("/modules")
def read(u: dict = Depends(can_read)): return {"ok": u["name"]}
@app.post("/modules")
def write(u: dict = Depends(can_write)): return {"ok": u["name"]}
c = TestClient(app)
for tok in ["", "reader", "editor"]:
h = {"x-token": tok} if tok else {}
print("%-7s GET %s POST %s" % (tok or "-",
c.request("GET", "/modules", headers=h).status_code,
c.request("POST", "/modules", headers=h).status_code))
Output
Not leaking which one was wrong
A login that says “no such user” tells an attacker which names exist. Say the same thing either way.
example_05.pyFastAPI
import hashlib, hmac
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
def digest(password: str, salt: bytes) -> bytes:
# Illustrative only. A real system uses bcrypt, scrypt or argon2 through
# a library: those are deliberately SLOW, which is the property that
# matters and the one a plain hash does not have.
return hmac.new(salt, password.encode(), hashlib.sha256).digest()
SALT = b"per-user-salt"
USERS = {"ada": digest("correct-horse", SALT)}
class Login(BaseModel):
username: str
password: str
@app.post("/login")
def login(body: Login):
stored = USERS.get(body.username)
candidate = digest(body.password, SALT)
# Compare even when the user is unknown, and say one thing either way.
if stored is None or not hmac.compare_digest(stored, candidate):
raise HTTPException(401, "Incorrect username or password")
return {"token": "tok-" + body.username}
c = TestClient(app)
for u, p in [("ada", "correct-horse"), ("ada", "wrong"), ("nobody", "wrong")]:
r = c.post("/login", json={"username": u, "password": p})
print("%-8s %-14s %s %s" % (u, p, r.status_code, r.json()))
Output
Keeping secrets out of the response
The response model is the last line of defence, and the one that cannot be forgotten by a future handler.
example_06.pyFastAPI
from fastapi import FastAPI
from pydantic import BaseModel, Field, SecretStr
app = FastAPI()
ROW = {"id": 1, "name": "ada", "email": "ada@vizlearn.in",
"password_hash": "$2b$12$abcdef", "api_token": "tok_live_123"}
class UserOut(BaseModel):
id: int
name: str # email and secrets simply absent
@app.get("/users/1", response_model=UserOut)
def read():
return ROW
class UserInternal(BaseModel):
name: str
password_hash: str = Field(exclude=True)
api_token: SecretStr
u = UserInternal(name="ada", password_hash="$2b$12$abcdef", api_token="tok_live_123")
print("public response :", TestClient(app).get("/users/1").json())
print("internal repr :", u)
print("internal dump :", u.model_dump())
Output
401 and 403
Worth restating because merging them is so common.
401 Unauthorized really means *unauthenticated*: I don't know who you're. It should carry a WWW-Authenticate header naming the scheme.
403 Forbidden means: I know who you're, and no.
A client seeing 401 should obtain credentials or refresh a token. One seeing 403 shouldn't. Retrying as the same person will fail again. Merging them makes a login loop where there should be a message.
Layering them as split dependencies. current_user raising 401, require_scope raising 403 — makes the gap structural rather than a thing each handler decides.
What a token is
A signed token is a payload plus a signature found with a server-side secret.
The property that matters, and that people get wrong: it's signed, not encrypted. Anyone holding the token can decode and read the payload. The signature proves it was issued by someone with the secret and hasn't been altered — nothing more.
So the payload may hold a user id, an expiry, a set of scopes. It must never hold a password, a card number, or anything else that shouldn't be read by whoever holds the token. Which includes anyone who obtains it from a log, a browser's storage, or a proxy.
The second editor above builds and verifies one with nothing but the standard library. The method is worth seeing once. In production, use a library. PyJWT or Authlib. Which handles the method choices, the claim rules and the parsing edge cases that a hand-rolled version gets wrong.
A signature is not validity
A token can be just right signed and still unacceptable.
Expiry must be checked by name. A signature has no view about time.
Revocation is harder, and it's the honest weakness of stateless tokens. A signed token stays valid until it expires. Logging out or disabling an account doesn't stop it. The usual answers are short lifetimes plus refresh tokens, or a denylist. Which reintroduces the state that stateless tokens were meant to avoid.
Pick a short expiry. Fifteen minutes with a refresh flow is a common shape. A token valid for a year is a credential you can't withdraw.
Comparing secrets
Use hmac.compare_digest, not ==.
A normal string compare returns as soon as it finds a gap. The time it takes reveals how many leading characters were correct. Over enough attempts that is enough to rebuild a secret. compare_digest takes the same time even so.
This applies to tokens, signatures, API keys and password hashes. Anything an attacker can submit again and again.
Not leaking who exists
A login that returns "no such user" for one input and "wrong password" for another lets anyone enumerate accounts.
Return the same message either way. Perform the hash compare even when the username is unknown. Else the *timing* gap says what the message didn't.
The same thinking applies to 404 versus 403 on a resource that exists but isn't yours. Returning 403 confirms it exists. For anything sensitive, 404 for both, always.
Passwords
Two rules, and the second isn't optional.
Never store a password. Store a hash.
Never hash it with SHA-256 alone. All-round hashes are designed to be fast. This Is just the wrong property. Use a on purpose slow, salted method designed for passwords. Bcrypt, scrypt or argon2, through a library like passlib.
The editor above uses PBKDF2 from the standard library to show the shape without pulling in a dependency. It's better than a bare SHA-256 and it isn't what you should ship. The real answer is a library that keeps its params current as hardware gets faster.
Keeping secrets out of responses
The last line of defence, and the one that lives a future handler being careless.
A response_model listing only what may be seen can't leak a field added to the table later. Field(exclude=True) keeps a value out of each dump. SecretStr keeps it out of repr, logs and tracebacks.
Use all three where they fit. Prefer the split output model. It's the only one that can't be forgotten by somebody editing a other file.
What this module does not cover
Enough to be worth naming: CSRF for cookie-based sessions, CORS config, rate limiting, input sanitisation for anything rendered as HTML, dependency scanning, and secrets management.
Each is a real subject. The point of this one is that the *shape*. Authentication as a dependency, authorisation layered on top, secrets kept out of payloads and responses. Is what the framework gives you. Getting that shape right is what makes the rest tractable.
Mistakes people make with Security Basics
Putting anything secret in a token payload. It's signed, not encrypted. Anyone holding it reads it - including from a log, browser storage or a proxy.
Checking the signature and stopping. Expiry is a split check. A signature has no view about whether an account was disabled.
Long-lived tokens. A stateless token is valid until it expires. A year-long token is a credential you can't withdraw.
**Checking with ==.** The timing reveals how many leading characters matched. hmac.compare_digest doesn't.
Distinct login errors. "No such user" enumerates accounts - and skipping the hash compare for an unknown user leaks the same fact through timing.
Hashing passwords with SHA-256. Fast is the wrong property. bcrypt, scrypt or argon2, through a library.
403 on a resource that exists but isn't yours. It confirms existence. For anything sensitive, 404 for both, always.
Trusting a client-supplied identity header.X-User-Id is a claim unless a proxy sets it and strips what the client sent.
What this does not cover
Worth naming so the gaps are known: CSRF for cookie sessions, CORS, rate limiting, sanitising anything rendered as HTML, dependency scanning, and secrets management.
Each is a subject. What this module gives you is the shape - authentication as a dependency, authorisation layered above it, secrets out of payloads and out of responses - and that shape is what makes the rest tractable rather than scattered.
Where to be careful
Three habits that prevent most of what goes wrong, beyond anything in the editors above.
Never log credentials.Authorization and Cookie are in each request and are the two headers you least want persisted. A logging middleware that dumps headers is a breach waiting for a log aggregator.
Never put secrets in a URL. They land in browser history, server logs, proxy logs and Referer headers. That is the arg for Authorization over ?api_key=.
Fail closed. A permission check that errors should deny, not allow. Code shaped if not allowed. Raise denies on an exception. Code shaped if denied: raise permits when the check itself breaks.
What to reach for
For anything real, use libraries rather than the primitives shown here.
Tokens: PyJWT or Authlib. This Handle method choice, claim rules and the parsing edge cases a hand-rolled version gets wrong - including the alg: none family of attacks.
Passwords: passlib with bcrypt or argon2. This Keeps its params current as hardware gets faster.
OAuth2 and OpenID Connect: FastAPI ships OAuth2PasswordBearer and friends. This Integrate with the docs so the interactive page can authenticate.
The editors here build things from hmac and hashlib to show what is underneath. That is worth seeing once and isn't what you should ship.
Security Basics: summary
Authentication belongs in a dependency: stated once, hopeless for an endpoint to forget. Visible in the schema. Authorisation layers above it. The two give other status codes for other questions.
A signed token is readable by anyone holding it. Nothing secret goes in the payload. A valid signature isn't a valid token - expiry is a split check and revocation needs state.
Compare secrets in constant time, say the same thing for an unknown user as for a wrong password, hash passwords with a thing on purpose slow. Let a response model decide what may leave.
Where to go after Security Basics
How the pieces are arranged once the use is more than one file. Routers per resource, services that know nothing about HTTP, schemas split by direction. An assembly file short enough to read at a glance.
The shape to take away
Authentication is a dependency. Authorisation is a dependency that depends on it. Both raise. Neither can be forgotten by an endpoint that declares them. Both appear in the schema.
all else in this module is a detail hung on that frame. What goes in a token, how to compare a secret, what a login should say, what a response model must not hold.
The frame is what the framework gives you. The details are what a review should check before anything real depends on them.
A closing thought
The most handy thing in this module isn't any single rule. It's that authentication has one place to live.
An use where each endpoint checks a header its own way has as many security models as it has endpoints. No way to review them. One where each endpoint declares Depends(current_user) has one, written down, that a reviewer can read in a minute.
That doesn't make it correct. It makes it *reviewable*. This Is the precondition for it turning correct.
A closing note on Security Basics
Security is the area where the framework helps most with shape and least with substance.
Depends makes authentication hopeless for an endpoint to forget, gives it one place to live. Puts it in the schema. That is truly valuable and it's structural - it says nothing about whether your token lifetime is sensible, your hashing is current, or your permission model matches what the business intended.
Those are choices, and they need review by someone who does this for a living before anything real depends on them. What this module offers is the setup that makes such a review possible. Rules in one place, expressed once, visible in the signature of each endpoint that leans on them.
Questions people ask about Security Basics
Does FastAPI handle authentication for me? It provides tools such as OAuth2PasswordBearer and the docs Authorize button, but you write the logic that checks passwords and tokens.
How should I store passwords? Hash them with a slow algorithm such as bcrypt or argon2 and never store plain text.
What is a JWT used for here? It is a signed token that carries the user id and an expiry, so the server can verify the caller without a database lookup on every request.
Where do I enforce authentication? In a dependency such as get_current_user, so protected routes depend on it and public routes do not.
What is CORS and do I need it? CORS controls which browser origins may call your API. Add CORSMiddleware if a web page on another origin calls it.
Is HTTPS part of FastAPI? No. Terminate TLS at a proxy or load balancer in front of the app. Without it, tokens travel in clear text.
Security Basics in one line
Authentication is a dependency and authorisation depends on it. A signed token is readable, a valid signature isn't a valid token, secrets compare in constant time, logins say one thing either way. A response model decides what leaves.
And for anything real, use libraries rather than the primitives shown here. They exist because the edge cases are numerous and the results of missing one aren't proportionate to the effort saved.
Check yourself
0 of 4
Answer without scrolling back up.
Is a signed token encrypted?
The signature proves origin and integrity, not confidentiality. Nothing secret belongs in the payload, because logs, browser storage and proxies all see it.
Why use `hmac.compare_digest` instead of `==`?
Over enough attempts, a timing difference is enough to reconstruct a secret. compare_digest takes constant time.
A login where the username does not exist. What should it return?
Different messages enumerate accounts - and skipping the hash comparison leaks the same fact through timing even when the message does not.
A signed token has a valid signature. Is it acceptable?
A signature has no opinion about time or about whether the account was disabled. That is the honest weakness of stateless tokens, which short lifetimes mitigate.
Cheat sheet
Security Basics
Add OAuth2PasswordBearer as a dependency, extract and verify a token, and see how authentication fits into FastAPI's dependency injection.
Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.