Authentication vs Authorization
Two different questions: WHO is calling (authentication, 401) and WHAT may they do (authorization, 403 or 404). Build them as separate dependencies with FastAPI’s HTTPBearer, test a guest, a user, an admin and a bad token against every endpoint, and use the Authorize button in /docs from a real browser.
What you will be able to do
- Tell authentication and authorization apart
- Choose between 401, 403 and 404
- Read a Bearer token with HTTPBearer
- Build optional and required user dependencies
- Check roles and ownership
- Use the Authorize button in /docs
The idea, in plain English
Security in an API answers two questions, always in this order. First authentication: who is calling? The client proves it with something - a password at login, then usually a token on every request. Second authorization: is this caller allowed to do this? A logged-in user may read their own notes, but not delete other users.
HTTP has a status code for each failure. 401 Unauthorized really means "not authenticated - tell me who you are", and comes with a WWW-Authenticate header. 403 Forbidden means "I know who you are, and the answer is no". And sometimes 404 is the best answer, so a stranger cannot even learn that a private thing exists.
In this lesson the tokens are fixed strings in a dict - Lesson 6.3 makes real ones with a login. That keeps the focus on the two questions and on FastAPI’s HTTPBearer. FastAPI 0.143.0.
Worked example: A notes API with a guest, a user (ravi) and an admin (anu): a public welcome page, a "me" page, private notes and an admin-only delete.
1 - A guest
No token. The first gate answers 401 with WWW-Authenticate: Bearer.
Every protected request passes two checks. Real results from our notes API.
Words you will see in this module
Module 6 is about security. These words come up in every lesson.
AuthenticationFinding out WHO is calling. Short: authn.AuthorizationDeciding WHAT the caller may do. Short: authz.TokenA string the client sends with each request to prove who it is.Bearer tokenA token sent as Authorization: Bearer <token> - whoever has it can use it.RoleA group of permissions, like user or admin.CredentialsWhat proves who you are: a password, a token, a key.An everyday example: an office building
- At the door, the guard checks your ID card - authentication. No card: "Please show your ID" (401).
- Inside, your card opens some doors and not others - authorization. Wrong floor: "You may not enter" (403).
- The director’s private room has no sign on the door at all, so visitors do not even know it is there (404).
- A visitor without a card may still use the reception area - an optional user.
Example 1 - a guest, a user and an admin
Read access.py in two parts:
- HTTPBearer(auto_error=False) reads the Authorization: Bearer <token> header and gives us the token - or None if there is no header.
- get_optional_user - authentication. No token: None (a guest). A known token: the user. A wrong token: 401 - a bad token is an error, not a guest.
- get_current_user - "must be logged in". It uses get_optional_user and turns None into 401.
- require_admin - authorization by role: 403 for non-admins.
- read_note - authorization by ownership: only the owner or an admin. Others get 404, the same as for a note that does not exist.
- Every 401 sends WWW-Authenticate: Bearer - the standard way to say "send a Bearer token".
import secrets
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
app = FastAPI()
# A stand-in for real logins (Lesson 6.3 makes real tokens)
TOKENS = {"tok-ravi-1f9a": "ravi", "tok-anu-77c2": "anu"}
USERS = {"ravi": {"name": "ravi", "role": "user"}, "anu": {"name": "anu", "role": "admin"}}
NOTES = {1: {"owner": "ravi", "text": "ravi's diary"}, 2: {"owner": "anu", "text": "anu's plans"}}
bearer = HTTPBearer(auto_error=False) # reads "Authorization: Bearer <token>"
# --- authentication: WHO is calling? ---
def get_optional_user(creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]):
if creds is None:
return None # a guest
for token, name in TOKENS.items():
if secrets.compare_digest(creds.credentials, token):
return USERS[name]
raise HTTPException(status_code=401, detail="Invalid token",
headers={"WWW-Authenticate": "Bearer"})
def get_current_user(user: Annotated[dict | None, Depends(get_optional_user)]):
if user is None:
raise HTTPException(status_code=401, detail="Not logged in",
headers={"WWW-Authenticate": "Bearer"})
return user
OptionalUser = Annotated[dict | None, Depends(get_optional_user)]
CurrentUser = Annotated[dict, Depends(get_current_user)]
# --- authorization: WHAT may they do? ---
def require_admin(user: CurrentUser):
if user["role"] != "admin":
raise HTTPException(status_code=403, detail="Admins only")
return user
@app.get("/welcome")
def welcome(user: OptionalUser): # everyone - but greets users by name
return {"message": f"Hello {user['name']}" if user else "Hello guest"}
@app.get("/me")
def me(user: CurrentUser): # any logged-in user
return user
@app.get("/notes/{note_id}")
def read_note(note_id: int, user: CurrentUser): # only the owner (or an admin)
note = NOTES.get(note_id)
if note is None or (note["owner"] != user["name"] and user["role"] != "admin"):
raise HTTPException(status_code=404, detail="Note not found") # do not reveal it exists
return note
@app.delete("/users/{name}")
def delete_user(name: str, admin: Annotated[dict, Depends(require_admin)]):
return {"deleted": name, "by": admin["name"]}from fastapi.testclient import TestClient
from access import app
c = TestClient(app)
GUEST = {}
RAVI = {"Authorization": "Bearer tok-ravi-1f9a"}
ANU = {"Authorization": "Bearer tok-anu-77c2"}
BAD = {"Authorization": "Bearer stolen-or-old"}
for who, headers in [("guest", GUEST), ("ravi", RAVI), ("anu", ANU), ("bad token", BAD)]:
print(f"--- {who}")
for method, url in [("GET", "/welcome"), ("GET", "/me"), ("GET", "/notes/1"),
("GET", "/notes/2"), ("DELETE", "/users/mohan")]:
r = c.request(method, url, headers=headers)
extra = f" WWW-Authenticate: {r.headers['www-authenticate']}" if "www-authenticate" in r.headers else ""
print(f" {method:<6} {url:<14} -> {r.status_code} {r.text}{extra}")Reading the output
- guest - /welcome works ("Hello guest"); everything else is 401 with WWW-Authenticate.
- ravi - his own note 200, anu’s note 404, delete 403. He is known, but not allowed.
- anu - everything 200: an admin passes the role and the ownership check.
- bad token - 401 "Invalid token" everywhere, even on /welcome. The client learns its token is wrong instead of silently becoming a guest.
--- guest
GET /welcome -> 200 {"message":"Hello guest"}
GET /me -> 401 {"detail":"Not logged in"} WWW-Authenticate: Bearer
GET /notes/1 -> 401 {"detail":"Not logged in"} WWW-Authenticate: Bearer
GET /notes/2 -> 401 {"detail":"Not logged in"} WWW-Authenticate: Bearer
DELETE /users/mohan -> 401 {"detail":"Not logged in"} WWW-Authenticate: Bearer
--- ravi
GET /welcome -> 200 {"message":"Hello ravi"}
GET /me -> 200 {"name":"ravi","role":"user"}
GET /notes/1 -> 200 {"owner":"ravi","text":"ravi's diary"}
GET /notes/2 -> 404 {"detail":"Note not found"}
DELETE /users/mohan -> 403 {"detail":"Admins only"}
--- anu
GET /welcome -> 200 {"message":"Hello anu"}
GET /me -> 200 {"name":"anu","role":"admin"}
GET /notes/1 -> 200 {"owner":"ravi","text":"ravi's diary"}
GET /notes/2 -> 200 {"owner":"anu","text":"anu's plans"}
DELETE /users/mohan -> 200 {"deleted":"mohan","by":"anu"}
--- bad token
GET /welcome -> 401 {"detail":"Invalid token"} WWW-Authenticate: Bearer
GET /me -> 401 {"detail":"Invalid token"} WWW-Authenticate: Bearer
GET /notes/1 -> 401 {"detail":"Invalid token"} WWW-Authenticate: Bearer
GET /notes/2 -> 401 {"detail":"Invalid token"} WWW-Authenticate: Bearer
DELETE /users/mohan -> 401 {"detail":"Invalid token"} WWW-Authenticate: Bearer401, 403 or 404?
- 401 - we do not know who you are (no token, wrong token, expired token). Send WWW-Authenticate. The client should log in again.
- 403 - we know who you are, and you may not do this. Logging in again will not help.
- 404 - for things that belong to someone else. A 403 on /notes/2 would tell ravi "note 2 exists, it is just not yours". 404 tells him nothing.
- Use 403 for actions everyone knows exist (an admin page); 404 for other people’s private data.
HTTPBearer with auto_error (the default)
With HTTPBearer() - auto_error=True - FastAPI answers for you when the header is wrong:
- No header: 401 "Not authenticated" with WWW-Authenticate: Bearer.
- A Basic header (user and password): also 401 - it only accepts Bearer.
- "bearer" in small letters was accepted - the scheme is not case-sensitive.
- It only checks the header’s SHAPE. Whether the token is valid is still your job.
- We used auto_error=False in access.py because /welcome must allow guests.
from typing import Annotated
from fastapi import Depends, FastAPI
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
app = FastAPI()
bearer = HTTPBearer() # auto_error=True: FastAPI answers for you
@app.get("/token-only")
def token_only(creds: Annotated[HTTPAuthorizationCredentials, Depends(bearer)]):
return {"scheme": creds.scheme, "token": creds.credentials}no header -> 401 {"detail":"Not authenticated"} WWW-Authenticate: Bearer
Basic scheme -> 401 {"detail":"Not authenticated"} WWW-Authenticate: Bearer
lowercase bearer -> 200 {"scheme":"bearer","token":"abc"}
Bearer abc -> 200 {"scheme":"Bearer","token":"abc"}
securitySchemes: {'HTTPBearer': {'type': 'http', 'scheme': 'bearer'}}
/me security: [{'HTTPBearer': []}]The Authorize button in /docs
Because the dependencies use HTTPBearer, the OpenAPI document gets a security scheme, and /docs shows an Authorize button and a lock on each endpoint that uses it. We opened /docs in a real browser (Playwright):
- The Authorize button was there, and 4 endpoints had a lock.
- We clicked Authorize, pasted ravi’s token, and called GET /me with "Try it out": 200 and ravi’s data.
- Swagger showed the curl command - with the Authorization: Bearer header filled in.
Authorize button: shown
endpoints with a lock icon: 4
GET /me status: 200
body: { "name": "ravi", "role": "user" }
curl: curl -X 'GET' \ 'http://127.0.0.1:8362/me' \ -H 'accept: */*' \ -H 'Authorization: Bearer tok-ravi-1f9a'Authn and authz at a glance
Read a Bearer tokenNone if missing.
bearer = HTTPBearer(auto_error=False) creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]
Not logged inAlways with the header.
HTTPException(401, "Not logged in", headers={"WWW-Authenticate": "Bearer"})Not allowedKnown user, no permission.
HTTPException(403, "Admins only")
Someone else’s dataHide that it exists.
HTTPException(404, "Note not found")
Compare secretsSafe for timing.
secrets.compare_digest(given, expected)
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Run fastapi dev access.py, click Authorize, and try every endpoint as ravi and then as anu.”
“curl -H "Authorization: Bearer tok-anu-77c2" -X DELETE localhost:8000/users/mohan”
“Add a "moderator" role that may read every note but not delete users.”
“Change read_note to answer 403 for other people’s notes. What does ravi learn now from /notes/2 and /notes/3?”
What usually goes wrong
That is 401 - with WWW-Authenticate: Bearer.
The client never learns its token is wrong. Answer 401.
It reveals that the item exists. Use 404.
Anyone could claim to be anyone. Take it from the authenticated user.
It only reads the header. You must check the token yourself.
Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
Add PUT /notes/{note_id} that only the owner may change (admins may NOT - they may only read). Add POST /notes for any logged-in user, which stores the caller as the owner. Write tests for guest, ravi, anu and a bad token, and check every status code.
Show hintHide hint
The ownership check for PUT is note["owner"] == user["name"] only. Use CurrentUser for POST and take the owner from it, never from the body.
Key points
- Authentication: who is calling. Authorization: what they may do.
- 401 = not authenticated (with WWW-Authenticate); 403 = authenticated but not allowed.
- Use 404 to hide other people’s private items.
- HTTPBearer reads Authorization: Bearer <token>; auto_error=False allows guests.
- Build it as dependencies: optional user -> current user -> role or ownership checks.
- Security dependencies give /docs an Authorize button.
Quick check before you move on
Interview questions
What is the difference between 401 and 403?
401 means the request lacks valid authentication (and should carry WWW-Authenticate); re-authenticating may help. 403 means the identity is known but not permitted; re-authenticating will not help.
How would you structure authorization in a FastAPI app?
Authentication in one dependency that yields the current user; authorization in small composable dependencies (roles, scopes) and in resource-level checks (ownership) close to the data, returning 403 or 404; never trust identity fields from the request body.
Quiz
- 1.
Which header should come with a 401, and why?
- 2.
Why does /welcome use get_optional_user and not get_current_user?
- 3.
Why did /docs show an Authorize button?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...