Skip to content

How to add post-quantum tokens to FastAPI

A FastAPI service that issues bearer tokens signed with hybrid ML-DSA-65 + Ed25519 and rejects any token that fails verification. The file runs its own self-check with FastAPI's test client.

The problem

Most FastAPI authentication uses JWTs signed with HS256, RS256 or ES256. RS256 and ES256 rely on RSA and elliptic curves, which a quantum computer breaks. You want the same bearer-token flow with signatures that stay unforgeable, without changing how clients send the token.

Install

pip install fastapi httpx git+https://github.com/PQBlocks/hybrid-jose

httpx is only needed for the self-check at the bottom of the file.

Complete working code

fastapi_pqc.py
"""FastAPI service that issues and checks hybrid ML-DSA-65 + Ed25519 signed tokens (pqblocks-jose)."""
from fastapi import Depends, FastAPI, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pqblocks_jose import Policy, decode_jws, encode_jws, generate_keypair

POLICY = Policy(
    version=1,
    kem="X25519+MLKEM768",
    sig="MLDSA65+Ed25519",
    kdf="HKDF-SHA256",
    max_token_bytes=8192,
    kdf_info="JOSE_PostQuantum_Traditional_Hybrid X25519MLKEM768",
    sig_alg_std="ML-DSA-65-Ed25519",
)
# Demo only: a real service loads its signing key from a secret store at start-up.
SIGNING_KEYS = generate_keypair("ML-DSA-65-Ed25519")

app = FastAPI(title="Post-quantum signed tokens")
bearer = HTTPBearer()


@app.post("/token")
def issue_token(user: str) -> dict:
    claims = {"sub": user, "aud": "api.example.com"}
    signed = encode_jws(claims, POLICY, private_key=SIGNING_KEYS.private_key)
    return {"access_token": signed.compact_serialization, "token_type": "Bearer"}


def verified_token(creds: HTTPAuthorizationCredentials = Depends(bearer)) -> str:
    try:
        result = decode_jws(token=creds.credentials, policy=POLICY, public_key=SIGNING_KEYS.public_key)
    except Exception:
        raise HTTPException(status_code=401, detail="invalid token")
    if not result.signature_verified:
        raise HTTPException(status_code=401, detail="invalid token")
    return creds.credentials


@app.get("/me")
def me(token: str = Depends(verified_token)) -> dict:
    return {"ok": True, "token_bytes": len(token)}


if __name__ == "__main__":
    # Self-check without a server: issue a token, use it, then tamper with it.
    from fastapi.testclient import TestClient

    client = TestClient(app)
    tok = client.post("/token", params={"user": "alice"}).json()["access_token"]
    assert client.get("/me", headers={"Authorization": "Bearer " + tok}).status_code == 200
    tampered = tok[:-8] + ("A" * 8 if not tok.endswith("A" * 8) else "B" * 8)
    assert client.get("/me", headers={"Authorization": "Bearer " + tampered}).status_code == 401
    print("issued, accepted, tampered token rejected: OK")

Run the self-check with python fastapi_pqc.py, or serve it with uvicorn fastapi_pqc:app. Output of the self-check:

issued, accepted, tampered token rejected: OK

How it works

sequenceDiagram
    autonumber
    participant C as Client
    participant A as FastAPI service
    C->>A: POST /token?user=alice
    A->>A: encode_jws(claims, POLICY, ML-DSA-65 + Ed25519 private key)
    A-->>C: access_token (compact JWS)
    C->>A: GET /me  Authorization: Bearer <token>
    A->>A: decode_jws(token, POLICY, public key)
    alt both signatures valid
        A-->>C: 200 {"ok": true}
    else any check fails
        A-->>C: 401 invalid token
    end
  1. /token signs the claims with the service's composite private key.
  2. verified_token is a FastAPI dependency: it decodes the bearer token against the pinned POLICY and the public key, and turns every failure into 401.
  3. Any route that depends on verified_token is protected; the handler only runs for a valid token.

Things to know

  • Load keys, do not generate them per process. With several workers, each would otherwise have its own key and reject the others' tokens. Load the private key from a secret store at start-up and share the public key with verifiers.
  • Token size. Hybrid ML-DSA-65 tokens are about 4.6 KB. Raise header limits on proxies (for example nginx large_client_header_buffers) if needed.
  • Add the usual JWT checks. This recipe shows the signature layer. Add expiry (exp), audience and issuer checks for your application.