← Back to FastAPI
Lesson 6.1 · Auth & Security

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.

Intermediate30 min

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.

workflowTwo gatesstep 1 / 3

1 - A guest

No token. The first gate answers 401 with WWW-Authenticate: Bearer.

GET /me
401 Not logged in
GET /welcome
200 Hello guest

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.

Small dictionary
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".
Example 1 - access.py
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"]}
try_access.py
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.
Output
--- 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: Bearer

401, 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.
default_bearer.py
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}
Output
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.
Output - browser
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 token

None if missing.

bearer = HTTPBearer(auto_error=False)
creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]
Not logged in

Always with the header.

HTTPException(401, "Not logged in", headers={"WWW-Authenticate": "Bearer"})
Not allowed

Known user, no permission.

HTTPException(403, "Admins only")
Someone else’s data

Hide that it exists.

HTTPException(404, "Note not found")
Compare secrets

Safe 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.

Use /docs

“Run fastapi dev access.py, click Authorize, and try every endpoint as ravi and then as anu.”

With curl

“curl -H "Authorization: Bearer tok-anu-77c2" -X DELETE localhost:8000/users/mohan”

Another role

“Add a "moderator" role that may read every note but not delete users.”

403 instead

“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

403 when the user is not logged in

That is 401 - with WWW-Authenticate: Bearer.

Treating a bad token as a guest

The client never learns its token is wrong. Answer 401.

403 for other people’s private data

It reveals that the item exists. Use 404.

Taking the owner from the request body

Anyone could claim to be anyone. Take it from the authenticated user.

Thinking HTTPBearer checks the token

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.

1.

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 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

What did ravi get for DELETE /users/mohan?
403 Admins only.
Why did ravi get 404, not 403, for /notes/2?
To not reveal that anu’s note exists.
What does default HTTPBearer() answer without a header?
401 "Not authenticated" with WWW-Authenticate: Bearer.
What did a bad token get on /welcome?
401 Invalid token - not "Hello guest".

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. 1.

    Which header should come with a 401, and why?

  2. 2.

    Why does /welcome use get_optional_user and not get_current_user?

  3. 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...