Authentication with OAuth2, JWT and password hashing

Hash passwords safely, issue and verify JSON Web Tokens, read the current user from a dependency, and use scopes to gate endpoints.

Storing passwords

pip install "passlib[bcrypt]" "python-jose[cryptography]"
import os
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext

SECRET_KEY = os.environ["SECRET_KEY"]        # long and random, from the environment
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(raw: str) -> str:
    return pwd.hash(raw)

def verify_password(raw: str, hashed: str) -> bool:
    return pwd.verify(raw, hashed)
  • Store a slow hash (bcrypt, scrypt, argon2) with a per-user salt; never a reversible encryption and never a fast hash such as MD5 or SHA-256.
  • pwd.verify compares in constant time and returns a boolean; do not compare hash strings yourself.
  • bcrypt only considers the first 72 bytes of input, so a very long passphrase is silently truncated. Reject absurdly long passwords or pre-hash with SHA-256.

Issuing and verifying tokens

def create_access_token(subject: str, scopes: list[str]) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": subject,
        "scopes": scopes,
        "iat": now,
        "exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
    }
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)

def decode_token(token: str) -> dict:
    try:
        return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except JWTError:
        raise HTTPException(status_code=401, detail="Invalid or expired token",
                            headers={"WWW-Authenticate": "Bearer"})

A JWT is signed, not encrypted: anyone can read the payload. Put only an identifier and authorisation claims in it, never personal data or secrets.

The current-user dependency

from fastapi import Depends, Security
from fastapi.security import OAuth2PasswordBearer, SecurityScopes

oauth2_scheme = OAuth2PasswordBearer(
    tokenUrl="/api/v1/auth/token",
    scopes={"items:read": "Read items", "items:write": "Modify items"},
)

@router.post("/auth/token")
def login(form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
    user = db.scalar(select(User).where(User.email == form.username))
    if not user or not verify_password(form.password, user.hashed_password):
        raise HTTPException(status_code=400, detail="Incorrect credentials")
    return {"access_token": create_access_token(user.email, ["items:read"]),
            "token_type": "bearer"}

def current_scopes(security_scopes: SecurityScopes, token: str = Depends(oauth2_scheme)):
    claims = decode_token(token)
    granted = set(claims.get("scopes", []))
    missing = set(security_scopes.scopes) - granted
    if missing:
        raise HTTPException(status_code=403, detail="Not enough permissions")
    return claims

@router.get("/items", dependencies=[Security(current_scopes, scopes=["items:read"])])
def list_items():
    return []
💡
The password form field is named username even when it carries an email address, because OAuth2PasswordRequestForm follows the OAuth2 specification. Using another name is the usual reason the Swagger authorisation dialog appears to do nothing.

FAQ

How do I log a user out before the token expires?
A stateless JWT cannot be revoked on its own. Keep access tokens short-lived, add a refresh token stored server-side that you can delete, and maintain a denylist of revoked token ids until their expiry.
Where should the client keep the token?
For browser clients, an HttpOnly, Secure cookie avoids exposing it to JavaScript and reduces XSS impact but needs CSRF protection. A memory-only variable plus a refresh cookie is the common compromise; localStorage is the riskiest choice.

Dependencies and async Error handling, middleware and CORS

Last refreshed 2026-09-18.