FastAPI authentication is the part everyone needs and almost nobody explains from the ground up. This post builds a real login system step by step: you will hash passwords the right way, issue and verify JSON Web Tokens (JWTs), wire up an OAuth2 login flow with OAuth2PasswordBearer, protect routes with a shared get_current_user dependency, and add scopes and roles. Every block is tested and runnable.
“Never roll your own crypto, and never store a password you could read back.”
Last Updated: July 2026 | Tested on: Python 3.14.6, FastAPI 0.138.0, PyJWT 2.13.0, argon2-cffi 25.1.0 | Difficulty: Advanced | Reading Time: 19 minutes
Here is the problem. Your Application Programming Interface (API) works great until the day it holds something private: a user’s orders, their messages, their money. Now you have to answer two questions on every single request. Who is this? And are they allowed to do this? Get that wrong and you either lock out real users or hand a stranger the keys. Most tutorials skip straight to copying a JWT snippet off the internet, which is exactly how secrets end up hard-coded and tokens end up never expiring.
Think of it like a hotel. When you check in at the front desk, you show your ID once (that is the login). In return you get a key card that opens only your room and works only until checkout (that is the token). The card does not carry your passport photo around, and the housekeeping staff can check it at any door without calling the front desk. Good API authentication works the same way: verify identity once, hand back a short-lived token, and let every route check that token on its own.
The durable ideas here (OAuth2 and JWT) are open standards that have outlived many libraries, so once you understand them the specific package barely matters. We will use PyJWT and argon2-cffi because they are the common choices at the time of writing, and note the alternatives as we go. Say a backend developer named Anvay is adding logins to the FastAPI service he built in the FastAPI tutorial. By the end of this post, his service checks who you are and what you are allowed to do, without a single password stored in plain text.
Table of Contents
Prerequisites
You should be comfortable building routes with FastAPI, since we build directly on Depends() and Pydantic models from there. A little decorator and type hint knowledge helps too. Install the three libraries this post uses into your project’s virtual environment.
📄 Terminal: install the auth libraries
pip install "fastapi[standard]" pyjwt argon2-cffi python-multipart # pyjwt: encode and verify JWTs # argon2-cffi: password hashing # python-multipart: lets FastAPI read the OAuth2 login form
Sessions, Tokens, and OAuth2: When to Use Each
Before any FastAPI authentication code, get the three words straight, because people mix them up constantly. A session keeps the login state on the server: the browser holds a random session ID in a cookie, and the server looks up who that is on every request. A token (a JWT here) flips that around: the token itself carries the claims, signed so it cannot be faked, and the server keeps no per-user memory. OAuth2 is not a storage choice at all; it is a standard set of flows for how a user proves identity, often through a third party like Google, so your app never touches their password.
A quick way to choose: reach for server sessions when you have one classic web app and want instant logout and simple revocation. Reach for JWTs when you have APIs, mobile clients, or many services that must accept the same login without sharing a session store. Reach for full OAuth2 with an outside provider when you want “Sign in with Google” or a company single sign-on and would rather never store passwords at all. This post uses JWTs because they are the default for FastAPI-style APIs, and the same login endpoint slots straight into the OAuth2 flow shown below.
The diagram traces the OAuth2 authorization code flow with PKCE (Proof Key for Code Exchange), which is the flow you want for web and mobile apps that log in through a provider. The client first invents a random secret (the code_verifier) and sends only a hash of it (the code_challenge) when it kicks off login. After the user signs in, the authorization server hands back a short-lived code, and the client redeems that code together with the original verifier. Because a stolen code is worthless without the matching verifier, PKCE shuts down a whole class of interception attacks.
The rest of this post focuses on the token half of that picture, which is what your FastAPI routes actually deal with.
Hash Passwords the Right Way
Rule number one of authentication: you never store a password you could read back. You store a one-way hash. Think of it like blending a fruit smoothie. Turning strawberries and banana into a smoothie is easy, but nobody can turn the smoothie back into whole fruit. When a user logs in, you blend their input the same way and compare the two smoothies. Argon2 is the current recommended algorithm at the time of writing (bcrypt is the other solid, widely used choice), and the argon2-cffi library gives you a tiny, safe interface.
📄 hash_demo.py: hash and verify a password with Argon2
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
# One hasher, sensible defaults. Reuse it across your app.
ph = PasswordHasher()
password = "correct horse battery staple"
# Hashing is one-way. You store this string, never the raw password.
hash1 = ph.hash(password)
hash2 = ph.hash(password)
print("hash length:", len(hash1))
print("hash starts with:", hash1[:12])
print("same password, two different hashes?", hash1 != hash2)
# Verify: does this password match the stored hash?
print("correct password verifies:", ph.verify(hash1, password))
# A wrong password raises, it does not return False.
try:
ph.verify(hash1, "wrong password")
except VerifyMismatchError:
print("wrong password verifies: rejected")
▶ Output
hash length: 97 hash starts with: $argon2id$v= same password, two different hashes? True correct password verifies: True wrong password verifies: rejected
What happened here: The same password hashed twice gave two different strings, because Argon2 mixes in a random salt each time, which is exactly what stops attackers from spotting two users who share a password. The stored string starts with $argon2id$, so the parameters travel with the hash and verification just works later. Notice that verify raises VerifyMismatchError on a wrong password instead of returning False, so you wrap it in a try and treat the exception as “no match”. You never compare hashes with == yourself; the library does a constant-time check for you.
Issue, Verify, and Refresh a JWT
A JWT is just three base64 chunks joined by dots: a header, a payload of claims, and a signature. The signature is the whole point. It is made with your secret key, so anyone can read the claims but nobody can change them without the key. Think of it like a laminated concert wristband. You can read the date and section printed on it, but you cannot peel it open and rewrite it without wrecking the seal. Here is how you issue one, verify it, and see it reject both a forged signature and an expired token.
📄 jwt_demo.py: issue and verify a signed token
import jwt # PyJWT
from datetime import datetime, timedelta, timezone
# In real code this comes from an environment variable, never hard-coded.
# HS256 needs at least 32 bytes of randomness to be safe.
SECRET = "load-this-32-byte-secret-from-an-env-var-not-source-code"
ALGO = "HS256"
def make_token(username: str, minutes: int) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": username, # who the token is about
"iat": now, # issued at
"exp": now + timedelta(minutes=minutes), # expiry
}
return jwt.encode(payload, SECRET, algorithm=ALGO)
# 1. Issue a normal 15-minute token and read it back.
token = make_token("aditi", minutes=15)
print("token (truncated):", token[:40], "...")
decoded = jwt.decode(token, SECRET, algorithms=[ALGO])
print("verified subject:", decoded["sub"])
# 2. A token signed with a different secret must fail.
try:
jwt.decode(token, "some-other-32-byte-secret-the-attacker-guessed", algorithms=[ALGO])
except jwt.InvalidSignatureError:
print("tampered/forged token:", "rejected")
# 3. An already-expired token must fail too.
expired = make_token("aditi", minutes=-1) # expired one minute ago
try:
jwt.decode(expired, SECRET, algorithms=[ALGO])
except jwt.ExpiredSignatureError:
print("expired token:", "rejected")
▶ Output
token (truncated): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ ... verified subject: aditi tampered/forged token: rejected expired token: rejected
What happened here: The valid token decoded back to its subject, aditi. The same token failed the moment we tried to verify it with a different secret, which is what would happen if an attacker forged one without your key. And the token whose exp was already in the past raised ExpiredSignatureError, so PyJWT enforces expiry for you as long as you include an exp claim. Always pass algorithms=[ALGO] on decode: that pins the algorithm you trust and blocks the confusion attacks we cover later.
Short expiry is safer but annoying, since nobody wants to log in every fifteen minutes. The standard fix is two tokens: a short-lived access token for normal requests and a longer-lived refresh token whose only job is to mint fresh access tokens. It is like a movie ticket versus a monthly pass. The ticket gets you into one show; the pass lets you swap it for a new ticket whenever you come back.
📄 refresh_demo.py: swap a refresh token for a new access token
import jwt
from datetime import datetime, timedelta, timezone
SECRET = "a-proper-32-byte-secret-loaded-from-the-environment"
def issue(username: str, minutes: int, kind: str) -> str:
now = datetime.now(timezone.utc)
return jwt.encode(
{"sub": username, "type": kind, "exp": now + timedelta(minutes=minutes)},
SECRET, algorithm="HS256",
)
# Short-lived access token, long-lived refresh token.
access = issue("aditi", minutes=15, kind="access")
refresh = issue("aditi", minutes=60 * 24 * 7, kind="refresh") # 7 days
# The client uses access until it expires. When it does, it presents refresh.
def refresh_access(refresh_token: str) -> str:
claims = jwt.decode(refresh_token, SECRET, algorithms=["HS256"])
if claims.get("type") != "refresh":
raise ValueError("not a refresh token")
return issue(claims["sub"], minutes=15, kind="access")
new_access = refresh_access(refresh)
claims = jwt.decode(new_access, SECRET, algorithms=["HS256"])
print("refresh accepted, minted a new access token for:", claims["sub"])
print("new token type:", claims["type"])
# An access token must never be accepted where a refresh token is expected.
try:
refresh_access(access)
except ValueError as e:
print("access token used as refresh ->", str(e))
▶ Output
refresh accepted, minted a new access token for: aditi new token type: access access token used as refresh -> not a refresh token
What happened here: The refresh token bought a brand new 15-minute access token without the user typing a password again. The type claim keeps the two apart: presenting an access token where a refresh token is expected is rejected outright. That check matters, because it stops someone from stretching a stolen access token into a long-lived one. In production you would also store refresh tokens server-side so you can revoke them, which is the one thing pure JWTs cannot do on their own.
Build a Login-Protected API
Now assemble the pieces of FastAPI authentication into a real app. FastAPI ships an OAuth2PasswordBearer helper that reads the token out of the Authorization: Bearer ... header and even wires up the login box in the Swagger docs. The login route takes a username and password, checks the Argon2 hash, and hands back a JWT. Every protected route depends on one small function, get_current_user, that decodes the token and loads the user.
📄 auth_app.py: login, token, and protected routes
import os
from datetime import datetime, timedelta, timezone
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel
# --- Config (read secrets from the environment, never hard-code them) ---
SECRET = os.environ.get("AUTH_SECRET", "dev-only-change-me-32-bytes-minimum-secret")
ALGO = "HS256"
TOKEN_MINUTES = 15
app = FastAPI(title="Auth Demo")
ph = PasswordHasher()
# tokenUrl tells Swagger UI where to send the username/password.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
# A tiny fake user table. In production this is your database.
# The stored value is an argon2 hash, never the plain password.
users_db = {
"aditi": {"username": "aditi", "hashed_password": ph.hash("secret-pass"), "roles": ["reader"]},
"anvay": {"username": "anvay", "hashed_password": ph.hash("admin-pass"), "roles": ["reader", "admin"]},
}
class User(BaseModel):
username: str
roles: list[str]
def authenticate(username: str, password: str):
record = users_db.get(username)
if not record:
return None
try:
ph.verify(record["hashed_password"], password)
except VerifyMismatchError:
return None
return record
def make_token(username: str) -> str:
now = datetime.now(timezone.utc)
payload = {"sub": username, "iat": now, "exp": now + timedelta(minutes=TOKEN_MINUTES)}
return jwt.encode(payload, SECRET, algorithm=ALGO)
# The dependency every protected route reuses.
async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
creds_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET, algorithms=[ALGO])
username = payload.get("sub")
except jwt.InvalidTokenError:
raise creds_error
record = users_db.get(username)
if record is None:
raise creds_error
return User(username=record["username"], roles=record["roles"])
@app.post("/token")
async def login(form: OAuth2PasswordRequestForm = Depends()):
record = authenticate(form.username, form.password)
if not record:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return {"access_token": make_token(record["username"]), "token_type": "bearer"}
@app.get("/me")
async def read_me(user: User = Depends(get_current_user)):
return {"username": user.username, "roles": user.roles}
From a terminal you would log in and call the protected route with two curl commands: curl -X POST -d "username=aditi&password=secret-pass" localhost:8000/token to get the token, then curl -H "Authorization: Bearer <token>" localhost:8000/me. To keep this reproducible without starting a server, the test below drives the exact same requests with FastAPI’s in-memory TestClient.
📄 test_auth.py: log in, then hit a protected route
from fastapi.testclient import TestClient
from auth_app import app
client = TestClient(app)
# 1. Hit a protected route with no token.
r = client.get("/me")
print("no token ->", r.status_code, r.json())
# 2. Log in with the correct password (OAuth2 form fields: username, password).
r = client.post("/token", data={"username": "aditi", "password": "secret-pass"})
print("login ->", r.status_code)
token = r.json()["access_token"]
# 3. Call the protected route with the token in the Authorization header.
auth = {"Authorization": f"Bearer {token}"}
r = client.get("/me", headers=auth)
print("me ->", r.status_code, r.json())
# 4. Wrong password never issues a token.
r = client.post("/token", data={"username": "aditi", "password": "wrong"})
print("bad login ->", r.status_code, r.json())
▶ Output
no token -> 401 {'detail': 'Not authenticated'}
login -> 200
me -> 200 {'username': 'aditi', 'roles': ['reader']}
bad login -> 401 {'detail': 'Incorrect username or password'}
What happened here: The unauthenticated call to /me came back 401 Not authenticated because OAuth2PasswordBearer found no token. A correct login returned a token, and the same route with that token in the header returned the user. A wrong password never even reached token creation; authenticate returned None and the login raised a 401. Notice the login error says “Incorrect username or password” for both a bad username and a bad password on purpose, so an attacker cannot tell which usernames exist.
Scopes and Roles
Authentication answers “who are you”. Authorization answers “what are you allowed to do”, and that is where roles and scopes come in. A role is a job title like admin or reader; a scope is a narrower permission like orders:read. The pattern in FastAPI is beautifully simple: write one more dependency that builds on get_current_user and rejects anyone missing the role. Because dependencies stack, the admin check runs only after the token check has already passed.
📄 auth_app.py: an admin-only route built on get_current_user
# Reject anyone without the admin role. Runs after get_current_user.
async def require_admin(user: User = Depends(get_current_user)) -> User:
if "admin" not in user.roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required")
return user
@app.get("/admin/report")
async def admin_report(user: User = Depends(require_admin)):
return {"report": "quarterly numbers", "viewed_by": user.username}
📄 test_roles.py: a reader is blocked, an admin gets through
import json
from fastapi.testclient import TestClient
from auth_app import app
client = TestClient(app)
# aditi is a reader.
r = client.post("/token", data={"username": "aditi", "password": "secret-pass"})
reader_auth = {"Authorization": f"Bearer {r.json()['access_token']}"}
r = client.get("/admin/report", headers=reader_auth)
print("reader on admin route ->", r.status_code, r.json())
# anvay has the admin role.
r = client.post("/token", data={"username": "anvay", "password": "admin-pass"})
admin_auth = {"Authorization": f"Bearer {r.json()['access_token']}"}
r = client.get("/admin/report", headers=admin_auth)
print("admin on admin route ->", r.status_code, json.dumps(r.json()))
▶ Output
reader on admin route -> 403 {'detail': 'Admin role required'}
admin on admin route -> 200 {"report": "quarterly numbers", "viewed_by": "anvay"}
What happened here: Aditi logged in fine and got a valid token, but her token carries only the reader role, so require_admin stopped her with a 403 Forbidden. That 403 is different from a 401: she is authenticated but not authorized. Anvay’s token carries admin, so the same route let him through. The report logic never needed to know about roles at all; the dependency chain handled every access decision before the endpoint ran.
The 5 Classic Auth Mistakes
These five FastAPI authentication mistakes show up in real breaches over and over. Here they are, each with the fix.
| Mistake | Why it hurts | The fix |
|---|---|---|
| 1. Secret in source code | Anyone with repo access can forge tokens | Read it from an environment variable or a secrets manager |
| 2. Tokens with no expiry | A leaked token works forever | Always set exp; require it on decode |
| 3. Not pinning the algorithm | An “alg: none” or key-confusion token slips past | Pass algorithms=["HS256"] explicitly |
| 4. Tokens in the URL | They leak into logs, history, and referrers | Send them in the Authorization header only |
| 5. No HTTPS | Tokens are readable on the wire | Serve over TLS everywhere, redirect plain HTTP |
Mistakes 1, 4, and 5 are habits: keep secrets in the environment, keep tokens out of query strings, and put everything behind HTTPS. Mistakes 2 and 3 are code, so let us prove the fixes. The script below shows an expiry-less token being refused when you require exp, and an “alg: none” token being refused because we pinned the algorithm.
📄 mistakes_demo.py: enforce expiry and pin the algorithm
import jwt
from datetime import datetime, timedelta, timezone
SECRET = "a-proper-32-byte-secret-loaded-from-the-environment"
# Mistake 2: a token with no expiry claim stays valid forever.
forever = jwt.encode({"sub": "aditi"}, SECRET, algorithm="HS256")
# Demand an exp claim on decode, and this token is refused.
try:
jwt.decode(forever, SECRET, algorithms=["HS256"], options={"require": ["exp"]})
except jwt.MissingRequiredClaimError:
print("no-expiry token, exp required ->", "rejected")
now = datetime.now(timezone.utc)
good = jwt.encode({"sub": "aditi", "exp": now + timedelta(minutes=15)}, SECRET, algorithm="HS256")
jwt.decode(good, SECRET, algorithms=["HS256"], options={"require": ["exp"]})
print("token with exp, exp required ->", "accepted")
# Mistake 3: the alg-confusion trick. An attacker crafts an UNSIGNED token
# that claims "alg": none, hoping your verifier trusts the header.
unsigned = jwt.encode({"sub": "attacker"}, key=None, algorithm="none")
try:
# Pin the algorithms you accept. Never let the token pick.
jwt.decode(unsigned, SECRET, algorithms=["HS256"])
except jwt.InvalidAlgorithmError:
print("alg:none token, algorithms pinned ->", "rejected")
▶ Output
no-expiry token, exp required -> rejected token with exp, exp required -> accepted alg:none token, algorithms pinned -> rejected
What happened here: Adding options={"require": ["exp"]} made PyJWT reject any token that forgot to set an expiry, so a “forever” token cannot sneak through. The unsigned “alg: none” token, which is the classic forgery attempt, was refused the instant we pinned algorithms=["HS256"], because the decoder only trusts the algorithm you named, not the one the token claims. Those two lines close two of the most common JWT holes with almost no effort.
Best Practices
- Keep the signing secret in the environment or a secrets manager, and rotate it if it ever leaks. For multi-service setups, consider asymmetric RS256 so services can verify tokens with a public key they cannot forge with.
- Give access tokens a short life (5 to 15 minutes) and lean on refresh tokens for longer sessions. Store refresh tokens server-side so you can revoke them.
- Return the same generic error for a bad username and a bad password, so you never leak which accounts exist.
- Let the library do the crypto. Use argon2-cffi or bcrypt for passwords and PyJWT or Authlib for tokens, and never hand-roll comparisons or signing.
- If you would rather not run auth yourself, a managed identity provider such as Auth0 or a self-hosted Keycloak handles login, refresh, and social sign-in, and your FastAPI app just verifies their tokens.
Conclusion
You now have a complete FastAPI authentication flow: passwords hashed with Argon2, JWTs issued and verified with real expiry, refresh tokens for longer sessions, a reusable get_current_user dependency, and role checks that return a clean 403. The habits matter more than the library: hash, never store; sign, always expire; pin your algorithm; keep secrets and tokens out of code and URLs. Because OAuth2 and JWT are standards, this knowledge carries over even when the packages change. This login flow is the foundation the capstone project later in the series builds on, so keep auth_app.py handy. For everything else, from basics to AI/ML, visit the Python + AI/ML tutorial series home.
Frequently Asked Questions
Should I use sessions or JWTs for FastAPI authentication?
Use JWTs for APIs, mobile clients, and multi-service setups where you do not want a shared session store. Use server sessions for a single classic web app when you need instant logout and simple revocation. Many real systems mix both: JWT access tokens plus server-stored refresh tokens.
Is argon2 or bcrypt better for password hashing?
Both are safe and widely used. Argon2 is the current recommendation at the time of writing because it resists GPU cracking well, and argon2-cffi gives a clean interface. bcrypt is older, battle-tested, and still perfectly fine. The one wrong answer is a fast hash like plain SHA-256 or MD5.
Where do I store the JWT on the client?
For browsers, an HttpOnly, Secure, SameSite cookie is safest because JavaScript cannot read it, which blocks token theft via XSS. For mobile or server-to-server clients, the Authorization header is standard. Never put a token in the URL, where it leaks into logs and history.
How do I log a user out with stateless JWTs?
A pure JWT cannot be revoked before it expires, which is why access tokens should be short-lived. For real logout, keep refresh tokens in the database and delete them on logout, or maintain a small deny-list of revoked token IDs that you check on each request.
Do I have to build all of this myself?
No. Libraries like Authlib handle OAuth2 flows for you, and managed providers such as Auth0 or a self-hosted Keycloak run login, refresh, and social sign-in. Your FastAPI authentication layer then only verifies the tokens they issue, using the same get_current_user pattern shown here.
Try It Yourself
Extend auth_app.py with a real refresh flow. Add a /refresh endpoint that accepts a refresh token and returns a new access token, give the login route both tokens, and add a /logout route that removes the refresh token from a server-side set so it can no longer be used. Then add a second protected route that only users with an editor role can reach, and prove all of it with a TestClient script.
Interview Questions on FastAPI Authentication
Try each one aloud first. The phrasing you produce under mild pressure is what interviews measure.
Q: Why hash passwords with Argon2 or bcrypt instead of a fast hash like SHA-256?
SHA-256 is built to be fast, which is exactly wrong for passwords: an attacker with your database can try billions of guesses per second. Argon2 and bcrypt are deliberately slow and memory-hard, and they salt each hash automatically, so a stolen database is far harder to crack and two users with the same password still get different hashes.
Q: What is the difference between a 401 and a 403 response?
401 Unauthorized means the server does not know who you are: no token, an expired token, or a bad signature. 403 Forbidden means it knows who you are but you lack permission for this action, like a reader hitting an admin route. In the demo, a missing token returns 401 and a valid reader token on an admin route returns 403.
Q: Why must you pass algorithms explicitly when decoding a JWT?
Because the token header names its own algorithm, and if your verifier trusts that blindly, an attacker can send alg: none for an unsigned token or trigger a key-confusion attack. Passing algorithms=["HS256"] pins the one algorithm you accept, so any token claiming a different one is rejected before the signature is even checked.
Q: Why use short-lived access tokens plus refresh tokens instead of one long-lived token?
A stateless JWT cannot be revoked before it expires, so a leaked long-lived token is dangerous for a long time. A short access token limits that window to minutes, while a refresh token, which you can store and revoke server-side, keeps the user logged in without re-entering a password. It is the balance between security and convenience.
Q: How does get_current_user keep authentication logic out of every endpoint?
It is a single dependency that reads the token, verifies it, loads the user, and either returns that user or raises a 401. Any route that adds user: User = Depends(get_current_user) is protected with no repeated code, and FastAPI caches the result within a request so it runs once even if several dependencies ask for it.
Q: Where should the JWT signing secret live, and why not in the code?
In an environment variable or a secrets manager, never in source. Anyone who can read the secret can forge valid tokens for any user, and secrets committed to a repository leak through history, forks, and backups long after you delete them. Loading it with os.environ also lets each environment use its own key and makes rotation possible.
Reference: the complete, always-current details live in FastAPI documentation.
Related Posts
Previous: Python: Flask vs FastAPI vs Django, Web Frameworks Compared
Next: Python: Multithreading (Thread, Lock, Global Interpreter Lock (GIL))
Series Home: Python + AI/ML Tutorial Series

No comment