OAuth2 Password Flow
24 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)A login route that returns a token, and a protected route that reads it from the Authorization header: this is the OAuth2 password flow FastAPI supports out of the box. It also gives you the Authorize button in the docs page.
Login once, then send a token
Bearer token is a value sent in the Authorization header as Bearer <token>. Whoever holds (bears) the token is treated as the user, so it must travel only over HTTPS.
Sending the password with every request is risky. Instead the client logs in once, gets a short token back, and sends that token in a header. The server only has to look at the token.
from fastapi.security import OAuth2PasswordBearer
oauth2 = OAuth2PasswordBearer(tokenUrl="token")
# Each request carries:# Authorization: Bearer <token>Example 1 · What OAuth2PasswordBearer adds to the docs
from fastapi import Depends, FastAPIfrom fastapi.security import OAuth2PasswordBearerimport json
app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="token")
@app.get("/items")def items(token: str = Depends(oauth2)): return []
scheme = app.openapi()["components"]["securitySchemes"]print(json.dumps(scheme, sort_keys=True)){"OAuth2PasswordBearer": {"flows": {"password": {"scopes": {}, "tokenUrl": "token"}}, "type": "oauth2"}}What this code does
- The app's OpenAPI description now has a security scheme with the
tokenUrl. - That is what makes the Authorize button appear in
/docs.
tokenUrl tells the docs page where to log in. As a dependency, oauth2 reads the Authorization header and returns the token text, or answers 401 if it is missing.
The /token route and the login form
OAuth2 password flow is a login pattern where the client sends the username and password to a /token route and receives an access token. It suits your own first-party app. OAuth 2.1 drops it, so large providers use other flows.
The OAuth2 password flow has a fixed shape: the client posts username and password as a form, not JSON. FastAPI reads the form with OAuth2PasswordRequestForm. The reply must hold access_token and token_type.
@app.post("/token")def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]): user = USERS.get(form.username) if user is None or not check(form.password, user): raise HTTPException(400, "Incorrect username or password") return {"access_token": make_token(user), "token_type": "bearer"}Example 2 · Login: success, wrong password, unknown user
import hashlib, hmac, secretsfrom typing import Annotatedfrom fastapi import Depends, FastAPI, HTTPExceptionfrom fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestFormfrom fastapi.testclient import TestClient
SALT = b"demo-salt"
def hash_pw(password): return hashlib.pbkdf2_hmac("sha256", password.encode(), SALT, 1000).hex()
USERS = {"asha": {"name": "asha", "hash": hash_pw("Orange#2024")}}TOKENS = {}
app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="token")
@app.post("/token")def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]): user = USERS.get(form.username) if user is None or not hmac.compare_digest(user["hash"], hash_pw(form.password)): raise HTTPException(status_code=400, detail="Incorrect username or password") token = secrets.token_hex(16) TOKENS[token] = user["name"] return {"access_token": token, "token_type": "bearer"}
def current_user(token: Annotated[str, Depends(oauth2)]): name = TOKENS.get(token) if name is None: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Bearer"}) return USERS[name]
@app.get("/me")def me(user: Annotated[dict, Depends(current_user)]): return {"username": user["name"]}
client = TestClient(app)
ok = client.post("/token", data={"username": "asha", "password": "Orange#2024"})print(ok.status_code, sorted(ok.json()), len(ok.json()["access_token"]))bad = client.post("/token", data={"username": "asha", "password": "wrong"})print(bad.status_code, bad.json())ghost = client.post("/token", data={"username": "nobody", "password": "x"})print(ghost.status_code, ghost.json())200 ['access_token', 'token_type'] 32
400 {'detail': 'Incorrect username or password'}
400 {'detail': 'Incorrect username or password'}What this code does
- Only the length of the token is printed, because the token is random each run.
- A wrong password and an unknown user get the same answer, so the route does not reveal which usernames exist.
The form needs the python-multipart package installed. Give the same message for a wrong user and a wrong password, so attackers cannot find which usernames exist.
A current-user dependency
A dependency is a function FastAPI runs before your route. Put the token lookup in one dependency, and every protected route just asks for it. A bad or missing token stops the request with 401.
def current_user(token: Annotated[str, Depends(oauth2)]): name = TOKENS.get(token) if name is None: raise HTTPException(401, "Invalid token", headers={"WWW-Authenticate": "Bearer"}) return USERS[name]Example 3 · Protected route: no token, bad token, good token
import hashlib, hmac, secretsfrom typing import Annotatedfrom fastapi import Depends, FastAPI, HTTPExceptionfrom fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestFormfrom fastapi.testclient import TestClient
SALT = b"demo-salt"
def hash_pw(password): return hashlib.pbkdf2_hmac("sha256", password.encode(), SALT, 1000).hex()
USERS = {"asha": {"name": "asha", "hash": hash_pw("Orange#2024")}}TOKENS = {}
app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="token")
@app.post("/token")def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]): user = USERS.get(form.username) if user is None or not hmac.compare_digest(user["hash"], hash_pw(form.password)): raise HTTPException(status_code=400, detail="Incorrect username or password") token = secrets.token_hex(16) TOKENS[token] = user["name"] return {"access_token": token, "token_type": "bearer"}
def current_user(token: Annotated[str, Depends(oauth2)]): name = TOKENS.get(token) if name is None: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Bearer"}) return USERS[name]
@app.get("/me")def me(user: Annotated[dict, Depends(current_user)]): return {"username": user["name"]}
client = TestClient(app)
r = client.get("/me")print(r.status_code, r.json(), r.headers["www-authenticate"])
r = client.get("/me", headers={"Authorization": "Bearer not-a-real-token"})print(r.status_code, r.json())
token = client.post("/token", data={"username": "asha", "password": "Orange#2024"}).json()["access_token"]r = client.get("/me", headers={"Authorization": f"Bearer {token}"})print(r.status_code, r.json())401 {'detail': 'Not authenticated'} Bearer
401 {'detail': 'Invalid token'}
200 {'username': 'asha'}What this code does
- A missing token and a bad token both give 401, and the first reply carries
WWW-Authenticate: Bearer. - The good token passes
current_user, and the route gets the user.
The WWW-Authenticate: Bearer header is part of the standard 401 reply: it tells the client which kind of login the server expects.
Testing the flow
TestClient calls the app in the same process, without a server or a network. Log in with data= (a form), read the token from the answer and send it back in a header.
login = client.post("/token", data={"username": "asha", "password": "..."})token = login.json()["access_token"]
r = client.get("/me", headers={"Authorization": f"Bearer {token}"})print(r.status_code, r.json())Example 4 · A form, not JSON, and overriding the user in tests
import hashlib, hmac, secretsfrom typing import Annotatedfrom fastapi import Depends, FastAPI, HTTPExceptionfrom fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestFormfrom fastapi.testclient import TestClient
SALT = b"demo-salt"
def hash_pw(password): return hashlib.pbkdf2_hmac("sha256", password.encode(), SALT, 1000).hex()
USERS = {"asha": {"name": "asha", "hash": hash_pw("Orange#2024")}}TOKENS = {}
app = FastAPI()oauth2 = OAuth2PasswordBearer(tokenUrl="token")
@app.post("/token")def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]): user = USERS.get(form.username) if user is None or not hmac.compare_digest(user["hash"], hash_pw(form.password)): raise HTTPException(status_code=400, detail="Incorrect username or password") token = secrets.token_hex(16) TOKENS[token] = user["name"] return {"access_token": token, "token_type": "bearer"}
def current_user(token: Annotated[str, Depends(oauth2)]): name = TOKENS.get(token) if name is None: raise HTTPException(status_code=401, detail="Invalid token", headers={"WWW-Authenticate": "Bearer"}) return USERS[name]
@app.get("/me")def me(user: Annotated[dict, Depends(current_user)]): return {"username": user["name"]}
client = TestClient(app)
r = client.post("/token", json={"username": "asha", "password": "Orange#2024"})print(r.status_code, r.json()["detail"][0]["loc"])
app.dependency_overrides[current_user] = lambda: {"name": "test-user"}print(client.get("/me").json())app.dependency_overrides.clear()print(client.get("/me").status_code)422 ['body', 'username']
{'username': 'test-user'}
401What this code does
- Sending JSON to the form route fails with 422, and the error points at the missing form fields in the body.
dependency_overridesswaps the dependency for a fake user, so a test can reach a protected route without logging in. Clear it afterwards.
Use data= for a form and json= for JSON. The /token route needs a form, so json= would give 422.
Next step
Check your understanding
12 quick questions