Sổ tay kỹ thuật

FastAPI xác thực bằng JWT – OAuth2PasswordBearer, PyJWT, pwdlib

how-to#python#fastapi#api#jwt· cập nhật 2026-11-06

Dùng khi API FastAPI cần đăng nhập bằng username/password, trả access token JWT và bảo vệ endpoint bằng dependency get_current_user.

Sửa trang này
Mục lục

Tóm tắt: uv add "fastapi[standard]" pyjwt "pwdlib[argon2]" → lưu mật khẩu dạng hash Argon2 → endpoint POST /token nhận form username/password (OAuth2PasswordRequestForm) và trả JWT ký HS256 có exp → OAuth2PasswordBearer đọc header Authorization: Bearer ... → dependency get_current_user giải mã và kiểm tra token. Đã chạy thử với FastAPI 0.162, PyJWT 2.15, pwdlib 0.3. Nền tảng FastAPI xem FastAPI cơ bản.

Luồng hoạt động

sequenceDiagram participant C as Client participant A as FastAPI C->>A: POST /token (form username, password) A->>A: pwdlib verify hash A-->>C: access_token (JWT, exp 30 phút) C->>A: GET /users/me + Authorization Bearer A->>A: OAuth2PasswordBearer lấy token A->>A: jwt.decode kiểm chữ ký và exp A-->>C: 200 user, hoặc 401 + WWW-Authenticate

Cấu hình

Cài và tạo secret

uv add "fastapi[standard]" pyjwt "pwdlib[argon2]"
export JWT_SECRET="$(openssl rand -hex 32)"     # production: lấy từ secret manager
export DEMO_PASSWORD='***'                       # chỉ cho ví dụ dưới

Hướng dẫn cũ dùng python-jose và passlib: cả hai gần như không còn bảo trì. FastAPI docs hiện dùng PyJWT và pwdlib. Gói PyPI tên pyjwt nhưng import là jwt; đừng cài nhầm gói jwt khác tên.

Ứng dụng

main.py
import os
from datetime import UTC, datetime, timedelta
from typing import Annotated
 
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel
 
SECRET_KEY = os.environ["JWT_SECRET"]          # openssl rand -hex 32
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE = timedelta(minutes=30)
 
password_hash = PasswordHash.recommended()     # Argon2
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
DUMMY_HASH = password_hash.hash("dummy")
 
# Demo: thay bằng bảng users trong DB
USERS = {
    "netops": {
        "username": "netops",
        "hashed_password": password_hash.hash(os.environ["DEMO_PASSWORD"]),
        "scopes": ["read"],
        "disabled": False,
    }
}
 
 
class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"
 
 
class User(BaseModel):
    username: str
    scopes: list[str] = []
    disabled: bool = False
 
 
def authenticate(username: str, password: str) -> User | None:
    row = USERS.get(username)
    if row is None:
        password_hash.verify(password, DUMMY_HASH)  # tốn thời gian như user thật
        return None
    if not password_hash.verify(password, row["hashed_password"]):
        return None
    return User(**row)
 
 
 
def create_access_token(sub: str, expires: timedelta = ACCESS_TOKEN_EXPIRE) -> str:
    now = datetime.now(UTC)
    payload = {"sub": sub, "iat": now, "exp": now + expires}
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
 
 
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
    cred_exc = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])  # check chữ ký + exp
        username = payload.get("sub")
    except InvalidTokenError:
        raise cred_exc from None
    row = USERS.get(username) if username else None
    if row is None:
        raise cred_exc
    return User(**row)
 
 
async def get_active_user(user: Annotated[User, Depends(get_current_user)]) -> User:
    if user.disabled:
        raise HTTPException(status_code=403, detail="Inactive user")
    return user
 
 
CurrentUser = Annotated[User, Depends(get_active_user)]
app = FastAPI()
 
 
@app.post("/token")
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
    user = authenticate(form.username, form.password)
    if user is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    return Token(access_token=create_access_token(user.username))
 
 
@app.get("/users/me")
async def me(user: CurrentUser) -> User:
    return user

Gọi thử

TOKEN=$(curl -s -X POST localhost:8000/token \
  -d username=netops -d "password=$DEMO_PASSWORD" | jq -r .access_token)
curl -s localhost:8000/users/me -H "Authorization: Bearer $TOKEN"
# Giải mã phần payload để xem (KHÔNG kiểm chữ ký, chỉ để debug)
uv run python -c 'import jwt,sys; print(jwt.decode(sys.argv[1], options={"verify_signature": False}))' "$TOKEN"

Trên /docs có nút Authorize: nhập username/password, Swagger UI tự gọi /token và gắn Bearer cho mọi request.

Test

test_main.py
from datetime import timedelta
 
from fastapi.testclient import TestClient
 
from main import app, create_access_token
 
client = TestClient(app)
 
 
def test_expired_token_rejected():
    expired = create_access_token("netops", expires=timedelta(seconds=-1))
    r = client.get("/users/me", headers={"Authorization": f"Bearer {expired}"})
    assert r.status_code == 401

Thành phần

Thành phần Vai trò
OAuth2PasswordRequestForm Đọc form username, password, scope (content-type application/x-www-form-urlencoded)
OAuth2PasswordBearer(tokenUrl="token") Lấy token từ header Authorization: Bearer, thiếu thì trả 401; tokenUrl chỉ để Swagger UI biết chỗ login
PasswordHash.recommended() Hash Argon2, verify() so mật khẩu với hash
jwt.encode / jwt.decode Ký / kiểm chữ ký, tự kiểm exp và báo ExpiredSignatureError (lớp con của InvalidTokenError)
Claim sub Định danh user; iat, exp dùng datetime có timezone UTC

Phân quyền theo vai trò: thêm claim (vd "scopes": ["read"]) khi tạo token rồi viết dependency require_scope("write") kiểm tra trong get_current_user. Với nhiều service cần xác minh token mà không giữ secret chung, dùng RS256/ES256 (private key ký, public key verify; cần pyjwt[crypto]) hoặc để IdP (Keycloak, Authentik) phát token.

Lỗi thường gặp

Triệu chứng Nguyên nhân Cách xử lý
POST /token trả 422 Gửi JSON thay vì form Gửi -d username=... -d password=... (form-urlencoded); cần python-multipart (có trong fastapi[standard])
401 Not authenticated Thiếu header hoặc sai dạng Header đúng: Authorization: Bearer <token>
401 sau một thời gian dùng được Token hết hạn (exp) Đăng nhập lại; cần phiên dài thì làm refresh token riêng, đừng tăng exp lên nhiều ngày
Mọi token cũ bị từ chối sau deploy JWT_SECRET đổi giữa các lần chạy hoặc khác nhau giữa các worker Đặt secret cố định qua env/secret manager, giống nhau trên mọi instance
DecodeError: It is required that you pass in a value for the "algorithms" argument Gọi jwt.decode thiếu algorithms= Luôn truyền algorithms=["HS256"] cố định, không lấy từ header token
AttributeError: module 'jwt' has no attribute 'encode' Cài nhầm gói jwt thay vì pyjwt uv remove jwt && uv add pyjwt
Token lộ trong log reverse proxy Truyền token qua query string Chỉ dùng header; không log header Authorization

Tham khảo