Course topics

By WebNest Studio

Python Tutorial

Authentication with OAuth2 and JWT

Most APIs must know who is calling. The standard approach for FastAPI APIs is token authentication: the user logs in once with a username and password, receives a signed JSON Web Token (JWT), and sends it with every later request in the Authorization: Bearer <token> header.

This lesson builds that flow step by step: hashing passwords safely, issuing and verifying JWTs, a get_current_user dependency that protects endpoints, and role-based permissions.

Password Hashing

Never store passwords, only password hashes made with a slow, salted algorithm designed for passwords — Argon2 or bcrypt, never plain SHA-256 or MD5. The pwdlib library (pip install "pwdlib[argon2]") provides PasswordHash.recommended() with hash() and verify(). Each hash includes its own random salt, so the same password hashes differently every time.

JSON Web Tokens

A JWT has three base64url parts separated by dots: a header, a payload of claims (sub — the user, exp — expiry time, plus anything you add such as a role) and a signature. The server signs it with a secret key (pip install pyjwt, jwt.encode(payload, SECRET_KEY, algorithm="HS256")), so it can later verify with jwt.decode() that the token is genuine and unexpired without a database lookup. The payload is only encoded, not encrypted — never put secrets in it. Keep tokens short-lived and the secret key in an environment variable.

OAuth2 Password Flow in FastAPI

OAuth2PasswordRequestForm reads username and password from a form-encoded login request (so the Authorize button in /docs works). OAuth2PasswordBearer(tokenUrl="token") is a dependency that extracts the bearer token from the header and returns 401 when it is missing. A get_current_user dependency decodes the token and loads the user; endpoints simply declare user: CurrentUser. Always answer bad credentials with the same vague message so attackers cannot tell which part was wrong.

Examples

Hashing and verifying passwords with pwdlib

Python
# pip install "pwdlib[argon2]"
from pwdlib import PasswordHash

password_hash = PasswordHash.recommended()        # Argon2 with safe defaults

first = password_hash.hash("s3cret!")
second = password_hash.hash("s3cret!")
print(first.split("$")[1], len(first) > 60)       # algorithm name, long hash
print("same password, different hashes:", first != second)
print(password_hash.verify("s3cret!", first), password_hash.verify("wrong", first))
Output
argon2id True
same password, different hashes: True
True False

Login, JWT access tokens and protected endpoints

Python
import os
from datetime import datetime, timedelta, timezone
from typing import Annotated

import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.testclient import TestClient
from pwdlib import PasswordHash
from pydantic import BaseModel

SECRET_KEY = os.environ.get("SECRET_KEY", "change-me-use-a-long-random-value")
ALGORITHM = "HS256"
ACCESS_TOKEN_MINUTES = 30

password_hash = PasswordHash.recommended()
USERS = {   # normally a database table
    "asha": {"username": "asha", "full_name": "Asha Rao", "role": "admin",
             "hashed_password": password_hash.hash("asha-pass")},
    "ravi": {"username": "ravi", "full_name": "Ravi Kumar", "role": "member",
             "hashed_password": password_hash.hash("ravi-pass")},
}

class User(BaseModel):
    username: str
    full_name: str
    role: str

def create_access_token(username: str, minutes: int = ACCESS_TOKEN_MINUTES) -> str:
    expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
    return jwt.encode({"sub": username, "exp": expires}, SECRET_KEY, algorithm=ALGORITHM)

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
    unauthorized = HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials",
                                 headers={"WWW-Authenticate": "Bearer"})
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
    except jwt.ExpiredSignatureError:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Token expired",
                            headers={"WWW-Authenticate": "Bearer"})
    except jwt.InvalidTokenError:
        raise unauthorized
    user = USERS.get(payload.get("sub"))
    if user is None:
        raise unauthorized
    return User(**user)

CurrentUser = Annotated[User, Depends(get_current_user)]

def require_admin(user: CurrentUser) -> User:
    if user.role != "admin":
        raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Admins only")
    return user

app = FastAPI()

@app.post("/token")
def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]):
    user = USERS.get(form.username)
    if not user or not password_hash.verify(form.password, user["hashed_password"]):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password",
                            headers={"WWW-Authenticate": "Bearer"})
    return {"access_token": create_access_token(user["username"]), "token_type": "bearer"}

@app.get("/users/me")
def read_me(user: CurrentUser) -> User:
    return user

@app.get("/admin/reports")
def reports(admin: Annotated[User, Depends(require_admin)]):
    return {"generated_for": admin.username, "open_orders": 12}

client = TestClient(app)
print(client.post("/token", data={"username": "asha", "password": "nope"}).json())

token = client.post("/token", data={"username": "asha", "password": "asha-pass"}).json()
print(token["token_type"], token["access_token"].count(".") + 1, "parts")
auth = {"Authorization": f"Bearer {token['access_token']}"}
print(client.get("/users/me", headers=auth).json())
print(client.get("/admin/reports", headers=auth).json())

ravi = client.post("/token", data={"username": "ravi", "password": "ravi-pass"}).json()["access_token"]
print(client.get("/admin/reports", headers={"Authorization": f"Bearer {ravi}"}).json())

print(client.get("/users/me").json())
print(client.get("/users/me", headers={"Authorization": "Bearer not-a-real-token"}).json())
expired = create_access_token("asha", minutes=-1)
print(client.get("/users/me", headers={"Authorization": f"Bearer {expired}"}).json())
Output
{'detail': 'Incorrect username or password'}
bearer 3 parts
{'username': 'asha', 'full_name': 'Asha Rao', 'role': 'admin'}
{'generated_for': 'asha', 'open_orders': 12}
{'detail': 'Admins only'}
{'detail': 'Not authenticated'}
{'detail': 'Could not validate credentials'}
{'detail': 'Token expired'}

What is inside a JWT (encoded, not encrypted)

Python
import base64
import json
import jwt

token = jwt.encode({"sub": "asha", "role": "admin"}, "secret-key", algorithm="HS256")
header, payload, signature = token.split(".")

def b64decode(part):
    return json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))

print(b64decode(header))
print(b64decode(payload))                       # anyone can read the claims
print(jwt.decode(token, "secret-key", algorithms=["HS256"]))
try:
    jwt.decode(token, "wrong-key", algorithms=["HS256"])
except jwt.InvalidSignatureError as e:
    print("InvalidSignatureError:", e)
Output
{'alg': 'HS256', 'typ': 'JWT'}
{'sub': 'asha', 'role': 'admin'}
{'sub': 'asha', 'role': 'admin'}
InvalidSignatureError: Signature verification failed

Common Mistakes

  • Storing plain-text passwords or fast hashes (MD5/SHA-256) instead of Argon2 or bcrypt.
  • Hard-coding the JWT secret in source code; load it from the environment and make it long and random.
  • Putting sensitive data in the JWT payload — it is readable by anyone.
  • Issuing tokens that never expire.
  • Revealing whether the username or the password was wrong.
  • Checking permissions inside each endpoint instead of in reusable dependencies.

Key Points to Remember

  • Hash passwords with pwdlib (Argon2) — hash() to store, verify() to check.
  • A JWT carries signed claims (sub, exp, role); jwt.decode verifies signature and expiry.
  • OAuth2PasswordRequestForm handles login; OAuth2PasswordBearer extracts the bearer token.
  • get_current_user as a dependency protects any endpoint; role dependencies add authorisation.
  • Use 401 for missing/invalid credentials and 403 for insufficient permissions.

Practice the examples

Change an input, predict the result, then compare it with the output. Explain why the result changes.

Use your local project environment for these examples. Codelab currently runs Python and HTML/CSS/JavaScript; framework examples may need project dependencies.