A production-ready, self-hosted FastAPI (Python 3.14) authentication microservice for Docker Compose. Provides JWT authentication (HS256, RS256, ES256), Google OAuth2 with PKCE, Redis-backed stateful session management, role-based access control (RBAC), API key management with per-key rate limiting, and a private inter-service API — ready to drop into any Docker-based Python microservice stack.
Consumer services validate tokens locally using fastapi-m8 (pip install fastapi-m8 --upgrade) — no round-trip to the auth service on every request. fastapi-m8 bundles auth-sdk-m8 for JWT validation and shared schemas; advanced consumers can also import auth_sdk_m8 types directly. In stateful mode, revocation is checked via a lightweight HTTP call to the auth service private API (POST /private/v1/jti-status) instead of connecting to auth Redis directly, keeping the Redis instance private to the auth service.
The included example stacks use _m8 in their names as a personal naming convention — not a framework requirement. Any stack can be copied and adapted for your own project by renaming the Docker services, network, and env files.
- Features
- Architecture
- Docker Compose Stacks
- API Endpoints
- Quick Start
- Docker Hub image
- Choosing a Database
- Environment Variables
- Infrastructure Resilience
- Deployment Modes
- Security defaults
- API Key Authentication
- Private API
- Consumer Service Integration
- Development
- Prometheus Metrics
- Dependencies
- Email/password login with bcrypt password hashing (timing-attack safe)
- Google OAuth2 login with PKCE
- JWT access + refresh token pair (refresh token in HttpOnly cookie, atomically rotated on every use)
- RS256 / ES256 asymmetric signing with JWKS endpoint for zero-downtime key rotation
- Opt-in
iss/audJWT claim enforcement to prevent cross-service token reuse - Session tracking and JTI revocation via Redis
- Login rate limiting per email (Redis-backed, namespace-hardened)
- Refresh token rate limiting per user ID — 10 rotations / 5 min, prevents session integrity denial
- API key authentication with per-key fixed-window rate limiting (MINUTE / HOUR / DAY / MONTH),
X-RateLimit-*response headers, and write-behindlast_used_attracking - Role-based access control (
user,admin,superuser) - User management CRUD (superuser only)
- Profile self-service (read, update, password change, delete account, avatar URL)
- Dashboard activity endpoints
- Private inter-service API (protected by shared secret + Docker network isolation)
- MySQL or PostgreSQL — switchable via a single env var
- Prometheus metrics (
METRICS_ENABLED=true) with API key–specific counters and alert rules - Alembic migrations auto-applied on first start
- VS Code remote debugger support
Internet
│
┌──────▼──────┐
│ Traefik │ TLS termination, IP forwarding
└──────┬──────┘
│
┌────────────────┼────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌─────▼──────┐
│ auth-service│ │ consumer │ │ Prometheus │
│ :8000 │ │ service │ │ + Grafana │
└──┬──────┬───┘ └─────────────┘ └────────────┘
│ │
┌─────▼──┐ ┌─▼──────────┐
│ MySQL/ │ │ Redis :6379 │
│ Postgres│ └────────────┘
└────────┘
Consumer services validate tokens locally (JWT signature check + optional Redis blacklist via fastapi-m8) — no per-request call to the auth service. Other services on the same Docker network can also call the private API at http://auth-service:8000/user/private/ for operations such as creating users programmatically.
Six ready-to-run stacks are provided under examples/docker_compose/. See the stack selection guide for help choosing.
| Stack | Database | Algorithm | Token mode | Observability | Notes |
|---|---|---|---|---|---|
quickstart_m8 |
MariaDB | HS256 | stateful |
— | Start here — simplest onboarding |
postgres_m8 |
PostgreSQL 16 | HS256 | stateful |
— | PostgreSQL variant |
rs256_m8 |
MariaDB | RS256 | hybrid |
— | Asymmetric signing + JWKS |
metrics_m8 |
PostgreSQL 16 | HS256 | stateful |
Prometheus + Grafana | Metrics dashboards |
hardened_m8 |
PostgreSQL 16 | RS256 | stateful |
Prometheus + Grafana | Container hardening + Docker Hub image |
vault_dev_m8 |
PostgreSQL 18 | RS256 | stateful |
Prometheus + Grafana | HashiCorp Vault (dev mode) + Docker Hub image |
Start here → quickstart_m8 for the fastest path to a running stack.
All stacks include healthcheck probes and restart: unless-stopped on auth_user_service and fastapi_full. The consumer service waits for the auth service health check to pass (service_healthy) before starting.
The TOKEN_MODE column in the table above controls how tokens are validated across your services:
| Mode | Redis for JWT | Instant revocation | Google OAuth | Best for |
|---|---|---|---|---|
stateless |
No | ✗ | ✗ | Maximum scalability, no revocation needed |
hybrid |
Refresh only | Refresh tokens only | ✓ | Balance: scalable access + revocable refresh |
stateful |
Yes (every request) | ✓ | ✓ | Instant logout guarantee, highest security |
stateless disables Google OAuth (PKCE requires Redis). hybrid leaves a stolen access token valid until expiry after logout; use stateful if instant revocation is required.
All routes are prefixed with API_PREFIX (default /user).
| Tag | Method | Path | Auth | Description |
|---|---|---|---|---|
| meta | GET | /meta |
— | Static service/version/contract identity (ServiceMeta) for client compat checks; cacheable, read pre-auth |
| meta | GET | /ping |
— | Dependency-free liveness probe → {"status": "ok"} (single-mounted at {API_PREFIX}/ping since auth-sdk-m8 2.0.0) |
| health | GET | /health/ |
— (detail: X-Internal-Token) |
Public body is the constant {"status":"ok"}; Redis/database/effective-token-mode readiness detail is gated on HEALTH_DETAIL_CREDENTIAL (plan 9.4) |
| well-known | GET | /.well-known/jwks.json |
— | JWKS endpoint (RS256/ES256 public key; {"keys":[]} for HS256) |
| login | POST | /login/access-token |
— | Email/password login — returns access token, sets refresh cookie |
| login | POST | /login/refresh-token/ |
— | Refresh access token from HttpOnly cookie |
| login | POST | /login/logout/ |
JWT | Revoke session, blacklist JTI, clear cookie |
| login | POST | /login/test-token/ |
JWT | Validate access token, return current user |
| google-api | GET | /google-api/login-url/ |
— | Return Google OAuth2 authorization URL (native-app PKCE flow) |
| google-api | POST | /google-api/exchange/ |
— | One-time auth code exchange for tokens (PKCE verified, GETDEL atomic) |
| google-auth | GET | /google-auth/oauth-callback/ |
— | Google OAuth2 PKCE callback — exchange code, create/update user |
| profile | GET | /profile/get/me/ |
JWT | Read own profile |
| profile | PATCH | /profile/update/me/ |
JWT | Update own profile (email, full_name, avatar only — explicit allowlist) |
| profile | PATCH | /profile/me/password/ |
JWT | Change own password |
| profile | DELETE | /profile/delete/me/ |
JWT | Delete own account (cascades to the user's sessions, API keys, and rate-limit rows) |
| api-keys | GET | /profile/api-keys/verify |
X-API-Key | Validate key header, enforce rate limits, return key metadata |
| api-keys | POST | /profile/api-keys/ |
JWT | Create API key — plaintext returned once, never stored. Optional access_mode (read_only/read_write) and audiences (introspection consumer ids) are fixed at issuance (§3.11–§3.12); 409 on an invalid/ineligible audience |
| api-keys | GET | /profile/api-keys/ |
JWT | List own API keys (metadata only) |
| api-keys | GET | /profile/api-keys/{key_id} |
JWT | Get single key metadata |
| api-keys | DELETE | /profile/api-keys/{key_id} |
JWT | Revoke API key |
| api-keys-admin | GET | /api-keys/by-user/{user_id}/ |
superuser | Superadmin-only: list any user's API keys — metadata only (id, label, status, created_at/last_used_at, owner id); never the raw or hashed key (§3.11/§3.12). A read — not audited |
| api-keys-admin | POST | /api-keys/revoke/{key_id}/ |
superuser | Superadmin-only: revoke any user's API key (delete-equivalent, same authoritative path as the owner revoke); writes one delete privileged-action audit row atomically. No create/edit or secret-read surface exists |
| sessions | GET | /sessions/ |
superuser | List all sessions (paginated) |
| sessions | GET | /sessions/get/{session_id}/ |
superuser | Get session by ID |
| sessions | GET | /sessions/get-by-user/{user_id}/ |
superuser | Get session by user ID |
| sessions | GET | /sessions/get-current/ |
JWT | Get own current session |
| sessions | POST | /sessions/refresh-google-tokens/ |
JWT | Refresh external Google tokens |
| sessions | DELETE | /sessions/delete-by-user/{user_id}/ |
superuser | Delete all sessions for a user |
| sessions | DELETE | /sessions/delete/{session_id}/ |
superuser | Delete specific session |
| users | GET | /users/ |
superuser | List all users (paginated) |
| users | POST | /users/new_user/ |
superuser | Create user with password |
| users | POST | /users/signup/ |
superuser | Register user (no password set) |
| users | GET | /users/get/{user_id}/ |
superuser | Get user by ID |
| users | PATCH | /users/update/{user_id}/ |
superuser | Update user (role/is_active changes run under the superuser-set lock: revoke sessions, bump the auth generation, enqueue revocation to the durable outbox, and — on deactivation — revoke the user's API keys; returns 200 with auth_generation + revocation_enqueued; 409 last_superuser_required if it would remove the last active superuser; 403 on self-promotion) |
| users | DELETE | /users/delete/{user_id}/ |
superuser | Delete user (cascades to the user's sessions, API keys, and rate-limit rows; 409 last_superuser_required if it would remove the last active superuser; self-deletion allowed subject to that rule) |
| dashboard | GET | /dashboard/users/activity/ |
JWT | All-user activity stats (monthly) |
| dashboard | GET | /dashboard/users/activity/current/ |
JWT | Own activity stats (monthly) |
| metrics | GET | /metrics |
— | Prometheus metrics (METRICS_ENABLED=true only) |
| private | POST | /private/users/ |
X-Internal-Client + X-Internal-Token (user-create) |
Create user (inter-service, Docker network only) |
| private | POST | /private/v1/jti-status |
X-Internal-Client + X-Internal-Token (introspection) |
Check whether an access-token JTI is revoked (inter-service) |
| private | POST | /private/v1/api-keys/introspect |
X-Internal-Client + X-Internal-Token (api-key-introspection) |
Resolve a user API key to its canonical live owner principal (inter-service, §3.12) |
| private | GET | /private/v1/events/stream |
X-Internal-Client + X-Internal-Token (event-stream) |
SSE bridge of session-revoked / user-deleted events (inter-service) |
Service triad (/meta, /ping, /health/). /ping answers "is the process up?" (liveness — point container/orchestrator liveness probes here; never touches a dependency), /health/ answers liveness publicly with a constant {"status":"ok"} and "are dependencies reachable?" (readiness — Redis/DB) only in its credential-gated detail body (the public body never reflects degradation — plan 9.4 Design B), and /meta answers "what version/contract is this?" (client compatibility, read pre-auth — satisfies @fa-m8/astro-auth-m8's assertFaAuthM8Compatibility). /meta + /ping are the shared auth-sdk-m8 routes (mount_service_meta); the issuer mounts them directly since it doesn't use fastapi_m8.create_app. Since auth-sdk-m8 2.0.0 /ping is single-mounted at {API_PREFIX}/ping (the root /ping is no longer served when a prefix is set) and advertised in the schema — point container/orchestrator liveness probes at {API_PREFIX}/ping.
Interactive docs at {BACKEND_HOST}{API_PREFIX}/docs when SET_DOCS=true and ENVIRONMENT ≠ production. In production docs are suppressed by default; set SERVE_DOCS_IN_PRODUCTION=true to explicitly enable them (emits a startup warning — use only for public/open-source APIs).
cd examples/docker_compose/quickstart_m8 # fastest start — HS256 + stateful mode
# or
cd examples/docker_compose/rs256_m8 # asymmetric RS256 + JWKSSee the Docker Compose stack guide to pick the right stack.
cp .env.example .env
cp auth.env.example auth.env
cp api.env.example api.env
# Fill in all `changethis` values in .env, auth.env and api.envGenerate secrets with:
python -c "import secrets; print(secrets.token_urlsafe(64))"TLS works without this step. Each stack includes a
cert-initDocker service that generates a self-signed certificate automatically on the firstdocker compose up. Browsers will show a certificate warning in that case. Install mkcert only if you want a fully trusted cert with no browser warnings.
mkcert creates a local CA trusted by your OS and browsers, eliminating the ERR_CERT_AUTHORITY_INVALID warning and silent fetch() failures in Chrome extensions.
# Windows
winget install FiloSottile.mkcert # or: choco install mkcert
# macOS
brew install mkcert && brew install nss # nss = Firefox support
# Linux — see https://github.com/FiloSottile/mkcert#linuxAfter installing, run once to register the local CA system-wide:
mkcert -installinit.sh detects mkcert automatically and falls back to a self-signed OpenSSL certificate if it is not installed (browsers will still warn in that case).
| Browser | With mkcert | Without mkcert |
|---|---|---|
| Chrome, Edge, Brave, Opera, Vivaldi | ✅ Trusted automatically | |
| Safari (macOS) | ✅ Trusted automatically | |
| Firefox |
Firefox uses its own NSS certificate store and does not inherit the OS trust store.
See traefik/certs/README_DEV.md inside any example stack for the step-by-step
Firefox CA import walkthrough.
bash init.sh
# RS256/ES256 stacks: also generates the key pair and writes ACCESS_KEY_ID
# Rotating later: bash init.sh --rotate-keys — new keypair, previous public
# key retained as keys/public_old.pem, and both kids written together. The
# service refuses to boot if a kid does not match the key it labels.Windows: use Git Bash (included with Git for Windows) or WSL.
docker compose up --buildAlembic migrations run automatically. The first start seeds the superuser from FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD.
GET http://localhost:9000/user/health/
/user/healthanswers publicly on thewebsecureentryPoint (port 4430/443) with a constant shallow body ({"status":"ok"}) — it never reflects degradation. Its full infrastructure detail is gated at the app layer onHEALTH_DETAIL_CREDENTIAL(fail-closed)./user/metricsand/user/privatestay internal-only — reachable only on theapientryPoint (port 9000, localhost-bound) and blocked onwebsecure(plan 9.4, Design B).
The example stacks are ready-to-copy templates. To use one as the base for a new project:
- Copy the stack directory and rename it.
- In
docker-compose.yml, rename the Docker services and internal network to match your project. - Update all
changethisvalues in the env files. - Add your own microservices to
docker-compose.ymlon the same internal network.
hardened_m8 ships a thin production overlay so the same stack serves both a
safe dev/home-lab default and a hardened production posture — no second tree to
drift, nothing dangerous default-on. The base docker-compose.yml is unchanged
when the overlay is not applied; the overlay is what turns the production-only
options on.
cd examples/docker_compose/hardened_m8
cp .env.production.example .env
cp auth.env.production.example auth.env.production
cp api.env.production.example api.env.production
# Fill in every `changethis`, set your real FQDNs, and provision real TLS certs
# at ./traefik/certs/local.crt + local.key (the overlay never self-signs).
bash init.sh
docker compose -f docker-compose.yml -f docker-compose.production.yml up -dRequires Docker Compose v2.24+ (the overlay uses the
!reset/!overridemerge tags).
What the overlay changes vs. the dev default:
| Area | Dev default | Production overlay |
|---|---|---|
| Mode | ENVIRONMENT=local, STRICT_PRODUCTION_MODE=false |
production + STRICT_PRODUCTION_MODE=true (config-health is fatal, not warn) |
| Docs | SET_DOCS=true |
off (SET_DOCS=false; opt back in only with SERVE_DOCS_IN_PRODUCTION=true) |
| TLS certs | cert-init self-signs |
cert-init is a fail-closed presence check for operator certs |
| Published ports | app :8000, dashboard :8080, internal :9000, DB/Redis/Prometheus/Grafana on loopback |
only :80 (HTTP→HTTPS redirect) and :443; everything else Docker-network only |
| Host rules | Host(localhost) |
FQDN rules + app-layer ALLOWED_HOSTS |
| Cookies | — | SESSION_COOKIE_SECURE=true |
| Images | auth pinned, consumer builds locally | pinned (never :latest) |
Startup fails closed under the overlay on placeholder/duplicate secrets,
missing ALLOWED_HOSTS, missing TOKEN_ISSUER/TOKEN_AUDIENCE, localhost CORS
origins, unsafe internal HTTP, or unsafe event-signing rollout. HSTS stays
opt-in even in production (it is browser-persisted and hard to reverse).
Migrations. The stack runs alembic upgrade head automatically on up
(idempotent). For a single-node deployment this is kept as-is. For
multi-replica / zero-downtime rollouts, run it as a one-shot before starting
the app instead:
docker compose -f docker-compose.yml -f docker-compose.production.yml \
run --rm auth_user_service alembic upgrade headThe published image is available at:
docker pull tepochtli/fa-auth-m8:latest| Tag | Description |
|---|---|
latest |
Latest release from the main branch |
x.y.z (e.g. 1.0.0) |
Pinned release — recommended for production |
The example stacks under examples/docker_compose/ use build: to build the
service image locally from source. To use the published image instead, replace
the build: block in the auth_user_service service with an image: line:
# Replace this:
auth_user_service:
build:
context: ../../../
dockerfile: ./auth_user_service/Dockerfile
# With this:
auth_user_service:
image: tepochtli/fa-auth-m8:2.2.3 # pin to a specific release for productionAll env files, volumes, labels, and depends_on entries remain unchanged —
only the build: block is replaced.
| Scenario | Recommendation |
|---|---|
| Production deployment | image: tepochtli/fa-auth-m8:x.y.z — pinned, reproducible |
| Evaluating or quick start | image: tepochtli/fa-auth-m8:latest — always current |
| Active development / custom changes | build: (default in example stacks) — local source |
Set SELECTED_DB in .env (or auth.env):
| Value | Driver | Default port |
|---|---|---|
Mysql (default) |
pymysql / aiomysql |
3306 |
Mariadb |
pymysql / aiomysql |
3306 |
Postgres |
psycopg2 / asyncpg |
5432 |
Mysql and Mariadb are separate certified dialects (§4.6), never
interchangeable declarations. They share the same mysql+pymysql driver
family, but SELECTED_DB must name the engine actually deployed: Mysql is
valid only against a real MySQL server, Mariadb only against a real MariaDB
server. At startup the issuer compares SELECTED_DB against the connected
server's SQLAlchemy dialect and its version string (the only thing that
distinguishes a MariaDB server from a MySQL server on the shared wire
protocol) and fails startup cleanly with a dialect-mismatch error on any
mismatch — a deployment never runs half-configured.
Breaking configuration migration — existing MariaDB deployments. Before this contract, a MariaDB deployment could declare
SELECTED_DB=Mysql(the two dialects were not distinguished). That is no longer valid: every existing MariaDB deployment must changeSELECTED_DBtoMariadbbefore upgrading, or the issuer refuses to start with a dialect-mismatch error. This is not automatic — the setting is never rewritten silently. Deployments already declaringMysqlagainst a real MySQL server are unaffected. See the CHANGELOG for the release that introduced strict dialect verification.
| Variable | Required | Default | Description |
|---|---|---|---|
DOMAIN |
yes | — | Public domain (e.g. localhost) |
ENVIRONMENT |
yes | — | local | development | staging | production |
API_PREFIX |
yes | /user |
URL prefix for all routes |
PROJECT_NAME |
yes | — | Project name shown in docs |
STACK_NAME |
yes | — | Docker Compose stack slug |
BACKEND_HOST |
yes | — | Full backend URL (e.g. http://127.0.0.1:9000) |
FRONTEND_HOST |
yes | — | Full frontend URL (e.g. http://localhost:5173) |
BACKEND_CORS_ORIGINS |
yes | — | Comma-separated allowed origins |
TABLES_PREFIX |
no | auth |
DB table name prefix (e.g. auth_user, auth_api_key) |
SET_DOCS |
no | true |
Enable Swagger UI at {API_PREFIX}/docs. Suppressed in production unless SERVE_DOCS_IN_PRODUCTION=true. |
SET_REDOC |
no | true |
Enable ReDoc at {API_PREFIX}/redoc. Same production gate as SET_DOCS. |
SET_OPEN_API |
no | true |
Enable OpenAPI schema at {API_PREFIX}/openapi.json. Same production gate as SET_DOCS. |
SERVE_DOCS_IN_PRODUCTION |
no | false |
Explicitly serve docs in production (auth-sdk-m8 ≥ 0.7.3). Emits a startup warning. Only for public/open-source APIs. |
| Variable | Required | Default | Description |
|---|---|---|---|
TOKEN_MODE |
no | stateful |
stateless | hybrid | stateful — controls Redis usage and JTI revocation |
ACCESS_TOKEN_ALGORITHM |
no | RS256 |
Signing algorithm for access tokens. Secure-by-default RS256 (asymmetric + JWKS); ES256 also supported; HS256 is a documented opt-out (symmetric) |
REFRESH_TOKEN_ALGORITHM |
no | HS256 |
Signing algorithm for refresh tokens |
ACCESS_SECRET_KEY |
HS256 only | — | Symmetric signing key for access tokens |
REFRESH_SECRET_KEY |
yes | — | Signing key for refresh tokens (always HS256) |
REFRESH_SECRET_KEY_OLD |
no | — | Previous refresh signing key. Set during key rotation to allow old-key tokens to remain valid for the duration of their TTL. Remove once all pre-rotation refresh tokens have expired. |
ACCESS_PRIVATE_KEY_FILE |
RS256/ES256 only | — | Path to PEM private key file (mounted into container) |
ACCESS_PUBLIC_KEY_FILE |
RS256/ES256 only | — | Path to PEM public key file (distributed to consumers) |
ACCESS_TOKEN_EXPIRE_MINUTES |
no | 30 |
Access token lifetime |
REFRESH_TOKEN_EXPIRE_MINUTES |
no | 120 |
Refresh token lifetime |
REFRESH_TOKEN_COOKIE_EXPIRE_SECONDS |
no | 3600 |
Refresh cookie max-age |
SESSION_SECRET |
yes | — | Signing key for the SessionMiddleware cookie. Must be distinct from TOKENS_ENCRYPTION_KEY (key separation) so rotating the session key does not invalidate encrypted tokens. Rotation is single-key (Starlette exposes no key-list): the cookie max_age (3600s) bounds the blast radius to ≤1h of re-auth — there is no _OLD fallback. |
TOKENS_ENCRYPTION_KEY |
yes | — | Fernet key encrypting external/refresh token payloads at rest in Redis |
TOKENS_ENCRYPTION_KEY_OLD |
no | — | Previous TOKENS_ENCRYPTION_KEY. Set during a no-downtime key rotation so tokens encrypted under the old key stay decryptable (MultiFernet new→old fallback) while new writes use the current key. Remove once every stored token has been re-encrypted under / expired past the new key. |
TOKEN_ISSUER |
if strict | — | iss claim embedded in issued tokens; validators require an exact match. Required at boot when TOKEN_STRICT_VALIDATION=true (the default). |
TOKEN_AUDIENCE |
if strict | — | aud claim embedded in issued tokens; validators require an exact match. Required at boot when TOKEN_STRICT_VALIDATION=true (the default). |
TOKEN_STRICT_VALIDATION |
no | true |
Secure-by-default strict profile (auth-sdk-m8 ≥ 1.0.0): enforces iss/aud binding and pins the configured algorithm; the service fails closed at boot unless TOKEN_ISSUER/TOKEN_AUDIENCE are set. Set false to opt out (legacy/local), enforcing iss/aud only when configured. |
ACCESS_KEY_ID |
no | — | kid in JWT headers and JWKS. Must be the public key's DER fingerprint — SHA-256(SPKI DER), first 16 hex, the value init-keys.sh writes. A mismatch is a startup failure (2.1.0); the same value is derived when unset. |
ACCESS_KEY_ID_ALLOW_UNBOUND |
no | false |
Emergency break-glass: downgrade the ACCESS_KEY_ID binding failure to a warning so an already-unbound deployment can boot while it is re-provisioned. Removed in 3.0.0. |
ACCESS_PUBLIC_KEY_OLD_FILE |
no | — | Previous public key, kept published during a keypair rotation so tokens signed under it keep verifying with no consumer restart. Verification-only — signing always uses ACCESS_PRIVATE_KEY_FILE, and there is deliberately no ACCESS_PRIVATE_KEY_OLD. Written by init-keys.sh --rotate; unset once every old-key access token has expired. |
ACCESS_KEY_ID_OLD |
no | — | kid for ACCESS_PUBLIC_KEY_OLD_FILE, validated against it exactly as ACCESS_KEY_ID is. Derived from that key when unset. |
AUTH_SERVICE_ROLE |
no | issuer |
issuer (auth service) or consumer (downstream services) |
JWKS_URI |
no | — | Consumer services: JWKS endpoint URL; enables automatic JwksKeyResolver wiring |
JWKS_CACHE_TTL_SECONDS |
no | 300 |
JWKS key cache TTL in seconds |
RS256 / ES256 (secure-by-default) — ACCESS_TOKEN_ALGORITHM defaults to RS256: the auth service signs with the mounted private key and publishes JWKS (see below). RSA keys must be ≥ 2048-bit (enforced at boot).
HS256 (opt-out) — set ACCESS_TOKEN_ALGORITHM=HS256, ACCESS_SECRET_KEY, and REFRESH_SECRET_KEY; leave asymmetric key vars blank.
Generating asymmetric keys — set ACCESS_PRIVATE_KEY_FILE and ACCESS_PUBLIC_KEY_FILE, then mount the key files into the container (see examples/docker_compose/rs256_m8/keys/). Generate a key pair:
# RS256
openssl genrsa -out private.pem 2048
openssl rsa -in private.pem -pubout -out public.pem
# ES256
openssl ecparam -genkey -name prime256v1 -noout -out private.pem
openssl ec -in private.pem -pubout -out public.pemOr use bash init.sh in any asymmetric stack — it generates the correct key type automatically.
Migrating an existing HS256 deployment — auth-sdk-m8 ≥ 1.0.0 flips the defaults to RS256 + strict iss/aud. Existing deployments have two coherent paths:
- Adopt the secure default (recommended): generate an RSA key pair, set
ACCESS_PRIVATE_KEY_FILE/ACCESS_PUBLIC_KEY_FILE, point consumers atJWKS_URI, and set the sameTOKEN_ISSUER/TOKEN_AUDIENCEon the auth service and every consumer. Roll the public key / JWKS out to consumers before switching the issuer so in-flight tokens still validate. - Stay on the legacy posture (opt-out): set
ACCESS_TOKEN_ALGORITHM=HS256(withACCESS_SECRET_KEY) andTOKEN_STRICT_VALIDATION=false. The service then behaves as it did before 1.0.0 —iss/audenforced only when configured.
| Variable | Required | Default | Description |
|---|---|---|---|
SELECTED_DB |
no | Mysql |
Mysql, Mariadb, or Postgres — must name the actually-deployed engine; verified fail-closed at startup (§4.6, see Choosing a Database) |
DB_HOST |
yes | — | Database host |
DB_PORT |
yes | — | Database port |
DB_DATABASE |
yes | — | Database name |
DB_USER |
yes | — | Database user |
DB_PASSWORD |
yes | — | Database password |
| Variable | Required | Description |
|---|---|---|
REDIS_HOST |
yes | Redis host |
REDIS_PORT |
yes | Redis port |
REDIS_USER |
yes | Redis user |
REDIS_PASSWORD |
yes | Redis password |
REDIS_SSL |
no | Enable TLS for the Redis connection pool (default: false). Set true when Redis is reached over a network boundary in staging/production. |
REDIS_SSL_CA |
no | Path to CA certificate file. Required when REDIS_SSL=true — without it the connection pool cannot verify the server cert and will raise CERTIFICATE_VERIFY_FAILED. |
REDIS_SSL_CERT |
no | Path to client certificate for mTLS. Must be set together with REDIS_SSL_KEY; cannot be set without it. |
REDIS_SSL_KEY |
no | Path to client private key for mTLS. Must be set together with REDIS_SSL_CERT; cannot be set without it. |
| Variable | Required | Description |
|---|---|---|
FIRST_SUPERUSER |
yes | Email of the bootstrap superuser — used only on first run |
FIRST_SUPERUSER_PASSWORD |
yes | Password of the bootstrap superuser — used only on first run |
GOOGLE_CLIENT_ID |
no | Google OAuth2 client ID |
GOOGLE_CLIENT_SECRET |
no | Google OAuth2 client secret |
GOOGLE_OAUTH_REDIRECT_URI |
no | Fixed backend callback URI for native-app PKCE OAuth. Must match Google Console exactly. Defaults to auto-derived from request URL when empty. |
OAUTH_ALLOWED_REDIRECT_SCHEMES |
no | URI scheme(s) accepted as redirect_target at /google-api/login-url/ (default chrome-extension://). Add https:// only for trusted web clients; add http:// only for local development. |
OAUTH_ALLOWED_REDIRECT_PREFIXES |
no | Optional full-URI prefix allowlist. Required for http:// and https:// redirects to pin trusted callback origins; optional for native public-client schemes. Plain HTTP is limited to localhost and rejected in production/staging. |
CORS_ALLOWED_ORIGIN_SCHEMES |
no | Scheme-level CORS origins for native-app fetch() calls (e.g. chrome-extension://). |
PRIVATE_API_SECRET |
yes | Signs the short-TTL service tokens and gates the /health detail body + /metrics. No longer a private-route gate (the legacy shared X-Internal-Token path was retired in v1.0.0 — PRIVATE_API_CONSUMERS replaces it). |
PRIVATE_API_CONSUMERS |
yes¹ | Per-consumer private-API credentials: JSON map of consumer id → {secret, scopes} (scopes: introspection / api-key-introspection / event-stream / user-create, deny-by-default; secret plaintext or hashed sha256$<salt>$<digest>). The only private-API auth model — each consumer presents X-Internal-Client + X-Internal-Token (or exchanges it for a service token). api-key-introspection is the dedicated §3.12 grant — it is never implied by introspection, and the consumer id it is granted to is the API-key audience, so see Provisioning the API_KEY_INTROSPECTION scope before granting it. ¹Empty is allowed but then every /private/* call fails closed (401) and the service-token exchange is disabled (404). |
auth-sdk-m8 ≥ 1.0.0 introduces secure-by-default event signing (F3): when EVENT_SIGNING_ENABLED=true (the default), the service fails closed at boot unless a strong EVENT_SIGNING_KEY is configured. Set the key in auth_user_service/.env and in every consumer stack's env file.
| Variable | Required | Default | Description |
|---|---|---|---|
EVENT_SIGNING_ENABLED |
no | true |
Master switch. When true, EVENT_SIGNING_KEY is required at startup — the service will not boot without it. Set false only to disable event signing entirely (opt-out). |
EVENT_SIGNING_KEY |
if enabled | — | HMAC key used to sign event-bus payloads. Must satisfy the secret-key strength policy (32+ chars, mixed-case, digit, non-alphanumeric). Never use the dev placeholder in production. |
EVENT_SIGNING_ACCEPT_UNSIGNED |
no | false |
Transitional flag for rolling upgrades: when true, consumers accept signed or unsigned messages (still reject forged signatures). Flip back to false once every publisher signs. |
Auth Redis is private to fa-auth-m8. Consumer services check revocation via the HTTP private API (
POST /private/v1/jti-status) — they never connect to the auth Redis directly. Auth-state changes are pushed to consumers over the authenticated SSE bridge below (same private API, same trust channel), so there is no shared broadcast medium and no second Redis to wire.
fa-auth-m8 pushes its own auth-state changes to backend consumers over an authenticated Server-Sent Events stream on the private API: GET /private/v1/events/stream (gated by the same per-consumer credential as jti-status, requiring the event-stream scope). Consumers receive session-revoked and user-deleted events and evict locally cached token-validation state ahead of natural expiry.
Accelerator, not authority. Push is a best-effort latency optimisation. The JTI blacklist behind
POST /private/v1/jti-statusremains the revocation authority — a consumer that misses every event is still correct, just slower to converge. So stream loss is never fatal: the service keeps enforcing revocation over the HTTP authority path.
Each event data frame is HMAC-signed with the shared EVENT_SIGNING_KEY (reused from Event Signing above — no separate key); consumers verify it before acting. Reconnecting consumers send Last-Event-ID: a still-buffered gap is replayed exactly, while an unresumable gap (fa-auth restarted, or the id evicted from the ring buffer) is signalled with an event: gap frame, after which the consumer flushes all cached validation state.
| Variable | Required | Default | Description |
|---|---|---|---|
EVENT_STREAM_ENABLED |
no | true |
Master switch for the SSE bridge. When false, the endpoint returns 404 and emission is a fleet-wide no-op. |
EVENT_STREAM_BUFFER_SIZE |
no | 256 |
Ring-buffer depth (events retained for Last-Event-ID resume). |
EVENT_STREAM_HEARTBEAT_SECONDS |
no | 15 |
Heartbeat comment-frame interval. Keep below the consumer read timeout and any reverse-proxy idle timeout. |
EVENT_STREAM_MAX_QUEUE |
no | 64 |
Per-connection outbound queue depth before a slow consumer is disconnected (it reconnects and resumes/flushes). Never blocks the emitting request. |
Reverse proxies: SSE needs response buffering disabled so frames flush immediately. The endpoint sends
X-Accel-Buffering: no(honoured by nginx); for other proxies, disable response buffering and set the idle/read timeout aboveEVENT_STREAM_HEARTBEAT_SECONDS.
The database is the source of revocation correctness. Redis blacklist entries, outbox delivery and session-revoked events are accelerators: they evict consumer caches sooner, but they never decide. Every path that revokes authorization persists that revocation on the authoritative ClientSession row(s) — or on durable user state — in the same transaction/operation, so a Redis failure can never re-activate a database-inactive session and a fresh POST /private/v1/jti-status (v2) decision denies from database state alone.
| Path | Authoritative persistence | Accelerator |
|---|---|---|
| Logout | Session row deleted (revoke_session_jti, then the idempotent delete_session_by_jti) |
Access-JTI blacklist, refresh allowlist revoke, per-JTI event |
Individual session revocation (revoke_session_jti) |
Session row deleted and committed before any Redis write | Access-JTI blacklist + per-JTI event |
Administrative revocation (DELETE /sessions/delete/{id}/, /sessions/delete-by-user/{user_id}/) |
Session row(s) deleted and committed | Blacklist + refresh revoke + per-JTI (single) or user-wide (bulk) event |
| Refresh rotation | The user's session row is re-stamped with the new JTI and the owner's current generation, so the superseded JTI matches no row | Refresh-store rotate (atomic swap) |
| Refresh-token reuse response | Every session row for the user deleted (revoke_all_user_sessions) |
Blacklist of all captured JTIs + user-wide event |
| Role change / deactivation / reactivation | Route-owned transaction: auth_generation bump + session rows deleted + outbox rows enqueued, one commit |
Durable outbox (blacklist + v2 event) |
| Deletion | Durable authorization tombstone + row delete, one commit | Durable outbox |
| Security repair | Same transaction as a runtime role change (generation bump, session delete, outbox enqueue) | Durable outbox |
| Global legacy-session revocation (cutover) | All pre-Expand session rows deleted (never backfilled) | — (write-quiescent maintenance) |
No revocation path is Redis-only: a Redis write with no corresponding authoritative database change is a defect. Authoritative-database unavailability is a 503 and never falls open; Redis unavailability falls back to the authoritative database decision, except where strict Redis-backed rate limiting itself mandates 503.
Role-change revocation side effects (the Redis blacklist writes and the user-wide session-revoked event) are recorded as durable rows in the <prefix>_revocation_outbox table inside the same transaction as the database revocation, then applied by a background drain worker with at-least-once delivery. This closes the crash window in which a best-effort post-commit push could strand revocation state. The DB revocation is already authoritative (POST /private/v1/jti-status decides from database state), so a disabled or lagging worker only slows cache-eviction propagation — it never changes correctness. The role-change endpoint therefore returns 200 with auth_generation and revocation_enqueued: true as soon as the transaction commits; it never returns a post-commit 503/202. Outbox publish effects carry the v2 event (auth_generation, deterministic event_id) that consumers dedup on; blacklist effects carry a TTL derived from the captured per-target token expiry.
| Variable | Required | Default | Description |
|---|---|---|---|
OUTBOX_WORKER_ENABLED |
no | true |
Master switch for the in-process outbox drain loop. When false, effects still commit durably but propagation waits for another worker. |
OUTBOX_DRAIN_INTERVAL_SECONDS |
no | 1.0 |
How often the drain loop claims and delivers a batch. |
OUTBOX_BATCH_SIZE |
no | 50 |
Max rows claimed per drain (FOR UPDATE SKIP LOCKED). |
OUTBOX_LEASE_SECONDS |
no | 30 |
Lease held on a claimed row; an expired lease is re-claimable (crashed-worker recovery). |
OUTBOX_MAX_ATTEMPTS |
no | 5 |
Delivery attempts before a row is dead-lettered (status='dead'). |
OUTBOX_BACKOFF_BASE_SECONDS / OUTBOX_BACKOFF_CAP_SECONDS |
no | 2.0 / 300.0 |
Exponential-backoff base and cap between retries. |
OUTBOX_COMPLETED_RETENTION_SECONDS |
no | 3600 |
How long a completed row is retained (idempotency window) before reaping. |
Deleting a user removes the row that carries its auth_generation, so the delete transaction first writes a durable authorization tombstone — a <prefix>_tombstone row keyed by user_id, with no foreign key to the user, holding the subject's terminal generation. Introspection treats a tombstoned subject as fully revoked before it looks at anything else, and the write is an idempotent max upsert, so a replayed delete can only raise the terminal generation, never lower it. User ids are never reused, so a tombstone can never deny a legitimately new account.
A tombstone is removed only when both guards allow it:
- Retention horizon. It must be older than the maximum of the access-token TTL, the refresh-session lifetime,
OUTBOX_COMPLETED_RETENTION_SECONDS, andTOMBSTONE_CONSUMER_HORIZON_SECONDS— i.e. it outlives every artefact that could still replay the deleted subject's authorization. - No undelivered outbox effect references the subject. A
pending,leased, ordeadrow in the revocation outbox pins its subject's tombstone for as long as it exists (a leased row is a pending row a worker currently holds; a dead-lettered row awaits operator replay). This guard is independent of the horizon: an effect stuck in the dead-letter queue keeps the tombstone alive indefinitely.completedrows never pin — their idempotency window is already part of the horizon above.
| Variable | Required | Default | Description |
|---|---|---|---|
TOMBSTONE_CONSUMER_HORIZON_SECONDS |
no | 3600 |
The part of the retention horizon the issuer cannot observe: how long a consumer may still hold a positive cache entry for the subject or replay a session-revoked event about it. Set it to the longest revocation-cache TTL / event-replay window across your consumers. A floor, never a ceiling — the effective retention is the maximum of all four values, so raising it is always safe. |
Controls what happens to each security control when Redis is unavailable. All settings are optional; the defaults represent the recommended production posture.
| Variable | Default | Description |
|---|---|---|
AUTH_STRICT_MODE |
false |
When true, overrides all per-control modes to fail_closed |
REFRESH_VALIDATION_FAILURE_MODE |
fail_closed |
Refresh allowlist check unavailable → fail_closed: 503 | fail_open: skip check |
SESSION_WRITE_FAILURE_MODE |
fail_closed |
Token revocation on logout fails → fail_closed: 503 | fail_open: silent skip |
RATE_LIMIT_FAILURE_MODE |
fail_open |
Rate limiter unavailable → fail_closed: 503 | fail_open: skip check |
ACCESS_REVOCATION_FAILURE_MODE |
fail_open |
Access token blacklist check unavailable → fail_closed: 503 | fail_open: skip |
Default posture: refresh validation and session writes fail closed (logout is authoritative; unverifiable refresh tokens are rejected). Rate limiting and access revocation fail open (short token TTL bounds the exposure window; availability is preserved).
Every degraded-mode decision emits an auth_degraded_decision_total counter (labels: control, mode, reason) — see the Prometheus metrics table for full label values.
Controls the fixed-window rate limits applied to the login and refresh-token endpoints (Redis-backed). All settings are optional; the defaults represent the recommended security posture.
| Variable | Default | Description |
|---|---|---|
LOGIN_RATE_LIMIT_REQUESTS |
5 |
Max login attempts per window per email before 429 |
LOGIN_RATE_LIMIT_WINDOW_MINUTES |
15 |
Brute-force window in minutes |
REFRESH_RATE_LIMIT_REQUESTS |
10 |
Max refresh token rotations per window per user |
REFRESH_RATE_LIMIT_WINDOW_MINUTES |
5 |
Churn-prevention window in minutes |
A startup warning is logged if the effective rate (requests ÷ window) exceeds 5 req/min for login or 20 req/min for refresh. When Redis is unavailable, behaviour falls back to RATE_LIMIT_FAILURE_MODE.
| Variable | Required | Default | Description |
|---|---|---|---|
API_KEY_STRICT_RATE_LIMIT |
no | false |
Force fail-closed (503) API-key verification when Redis is unavailable. Auto-enabled in production/strict (ENVIRONMENT=production, STRICT_PRODUCTION_MODE=true, or AUTH_STRICT_MODE=true); this flag only forces it on elsewhere |
API_KEY_DEFAULT_LIMIT_MINUTE |
no | 60 |
Default requests per minute (0 = disabled) |
API_KEY_DEFAULT_LIMIT_HOUR |
no | 1000 |
Default requests per hour |
API_KEY_DEFAULT_LIMIT_DAY |
no | 10000 |
Default requests per day |
API_KEY_DEFAULT_LIMIT_MONTH |
no | 200000 |
Default requests per month |
API_KEY_MAX_PER_USER |
no | 10 |
Maximum API keys a user may create |
API_KEY_MAX_AUDIENCES |
no | 3 |
Maximum introspection audiences bindable to a single API key (§3.12) |
| Variable | Required | Default | Description |
|---|---|---|---|
METRICS_ENABLED |
no | false |
Expose GET /metrics Prometheus endpoint |
METRICS_GROUPS |
no | all |
Comma-separated groups: all | traffic | performance | reliability | health | auth |
SENTRY_DSN |
no | — | Sentry DSN for error tracking |
auth-sdk-m8 ≥ 1.2.1 adds a shared application-level hardening layer
(add_security_headers_middleware) wired into the auth service — and into every
fastapi-m8 consumer via create_app. Headers are applied on every response
(including error responses raised before the route handler) in three tiers:
| Tier | Headers | When applied |
|---|---|---|
| Always-on | X-Content-Type-Options: nosniff, X-Frame-Options: DENY |
Every environment (whenever SECURITY_HEADERS_ENABLED). Safe for Swagger/ReDoc/HMR. |
| Production-gated | Referrer-Policy, Permissions-Policy |
ENVIRONMENT=production or STRICT_PRODUCTION_MODE=true — the same gate as docs hiding. |
| Express opt-in | Strict-Transport-Security (HSTS), Content-Security-Policy (CSP) |
Only when HSTS_ENABLED / CONTENT_SECURITY_POLICY_ENABLED — never on local, even if opted in. |
HSTS and CSP are browser-persisted and hard to reverse (enabling HSTS on a host
writes a long-lived HTTPS-only record — on localhost it force-upgrades every local
service to HTTPS), so they are off by default, decoupled from the production gate,
and hard-blocked on local. A TLS-terminated staging stack can opt in without
masquerading as production.
| Variable | Default | Description |
|---|---|---|
SECURITY_HEADERS_ENABLED |
true |
Master switch. Set false to suppress every tier, even in production. |
HSTS_ENABLED |
false |
Express opt-in for Strict-Transport-Security. Never emitted on local. |
HSTS_MAX_AGE |
31536000 |
HSTS max-age in seconds. 0 also disables the header. Only applies when HSTS_ENABLED. |
HSTS_INCLUDE_SUBDOMAINS |
true |
Append includeSubDomains to the HSTS header. |
CONTENT_SECURITY_POLICY_ENABLED |
false |
Express opt-in for Content-Security-Policy. Never emitted on local. |
CONTENT_SECURITY_POLICY |
— | CSP value used when enabled. Unset → tight API default (default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'). Override for services that serve HTML. |
REFERRER_POLICY |
strict-origin-when-cross-origin |
Referrer-Policy header value (production-gated tier). |
PERMISSIONS_POLICY |
accelerometer=(), camera=(), geolocation=(), … |
Permissions-Policy header value (production-gated tier). |
Behaviour change (auth-sdk-m8 1.2.1 / fastapi-m8 1.5.0): HSTS and CSP were emitted automatically under the production gate in earlier releases. They are now off until explicitly enabled via
HSTS_ENABLED=true/CONTENT_SECURITY_POLICY_ENABLED=true.
| Variable | Default | Description |
|---|---|---|
API_BIND_IP |
127.0.0.1 |
Host IP Traefik binds port 9000 to. Set to 0.0.0.0 for LAN/public exposure |
TRUSTED_PROXY_COUNT |
1 |
Number of trusted reverse-proxy hops in front of this service. Used to extract the real client IP from X-Forwarded-For (leftmost entry). Set to 0 to ignore XFF entirely (no proxy in front). |
TRUSTED_PROXY_IPS |
172.16.0.0/12 |
CIDR(s) Uvicorn trusts as reverse-proxy source for X-Forwarded-For |
STRICT_PRODUCTION_MODE |
false |
When true, enforce production-grade checks (e.g. secure cookies) even in non-production environments |
The service degrades gracefully when Redis or the database is temporarily unavailable.
Behaviour when Redis is down is controlled by the Auth Degradation Policy settings. The table below shows the default posture (fail_closed for refresh + logout, fail_open for rate limiting + access revocation):
TOKEN_MODE |
Login | Refresh | Logout | Google OAuth |
|---|---|---|---|---|
stateless |
✅ unaffected | ✅ unaffected | ✅ unaffected | ❌ 503 (PKCE requires Redis) |
hybrid |
✅ works, rate limiting skipped | ❌ 503 (REFRESH_VALIDATION_FAILURE_MODE=fail_closed) |
❌ 503 (SESSION_WRITE_FAILURE_MODE=fail_closed) |
❌ 503 |
stateful |
✅ works, rate limiting skipped | ❌ 503 (REFRESH_VALIDATION_FAILURE_MODE=fail_closed) |
❌ 503 (SESSION_WRITE_FAILURE_MODE=fail_closed) |
❌ 503 |
Set REFRESH_VALIDATION_FAILURE_MODE=fail_open and SESSION_WRITE_FAILURE_MODE=fail_open to restore the previous fail-open behaviour (tokens accepted without allowlist check; logout silently skips revocation).
In stateful/hybrid mode with Redis down, the /health/ endpoint reflects effective_mode: stateless_degraded and a CRITICAL log is emitted at startup.
The service operates under two stable states with a brief transient inconsistency regime between them:
| State | Condition | Authorization correctness |
|---|---|---|
| Healthy | Redis reachable | Full: JWT + allowlist + blacklist all consistent |
| Fully degraded | Redis unreachable | Deterministic: each control follows its declared fail_open / fail_closed mode |
| Transient | Partial Redis failure (some commands succeed, others fail within the same request) | Non-deterministic: rate-limit increment may fail while allowlist read succeeds; outcomes become request-order dependent |
The transient regime is observable — it does not enable a specific exploit, but authorization consistency is weakened until Redis returns to a stable state. Observable via:
auth_redis_circuit_breaker_opengauge →1means the circuit is open (full degradation)auth_degraded_decision_totalcounter → increments on every per-control degraded decision/health/circuit_breakerfield →"open"|"closed"
The asymmetric posture (refresh + session writes fail-closed; rate limit + access revocation fail-open) is intentional: the highest-value targets for an attacker (token replay, unrevoked sessions) are hard-rejected; availability controls are preserved.
API key rate limiting: when Redis is unavailable, strict deployments return 503 rather than admit a valid key with no rate-limit ceiling. Strict behaviour is inherited from any production/strict posture (ENVIRONMENT=production, STRICT_PRODUCTION_MODE=true, or AUTH_STRICT_MODE=true) and can also be forced anywhere with API_KEY_STRICT_RATE_LIMIT=true. Only non-production, non-strict development fails open, and that admission is logged as unsafe. Either path emits a degraded_decision_total{control="api_key_rate_limit"} sample; logs never contain the raw key.
All routes that touch the database return 503 Service Unavailable with a clear message.
GET {API_PREFIX}/health/{
"status": "ok",
"token_mode": "stateful",
"effective_mode": "stateful",
"redis": "ok",
"circuit_breaker": "closed",
"database": "ok",
"revocation_available": true,
"rate_limiting_available": true,
"degraded_since": null,
"degradation_modes": {
"rate_limit": "fail_open",
"refresh_validation": "fail_closed",
"session_write": "fail_closed",
"access_revocation": "fail_open"
}
}circuit_breaker is "open" when Redis is required but currently unavailable (requests are short-circuited), and "closed" when healthy or not required.
degradation_modes shows the effective per-control policy (respecting AUTH_STRICT_MODE). degraded_since is the UTC timestamp when Redis first became unreachable in the current process lifetime, or null when healthy.
| Mode | API_BIND_IP |
TLS | HSTS | Use when |
|---|---|---|---|---|
| Development | 0.0.0.0 |
mkcert (trusted) or self-signed | off | local machine, Docker dev loop |
| Private LAN / homelab | 0.0.0.0 or 127.0.0.1 |
local CA recommended | off | Raspberry Pi, NAS, private LAN |
| Public / production | 127.0.0.1 |
valid cert required | on (opt-in) | VPS, cloud, internet-facing |
Requires a coordinated three-layer setup:
- Traefik — add
forwardedHeaders.trustedIPsto each entrypoint intraefik.yml(strips client-suppliedX-Forwarded-For, prevents IP spoofing). - Uvicorn — the startup script reads
TRUSTED_PROXY_IPS(default172.16.0.0/12) and passes it via--proxy-headers --forwarded-allow-ips. Never use*. - Application —
_client_ip()reads the leftmostX-Forwarded-Forvalue, which is trustworthy only because layers 1 and 2 have been configured. SetTRUSTED_PROXY_COUNT=0inauth.envto bypass XFF entirely (no proxy in front of FastAPI).
Hardening headers are applied at two complementary layers:
-
Application layer (default) —
auth-sdk-m8 ≥ 1.2.1emitsX-Frame-OptionsandX-Content-Type-Optionsin every environment, addsReferrer-PolicyandPermissions-PolicywheneverENVIRONMENT=productionorSTRICT_PRODUCTION_MODE=true, and emits CSP/HSTS only when explicitly opted in (CONTENT_SECURITY_POLICY_ENABLED/HSTS_ENABLED, never onlocal). See Response Security Headers for the tiers and tunable settings. This works regardless of the proxy in front and is the recommended baseline. -
Edge layer (optional) — each example stack also ships two Traefik configs in
traefik/:File Purpose dynamic_conf.ymlDevelopment — no CSP, Swagger UI works production_dynamic_conf.ymlProduction — adds Content-Security-Policy: default-src 'none'; frame-ancestors 'none'and enables HSTS at the edge. Replacedynamic_conf.ymlwith this file when deploying publicly and update theHostrules to your FQDN.
To avoid duplicate headers, pick one layer as the source of truth: either keep the app-level
layer (set SECURITY_HEADERS_ENABLED=true, default) and leave the dev Traefik config in place,
or disable the app layer (SECURITY_HEADERS_ENABLED=false) and enforce headers at Traefik via
production_dynamic_conf.yml. Verify with curl -I after deployment.
At the edge, Strict-Transport-Security is commented out in all traefik/dynamic_conf.yml
files and enabled by default in production_dynamic_conf.yml. At the application layer it is
off by default and emitted only when HSTS_ENABLED=true (and HSTS_MAX_AGE > 0); it is
never emitted on a local stack even when enabled. Only activate HSTS after confirming TLS is
stable and the hostname will remain HTTPS-only for the full max-age period.
Security-critical settings and how they behave across the stack. All secret_fields reject the
literal changethis placeholder at startup — a service with any modeled secret still at its
placeholder fails closed immediately.
Boot-required conditions — the service refuses to start unless:
- Every
secret_fieldsvalue has been changed fromchangethis. EVENT_SIGNING_KEYis set whenEVENT_SIGNING_ENABLED=true(the default).TOKEN_ISSUERandTOKEN_AUDIENCEare set whenTOKEN_STRICT_VALIDATION=true(the default).ACCESS_PUBLIC_KEY_FILEorJWKS_URIis present for RS256/ES256.
| Setting | SDK default | Dev / local | hardened_m8 |
Production overlay |
|---|---|---|---|---|
ACCESS_TOKEN_ALGORITHM |
RS256 |
any | RS256 |
RS256 |
TOKEN_STRICT_VALIDATION |
true |
true |
true |
true |
TOKEN_ISSUER |
None |
optional | set in auth.env.example |
required (fatal if unset) |
TOKEN_AUDIENCE |
None |
optional | set in auth.env.example |
required (fatal if unset) |
EVENT_SIGNING_ENABLED |
true |
true |
true |
true |
EVENT_SIGNING_KEY |
None |
required — boot fails without it | non-changethis required |
non-changethis required |
ENVIRONMENT |
local |
local |
local |
production (overlay) |
STRICT_PRODUCTION_MODE |
false |
false |
false |
true (overlay) |
ALLOWED_HOSTS |
None |
no host check | None — Traefik Host() rules are primary |
required (strict fatal if unset) |
ALLOWED_ORIGINS |
["http://localhost:8080"] |
localhost | localhost | no localhost (prod fatal) |
SET_DOCS / SET_OPEN_API |
true |
on | on | off (gated in production) |
HSTS_ENABLED |
false |
never (local-blocked) | never | opt-in only (HSTS_ENABLED=true) |
CONTENT_SECURITY_POLICY_ENABLED |
false |
never (local-blocked) | never | opt-in only |
SESSION_COOKIE_SECURE |
false |
false |
false |
true (overlay) |
ALLOW_INTERNAL_HTTP |
false |
no check in local |
Docker-network-only | true opt-in (single trusted Docker host) |
AUTH_STRICT_MODE |
false |
false |
false |
optionally true for fail-closed Redis ops |
Dev-only stacks. The examples below are development/learning templates and must not be used as-is for production deployments:
| Stack | Why dev-only |
|---|---|
quickstart_m8 |
HS256, no hardening layer, loopback DB/Redis |
postgres_m8 |
No hardening layer, loopback DB |
rs256_m8 |
RS256 demo, no hardening layer |
metrics_m8 |
Observability demo, no hardening layer |
vault_dev_m8 |
HashiCorp Vault in dev mode with root token — never production |
hardened_m8 is the only stack with a production path — apply docker-compose.production.yml as
documented in Going to production. See
examples/docker_compose/SECURITY.md
for the full operational security runbook (trust model, route inventory, secret inventory, attacker
paths, production checklist, and incident response playbooks).
For the complete cross-layer table including fastapi-m8 consumer defaults, see the auth-sdk-m8 Defaults by layer section.
API keys are created by authenticated users and validated by consumer services via the GET /profile/api-keys/verify endpoint (or the get_current_api_key FastAPI dependency in the SDK).
- Created with
POST /profile/api-keys/— plaintext key returned once only, never stored. - Stored as a SHA-256 hash in the database alongside metadata (name, expiry, revocation flag).
last_used_atis updated via a Redis write-behind queue flushed every 60 seconds.- Revoked with
DELETE /profile/api-keys/{key_id}.
Superadmin authority over API keys is deliberately narrowed to list + revoke, never create/edit and never a secret read:
GET /api-keys/by-user/{user_id}/lists any user's keys as metadata only — id, label, derived status (active/revoked/expired),created_at/last_used_at, owner id, and the immutable access mode. It never returns the raw key (never stored) or the storedkey_hash; the response model structurally cannot carry either (secret non-exposure invariant). Being a read, it writes no audit row.POST /api-keys/revoke/{key_id}/revokes any user's key through the same authoritative revocation path as the owner's own revoke (delete-equivalent — the key is rejected on its next use). Because it mutates non-owned data, it writes exactly onedeleteprivileged-action audit row atomically with the revocation.
Deactivation-driven key revocation (a user going inactive revokes their keys in the same transaction) is unchanged. Every other /profile/api-keys/* route stays scoped to the caller's own keys (APIKEY-OWNER-01).
GET /security/audit-log (excluded from the OpenAPI schema, JWT-only, rate limited) is the read-only surface over the append-only privileged_action_audit table: a superadmin sees every row, an admin sees only rows it authored. No create/update endpoint exists for this table, and no per-row delete path exists anywhere — the sole removal path is the retention purge below.
POST /security/audit-log/purge (same JWT-only/schema-excluded/rate-limited posture, superadmin-only) bulk-deletes audit rows older than a chosen retention window — 1w/1m/3m/6m/1y — and rejects any window shorter than the configured minimum-retention floor (AUDIT_PURGE_MIN_RETENTION_SECONDS, default >= 90 days; lowering it is an explicit operator config change, never a per-call parameter). Deletes run in AUDIT_PURGE_BATCH_SIZE-row batches (FOR UPDATE SKIP LOCKED, mirroring the outbox worker) so a large purge never holds one long-lived lock over the table. The purge always writes its own delete maintenance audit row (actor, window, rows removed) — it is timestamped after the horizon it was computed from, so it always survives the purge that wrote it, mirroring the tombstone retention-horizon + guarded-cleanup pattern.
A key is an opaque pointer to its owner — it stores no role, so a request is authorized as the owner at the owner's current role and can never exceed it. Two additive, explicit-only fields fix a key's reach at issuance and are immutable afterwards (change requires issuing a replacement key):
access_mode(read_only|read_write) — an immutable operation-category cap (APIKEY-MODE-01), not a role. Defaults to the most restrictiveread_only; aread_writekey still only writes when its owner is currently authorized to write.audiences— the registered consumer ids allowed to introspect the key remotely (APIKEY-AUD-01). Each must be an enabled consumer explicitly granted theapi-key-introspectionscope, capped byAPI_KEY_MAX_AUDIENCES. Stored in the normalizedapi_key_audiencesrelation.
Effective downstream authority is the intersection owner's current role ∩ key access_mode ∩ matching audience ∩ route policy — each dimension only narrows. A key with no audience rows is issuer-local only: remote introspection answers active: false, so no legacy key silently becomes a cross-service credential. Audiences on existing (legacy) keys are set by the audited operator command python -m auth_user_service.scripts.bind_api_key_audiences --key-id <uuid> --actor <who> --reason <why> --audience <consumer-id> (audiences only; idempotent; refuses to change an already-bound set).
Revocation is delete-equivalent for authorization but not a delete: a revoked key's row persists (revoked = true), and an expired key's row persists unchanged. Nothing removes an ApiKey row except ON DELETE CASCADE from a deleted owner, so dead rows — and their api_key_audiences and per-key RateLimit children — accumulate indefinitely without an explicit purge.
POST /security/api-keys/purge (excluded from the OpenAPI schema, JWT-only, rate limited, superadmin-only) is that purge, mirroring the audit-log purge above: a key is dead when it is revoked (dated by updated_at, which stops advancing once revoked) or carries a non-null expires_at in the past (expires_at IS NULL never qualifies). Rows dead longer than a chosen window — 1w/1m/3m/6m/1y — are bulk-deleted; a window shorter than API_KEY_PURGE_MIN_RETENTION_SECONDS (default >= 90 days, a dedicated floor independent of the audit table's) is rejected with 400. Deletes run in API_KEY_PURGE_BATCH_SIZE-row FOR UPDATE SKIP LOCKED batches; deleting the parent row cascades api_key_audiences and RateLimit in the same operation — there is deliberately no audience-only delete path. The purge writes its own delete maintenance audit row (actor, window, rows removed), timestamped after the horizon so it survives the purge that wrote it. Its request/signature shape carries no key-id/owner-id parameter, so a targeted single-key purge is not expressible.
The per-user creation cap (API_KEY_MAX_PER_USER) excludes both revoked and expired keys, so it bounds only live credentials — an expired row no longer forces a manual revoke before a replacement can be issued. The purge, not the cap, is what bounds total rows.
Each key is checked against up to four fixed windows (MINUTE, HOUR, DAY, MONTH). Priority chain: per-key RateLimit rows → per-user defaults → API_KEY_DEFAULT_LIMIT_* settings.
Response headers on every API key request:
| Header | Description |
|---|---|
X-RateLimit-Limit |
Limit for the tightest (MINUTE) window |
X-RateLimit-Remaining |
Remaining requests in the MINUTE window |
X-RateLimit-Reset |
Unix timestamp when the MINUTE window resets |
Retry-After |
Seconds to wait (429 responses only) |
Endpoints under /user/private/ are for inter-service calls only:
- Must not be exposed to the public internet — enforce at the reverse proxy / Docker network level.
- Every request must present a per-consumer credential —
X-Internal-Client: <consumer-id>+X-Internal-Token: <consumer-secret>matching aPRIVATE_API_CONSUMERSentry (or a short-TTLAuthorization: Bearer <service-token>), and carry the scope the route requires. The legacy sharedX-Internal-Token: <PRIVATE_API_SECRET>gate was retired in v1.0.0.
| Method | Path | Description |
|---|---|---|
| POST | /private/users/ |
Create a user account (called by other microservices) |
| POST | /private/v1/jti-status |
Introspect an access-token JTI. A subject-bound v2 request ({jti, expected_user_id, schema_version: "2"}) gets the database-authoritative revocation decision in stateful mode — active carries {user_id, auth_generation}, every inactive cause is one generic {active: false}, and an unreachable database is 503 (never fail-open). A legacy {jti}-only request keeps the v1 {active} behaviour. |
| POST | /private/v1/api-keys/introspect |
Resolve a user API key to its canonical live owner principal (§3.12), for a consumer service that does not share the issuer database. Gated by the dedicated api-key-introspection scope (kept distinct from introspection so a JTI-status consumer is not implicitly granted key introspection). The raw key travels as a redacted SecretStr body (a client-generated hash is never accepted); the caller's internal credential stays on the private-route headers and determines the evaluated audience. An active result returns the owner's current role/superuser evidence ∩ the key's immutable access mode — never is_active, the key hash/id, or email; every unusable cause (unknown/revoked/expired key, missing/inactive/inconsistent owner, or an audience the key does not carry) returns one generic 200 {active: false} so it is not an account-state oracle. The key's functional quota is consumed with parity to local auth (429+Retry-After), plus a separate per-consumer anti-abuse ceiling; DB-unavailable / unknown schema version are 503 (never fail-open). No audience rows on a key ⇒ issuer-local use only ⇒ remote introspection answers inactive. |
| GET | /private/v1/events/stream |
SSE bridge — pushes session-revoked / user-deleted events to consumers (best-effort accelerator; jti-status remains the revocation authority) |
| POST | /private/v1/service-token |
Exchange bootstrap credential for a short-TTL service token (scoped to granted scopes) |
Each consumer gets a unique client ID and a strong secret. Generate one per consumer and build the JSON map:
# Generate a strong secret per consumer (32+ bytes, URL-safe base64).
python3 -c "import secrets; print(secrets.token_urlsafe(32))"The consumers map is a JSON object keyed by consumer ID. Scopes are deny-by-default: list only the scopes the consumer actually needs.
{
"media-service": {
"secret": "<generated-secret>",
"scopes": ["introspection", "event-stream"]
},
"worker-service": {
"secret": "<generated-secret>",
"scopes": ["user-create"]
}
}Store the map as a Docker secret and reference it via
PRIVATE_API_CONSUMERS_FILE (preferred) or inline via PRIVATE_API_CONSUMERS:
# docker-compose hardened: mount as a Docker secret
PRIVATE_API_CONSUMERS_FILE=/run/secrets/private_api_consumers.json# docker-compose.yml
secrets:
private_api_consumers:
file: ./secrets/private_api_consumers.json
services:
auth_user_service:
secrets:
- private_api_consumersEach consumer sets its own side of the credential — the client ID must
match a key in the issuer's PRIVATE_API_CONSUMERS map:
# consumer env (api.env / media.env)
INTERNAL_CLIENT_ID=media-service
PRIVATE_API_SECRET=<the secret for media-service in PRIVATE_API_CONSUMERS>Rolling rotation (no downtime) — only for a consumer that does not hold
api-key-introspection; see the warning below:
- Add a
<consumer-id>-newentry to the issuer'sPRIVATE_API_CONSUMERSwith the new secret and the same scopes. - Reload the issuer (signal
SIGHUPor restart). Both entries are now valid. - Update the consumer's
PRIVATE_API_SECRETto the new secret and restart the consumer. - Remove the old
<consumer-id>entry fromPRIVATE_API_CONSUMERSand reload the issuer again.
Never use the parallel-id recipe for an API-key introspection consumer. The consumer id is the evaluated audience (§3.12 step 8) and a key's audience set is immutable after issuance, so a consumer that starts presenting
<consumer-id>-newis a different audience: every key bound to<consumer-id>is answeredactive: false, which the consumer maps to a generic401 Invalid or expired API key. Nothing fails loudly — the credential is valid, the service is healthy, its JTI-status path keeps working, and only API-key-authenticated callers are locked out, looking exactly like a revoked key. Rotate such a consumer in place instead (same client id, new secret), accepting a short window:
- Write the new hashed secret onto the existing
<consumer-id>entry and reload the issuer.- Update the consumer's
PRIVATE_API_SECRETand restart it.Between the two steps the consumer's API-key routes fail closed with
503(never bare-key acceptance), and its audience bindings are untouched. If a genuinely zero-downtime rotation is required, it is a key rotation, not a credential rotation: issue replacement keys bound to the new audience id first, then migrate integrations, then retire the old id.
If the consumer uses the service-token exchange (/private/v1/service-token),
its short-TTL tokens expire within SERVICE_TOKEN_TTL_SECONDS (default 300 s)
after the bootstrap credential is revoked — no additional invalidation step is
needed.
A stolen short-TTL service token expires naturally within
SERVICE_TOKEN_TTL_SECONDS. To force-invalidate before expiry:
- Immediately rotate the consumer's bootstrap secret (see above) — the issuer will reject all new token exchanges from the compromised credential.
- Restart the affected consumer service so it re-exchanges for a new token.
- If the stolen token's
jtiis known, add it to the JTI blacklist via the internal session-revocation path (requires a superuser call toDELETE /sessions/delete/{session_id}/).
For a stolen bootstrap credential (X-Internal-Token), rotate immediately:
remove the old entry from PRIVATE_API_CONSUMERS, reload the issuer, then
add the new entry. The issuer rejects the old credential the moment the
registry is updated.
After any rotation or environment change, run the private API live suite to confirm the issuer accepts the new credential and rejects the old one:
# Inside the security-tests-m8 repo
LIVE_TEST_PRIVATE_API_CLIENT_ID=media-service \
LIVE_TEST_PRIVATE_API_SECRET=<new-secret> \
AUTH_BASE_URL=http://auth-service:8000 \
pytest tests/live/test_private_api_live.py -vThe suite covers:
- Bootstrap credential acceptance (
X-Internal-Client+X-Internal-Token). - Service-token exchange and scoped endpoint access.
- Legacy
X-Internal-Token-only shape rejected (noX-Internal-Client). - Revocation check via
POST /private/v1/jti-status.
Run this before any consumer sets API_KEY_INTROSPECTION_ENABLED=true, and
after the issuer is on 2.0.0 (the introspection endpoint must exist). Until it
is done, the private endpoint is unconsumed and inert; after it is done, a
consumer can resolve user API keys to their owners' live authority, so the
grant is the security boundary of the whole §3.12 remote path.
- It is deny-by-default and dedicated:
api-key-introspectionis never implied byintrospection. A JTI-status consumer that is not explicitly granted it is answered403 ForbiddenbyPOST /private/v1/api-keys/introspectwhile keeping full access toPOST /private/v1/jti-status. - The granted consumer id is the audience. The issuer derives the evaluated
audience from the authenticated consumer's registry identity — never from the
request body — and answers
active: falsefor any key not bound to it. - Only a consumer holding this scope may be named as a key audience. Key
issuance (
POST /profile/api-keys/) and the auditedbind_api_key_audiencescommand both reject an unknown or unscoped consumer id with409 Unknown or ineligible audience(s): …, so a key can never be bound to a service that was never approved to introspect it.
# One strong secret per consumer (never reused across consumers).
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# Convert it to the portable hashed-at-rest form the issuer stores.
python3 -c "
from auth_sdk_m8.security.consumer_auth import ConsumerCredential
print(ConsumerCredential.create('prompt-engine-m8', '<generated-secret>').encoded_secret)
"
# -> sha256$<salt_hex>$<digest_hex>Add or update the entry in PRIVATE_API_CONSUMERS (or the file referenced by
PRIVATE_API_CONSUMERS_FILE). Grant nothing a consumer does not use:
{
"prompt-engine-m8": {
"secret": "sha256$<salt_hex>$<digest_hex>",
"scopes": ["introspection", "event-stream", "api-key-introspection"]
},
"reporting-service": {
"secret": "sha256$<salt_hex>$<digest_hex>",
"scopes": ["introspection"]
}
}Reload the issuer (restart or SIGHUP) — the registry is read from
configuration, so nothing is provisioned in the database.
# consumer env
INTERNAL_CLIENT_ID=prompt-engine-m8 # must equal the registry key = the audience
PRIVATE_API_SECRET=<the plaintext secret hashed in step 1>
INTROSPECTION_URL=http://auth_user_service:8000/user/private/v1/jti-status
API_KEY_INTROSPECTION_ENABLED=true # URL is derived from INTROSPECTION_URLNo existing key is silently promoted into a cross-service credential: a key
with no audience rows is issuer-local only and is answered active: false
remotely. Bind at issuance ("audiences": ["prompt-engine-m8"] in the create
body, immutable afterwards) or, for a pre-existing key that carries none, run
the audited operator command:
python -m auth_user_service.scripts.bind_api_key_audiences \
--key-id <uuid> --actor <who> --reason <why> \
--audience prompt-engine-m8A missing scope is the one dimension a consumer cannot detect at startup (see the matrix below), so verify it explicitly from inside the network:
# Approved consumer: admitted -> 200 {"active": false} for an unknown key.
curl -s -o /dev/null -w '%{http_code}\n' \
-H "X-Internal-Client: prompt-engine-m8" -H "X-Internal-Token: $SECRET" \
-H 'Content-Type: application/json' \
-d '{"api_key":"ak_0000000000000000000000000000000","schema_version":"1"}' \
http://auth_user_service:8000/user/private/v1/api-keys/introspect
# A consumer holding only `introspection` must get 403 on the same call,
# while POST /private/v1/jti-status still answers 200 for it.Expected results — every one of these is a required outcome, not a nicety:
| Check | Expected |
|---|---|
| Approved consumer, unknown key | 200 {"active": false, "schema_version": "1"} |
| Approved consumer, key bound to it | 200 {"active": true, …, "audience_id": "<consumer-id>"} |
| Approved consumer, key bound to a different approved consumer | 200 {"active": false} |
| Approved consumer, key with no audience | 200 {"active": false} |
introspection-only consumer |
403 Forbidden (and 200 on jti-status) |
| Unknown consumer id, or wrong secret | 401 Unauthorized (identical for both — no enumeration oracle) |
| Service token minted without the scope | 403 Forbidden |
Unsupported schema_version |
503 (never a guess at an unknown contract) |
| Consumer route with the bound key | succeeds; the owner's live role is re-resolved on every call |
| Consumer route with an unbound/unknown key | generic 401 Invalid or expired API key |
| Missing piece | Caught at | Result |
|---|---|---|
INTERNAL_CLIENT_ID |
consumer startup | CONFIG: API_KEY_INTROSPECTION_ENABLED=true requires INTERNAL_CLIENT_ID |
PRIVATE_API_SECRET |
consumer startup | … requires PRIVATE_API_SECRET |
| both introspection URLs | consumer startup | … requires API_KEY_INTROSPECTION_URL |
unknown API_KEY_INTROSPECTION_SCHEMA_VERSION |
consumer startup | … is not a version this auth-sdk-m8 release implements |
| the scope grant itself | first request (runtime) | issuer 403 → consumer 503 API key verification unavailable. |
| the consumer's secret is stale (mid-rotation) | first request (runtime) | issuer 401 → consumer 503 |
Startup validation covers this consumer's own configuration; it cannot cover a
grant that lives in the issuer's registry, and the §3.12 client deliberately
performs no startup probe (introspection consumes quota and is never cached).
A missing or withdrawn grant therefore surfaces as 503 on the first
capability-bearing request — fail closed, never a fallback to bare key
validity — which is why step 5 is a required deployment step and not optional.
Ordinary service-token expiry does not produce this 503. A consumer
running SERVICE_TOKEN_EXCHANGE_ENABLED=true refreshes its cached service
token proactively, SERVICE_TOKEN_REFRESH_LEEWAY_SECONDS (default 30)
before the token's exp — before, not after, the next introspection call is
sent — so continuous traffic never presents this issuer with an actually
expired service token. The 401-then-503-then-re-mint sequence above is
reserved for the genuine edge cases: a withdrawn/rotated consumer secret, or a
request that races the refresh margin (latency or clock skew). Accepted as
operational behavior; no proactive change beyond the existing leeway is
planned (Phase 9, P9-9).
- Rotation: rotate an introspection consumer in place (same client id,
new secret). The parallel
<consumer-id>-newrecipe silently breaks every audience binding — see the warning under Rotating a consumer secret. - Revocation: remove the consumer's entry and reload. Its bootstrap
credential is refused (
401) immediately, and its id stops being an eligible audience, so no new key can be bound to it. Any short-TTL service token it already minted keeps working until it expires (SERVICE_TOKEN_TTL_SECONDS, default 300 s) — the token is signature-verified and scope-checked, not re-checked against the registry. Treat that window as the revocation latency for a compromised consumer; to close it immediately, rotatePRIVATE_API_SECRET(which signs those tokens) as well.
examples/fastapi_full and examples/fastapi_minimal are reference implementations showing how a downstream microservice integrates with auth_user_service using fastapi-m8 >=4.5.1,<5.0.0 and auth-sdk-m8. fastapi_full demonstrates DB session, health checks, auth deps, and lifespan teardown; fastapi_minimal is the minimal three-step setup. The 4.2.0 floor supplied the centralized reader-tier guard (get_current_active_reader) the full example's category surface is gated on; the floor now sits at 4.5.1, which is what carries auth-sdk-m8 >= 3.2.0 — the consumer half of the JWKS kid fix — into a consumer service transitively.
Both examples wire every JWT guard straight from the single build_auth_deps call — no route re-implements a role or is_superuser check:
fastapi_minimal(routes.py) demonstrates all four JWT levels:GET /hello/(any authenticated user),/hello/writer(WRITER+),/hello/admin(ADMIN+), and/hello/superuser(canonical superuser only).fastapi_full(app/routes/category.py) demonstrates the five-role capability surface on real owned data: reads (GET /category/,GET /category/get/{id}/) are gated at READER and writes (add/edit/delete) at WRITER, so a plain USER is denied everything and awriter → readerdowngrade is immediately observable. Non-superadmins see and mutate their own categories only; a canonical superuser reaches every user's, and each of those cross-owner mutations writes an audit row (below). Authorization failures answer the canonical403.fastapi_full(app/routes/audit.py) demonstrates the consumer-side privileged-action audit trail:GET /security/audit-logis ADMIN-gated and read-only,POST /security/audit-log/purgeis superadmin-only, and no other endpoint touches the table.fastapi_full(app/routes/api_key_category.py) demonstrates the remote API-key principal dependency (§3.12):POST /category/api-key/add/depends onget_current_api_key_writerand resolves the key's owner live againstauth_user_serviceon every call — capped at WRITER, never administrative or superuser. The route only exists when this deployment setsAPI_KEY_INTROSPECTION_ENABLED=true(plusINTERNAL_CLIENT_IDand an introspection URL, see.example_env);get_current_api_key_writerisNoneotherwise and the router is never built. Because §3.11 caps every key decision at WRITER, this route can never perform a cross-owner mutation and therefore never writes an audit row.
fastapi-m8 is the recommended consumer integration package — it wires CORS, health, lifespan, and auth dependencies in a few lines and bundles auth-sdk-m8 for JWT validation and shared schemas:
pip install fastapi-m8 --upgrade # minimal consumer
pip install "fastapi-m8[db,postgres,mysql]" --upgrade # with SQLModel + DB drivers (fastapi_full)fastapi_full treats the owner of a row as something the server resolves, never something a request body sets (app/ownership.py):
owner_idis not a request field.CategoryCreateandCategoryUpdateforbid unknown fields, so a body carryingowner_idis rejected with422instead of silently ignored, andcategory_update_values()strips every ownership key before an edit reaches the row.- An edit or a delete authorizes against — and preserves — the
owner_idalready persisted on the fetched row. No mutation path writes an ownership column. - A cross-owner create is superadmin-only and requires an explicit
target_owner_id. It never defaults to the actor: omitting it creates a row owned by the actor, supplying it creates a row owned by that exact user, and a refusal, an unknown target, or an issuer outage raises (403/404/503) rather than falling back to the actor's id. - Because the user table belongs to
auth_user_service,target_owner_idis confirmed over the issuer's HTTP contract (GET {AUTH_PREFIX}/users/get/{user_id}/, derived fromINTROSPECTION_URL) using the caller's own bearer token — never by reading the auth database. An API-key-authorized create always refuses a cross-owner target: §3.11 caps every key decision at WRITER.
The rules have their own unit suite (examples/fastapi_full/tests/), run by the example-tests CI job through examples/fastapi_full/pytest.ini.
fastapi_full mirrors the issuer's audit contract for the data it owns (app/audit.py, db_models/privileged_action_audit.py), so a service copied from this example inherits the same guarantees rather than having to invent them:
- Every superadmin mutation of a category it does not own is recorded, and only those: an
addon behalf of another user, anedit, or adeleteof someone else's row writes exactly oneapp_privileged_action_auditrow. Ordinary work on one's own data records nothing, and a refused mutation records nothing.actor_roleis snapshotted from the authenticated principal, never from client input. - The row and the mutation share one transaction. The recorder flushes into the route's session and never commits, so a category and its audit row commit or roll back together — an audited action cannot commit without its record. On a delete, the primary key and the owner are captured before the row is removed, so the record outlives what it describes.
- The table is write-once. No update path and no targeted single-row delete exists anywhere in the code, and the Expand migration installs
BEFORE UPDATE/BEFORE DELETEguard triggers on every certified dialect, so an ad-hocUPDATEor a targetedDELETEis rejected by the database itself — not merely absent from the API. - Read scope is decided server-side:
GET /security/audit-log(schema-excluded, JWT-only, ADMIN-gated) shows a superadmin every row and any other caller only the rows it authored, filtered against the authenticated principal's id. USER/READER/WRITER are denied403by the guard. In this example only a superadmin can mutate non-owned data, so an admin's own view is legitimately empty. - The only removal path is the retention purge.
POST /security/audit-log/purge(superadmin-only) bulk-deletes rows older than a chosen window —1w/1m/3m/6m/1y— and rejects with400any window belowAUDIT_PURGE_MIN_RETENTION_SECONDS(default >= 90 days; lowering it is an explicit operator config change). Deletes run inAUDIT_PURGE_BATCH_SIZE-rowFOR UPDATE SKIP LOCKEDbatches, and the purge writes its own maintenance row, timestamped after the horizon so it survives the purge that wrote it. The request body and the purge signature carry no row identifier, so a targeted purge is not expressible.
The audit table is created by the additive Expand migration chained onto each maintained compose example's m8_app head; it is a new table, so there is no Enforce step and no interaction with the issuer's migration sequence.
# core/deps.py
from fastapi_m8 import AuthDeps, build_auth_deps
from .config import settings
auth: AuthDeps = build_auth_deps(settings)
CurrentUser = auth.CurrentUser# main.py
from fastapi_m8 import AppLifecycle, create_app
from .core.deps import auth
app = create_app(
settings,
api_router,
service_name="my-service",
service_version="1.0.0",
lifecycle=AppLifecycle(auth_deps=auth),
)build_auth_deps reads ACCESS_TOKEN_ALGORITHM, ACCESS_SECRET_KEY / ACCESS_PUBLIC_KEY_FILE, TOKEN_ISSUER, TOKEN_AUDIENCE, JWKS_URI, TOKEN_MODE, INTROSPECTION_URL, and failure-mode settings directly from a ConsumerServiceSettings instance.
When JWKS_URI is set, build_auth_deps wires up JwksKeyResolver automatically. The resolver fetches /.well-known/jwks.json, caches keys by kid, and refreshes on cache miss — supporting zero-downtime key rotation.
ACCESS_TOKEN_ALGORITHM=RS256
JWKS_URI=http://auth-service/user/.well-known/jwks.json
JWKS_CACHE_TTL_SECONDS=300Consumer services check revocation via an HTTP call to the auth service private API —
auth Redis is never shared with consumers. Set INTROSPECTION_URL, INTERNAL_CLIENT_ID, and
PRIVATE_API_SECRET (the consumer's per-consumer bootstrap secret) when TOKEN_MODE=stateful. The
client id + secret must match an entry in the auth service PRIVATE_API_CONSUMERS map (the legacy
shared-secret gate was retired in v1.0.0):
INTROSPECTION_URL=http://auth_user_service:8000/user/private/v1/jti-status
INTERNAL_CLIENT_ID=example-api
PRIVATE_API_SECRET=<this consumer's secret, matching its PRIVATE_API_CONSUMERS entry>fastapi-m8's build_auth_deps wires RemoteRevocationClient automatically and honours
ACCESS_REVOCATION_FAILURE_MODE from the consumer's settings. The default is fail_closed
— any outage returns HTTP 503 rather than accepting a potentially-revoked token. Set
ACCESS_REVOCATION_FAILURE_MODE=fail_open in api.env to restore availability-first behaviour,
or AUTH_STRICT_MODE=true to force all failure-mode controls closed. For a legacy v1
({jti}-only) request the issuer's /private/v1/jti-status endpoint mirrors the same
setting: Redis-unavailable returns active=false when fail_closed is effective. The
subject-bound v2 decision is database-authoritative instead — the Redis blacklist is only
an accelerator, so a Redis outage falls back to the DB result, and only an unreachable
authoritative database returns 503; ACCESS_REVOCATION_FAILURE_MODE=fail_open is not
honoured for that generation decision.
auth-sdk-m8 ≥ 1.0.0 enables TOKEN_STRICT_VALIDATION by default. Set TOKEN_ISSUER and TOKEN_AUDIENCE to the same values in both the auth service and every consumer: the auth service embeds iss/aud claims in issued tokens and all validators require an exact match. Under the strict default the service fails closed at boot until both are set. Opt out for legacy/local deployments with TOKEN_STRICT_VALIDATION=false, which enforces iss/aud only when configured.
EVENT_SIGNING_KEY is required at boot (auth-sdk-m8 ≥ 1.0.0 secure-by-default, F3) unless EVENT_SIGNING_ENABLED=false. Set it in auth_user_service/.env and in every consumer stack's env file before starting any service. The dev placeholder (DEV-ONLY-do-not-use-event-signing-key-Aa1!) shipped in example env files must be replaced with a secrets-managed value in staging and production.
Auth Redis is sensitive and fa-auth-only. The session/JTI blacklist Redis instance is private to this service. Consumer services check revocation via the HTTP private API (POST /private/v1/jti-status) — they never connect to auth Redis directly. This boundary is already enforced architecturally (see Revocation check above).
The SSE bridge (GET /private/v1/events/stream) is live. Consumers that opt in to low-latency cache eviction can connect to the stream using AuthEventStreamClient from auth-sdk-m8 ≥ 1.2.0 — it reconnects with jitter, replays buffered events via Last-Event-ID, verifies each payload's HMAC signature, and calls on_gap() so the consumer can flush its local caches when the buffer is unresumable. The bridge is a best-effort accelerator; POST /private/v1/jti-status remains the revocation authority. Consumers that do not connect continue to work correctly.
cd auth_user_service
pip install -r requirements_base.txt -r requirements_dev.txt
uvicorn auth_user_service.main:app --host 0.0.0.0 --port 8000 --reloadSet VSCODE_DEBUG=true in the container environment. The startup script launches debugpy on port 5678 and waits for the debugger to attach before starting Uvicorn.
Migrations are applied automatically on container start. To run manually:
alembic -c auth_user_service/alembic.ini revision --autogenerate -m "description"
alembic -c auth_user_service/alembic.ini upgrade headMySQL prerequisite — trigger creation under binary logging. The
privileged-action audit table's write-once guard is a trigger. MySQL 8 enables
binary logging by default, and a user without SUPER cannot then create a
trigger unless log_bin_trust_function_creators is on (error 1419), so
alembic upgrade head fails for an ordinary application user on a stock MySQL 8
server. Before migrating a MySQL deployment, either set
SET GLOBAL log_bin_trust_function_creators = 1; -- or set it in my.cnfor run the migration as a user holding SUPER. PostgreSQL and MariaDB are
unaffected — MariaDB ships with binary logging off. The Layer B matrix applies
the same prerequisite to its ephemeral MySQL target, so the certified migration
path is the one a correctly provisioned deployment runs.
Upgrading an existing 1.x database to 2.0 is not a rolling upgrade. The
role/flag equivalence CHECK and the final NOT NULL on
auth_client_session.auth_generation cannot be applied while an old issuer can
still create sessions or write roles, so the whole cutover runs inside one
maintenance window with no old login, refresh, role-update, deactivation, or
deletion process running between the repair and the new startup.
Each command below runs from the final 2.0 image against the target database. Steps are ordered; none may be skipped or reordered.
- Confirm the declared dialect matches the real engine. 2.0 verifies
SELECTED_DBagainst the connected server at startup and refuses to boot on a mismatch, so an unnoticed mismatch fails after Enforce — the worst point in the window. Every MariaDB deployment still declaringSELECTED_DB=Mysqlmust be changed toSELECTED_DB=Mariadbfirst; the value is never rewritten automatically (see Choosing a Database). - MySQL only — grant trigger creation. Enable
log_bin_trust_function_creators(or migrate as aSUPERuser), or the audit-table Expand fails with error 1419 inside the window (above). - Build/pull the final 2.0 image; it carries every migration and both operator CLIs.
# 1. Pre-migration backup — the only supported rollback once Enforce applies.
pg_dump -Fc -d "$AUTH_DB" -f auth_db.pre_cutover.dump
# 2. Stop every old issuer writer (scale to zero / maintenance mode).
# Write quiescence begins here.
docker compose stop auth_user_service fastapi_full
# 3. Expand — additive only, no CHECK, no final NOT NULL.
alembic -c /opt/auth_user_service/alembic.ini upgrade 50c9101b580d
# Consumer stacks running examples/fastapi_full also apply the example's
# additive audit-table Expand against their own database:
alembic -c /opt/fastapi_full/alembic.ini upgrade head
# 4. Preflight, then repair every mismatch explicitly. Exit 1 means "stop".
python -m auth_user_service.scripts.security_preflight
python -m auth_user_service.scripts.security_repair \
--user-id <uuid> --intended-role <role> --actor <who> --reason <why>
python -m auth_user_service.scripts.security_preflight # must now exit 0
# 5. Revoke every legacy session — a one-time global logout, never a backfill.
python -m auth_user_service.scripts.legacy_session_revocation \
--confirm REVOKE-ALL-LEGACY-SESSIONS --actor <who> --reason <why>
# 6. Enforce (CHECK + final NOT NULL), then the issuer audit-table Expand,
# which is chained after Enforce in the revision graph.
alembic -c /opt/auth_user_service/alembic.ini upgrade head
# 7. Start 2.0.0. The outbox worker is an in-process task of the issuer, not a
# separate container; it drains the effects the repair enqueued.
docker compose up -d auth_user_service- Confirm at least one active canonical superuser remains
(
role = 'SUPERADMIN' AND is_superuser AND is_active). - Confirm the outbox drain loop is actually running —
OUTBOX_WORKER_ENABLED(defaulttrue) andOUTBOX_DRAIN_INTERVAL_SECONDS— and that everyauth_revocation_outboxrow the repair enqueued has reachedcompleted. - Keep remote API-key consumers disabled until the
API_KEY_INTROSPECTIONscope is provisioned and proven; an unconsumed private endpoint is inert.
Expected user impact. Every pre-existing access and refresh session is deleted at step 5, so all users sign in again after cutover. This is deliberate: backfilling a generation onto a legacy session could bless an old token carrying a stale role.
Rollback. Aborting before Enforce is safe — Expand is additive and the old issuer ignores it. Once Enforce has applied, rollback below 2.0 is unsupported: incident response is a forward fix on the 2.0 line, or a full restore of the step-1 backup, which restores the pre-cutover session state as one consistent unit. Revocation is never rolled back for availability.
ruff format .
ruff check .
ruff check . --fixmypy auth_user_service --ignore-missing-imports
mypy examples/fastapi_full --ignore-missing-importsbandit -r auth_user_service examples/fastapi_full --severity-level mediumThe suite has three layers with distinct ownership; none substitutes for another.
| Layer | Suite | Needs | Owns |
|---|---|---|---|
| A — unit | tests/ (default run) |
nothing (no Docker, no database) | the 100% coverage gate; portable ORM/service/route behaviour, using in-memory SQLite strictly as a surrogate |
| B — database integration | tests/integration/database/ |
one ephemeral PostgreSQL, MySQL, or MariaDB container | migrations, engine-enforced constraints and enums, FOR UPDATE / SKIP LOCKED, cascades, and real-connection concurrency |
| C — live security | tests/live/ (and security-tests-m8) |
a running Compose stack | black-box adversarial behaviour |
100% Python coverage, database-dialect correctness, and end-to-end security
correctness are three different guarantees. Layer A can never certify a
migration, a lock, or a trigger — SQLite has no such semantics — so Layer B is
the authoritative portability evidence, and it runs on every database-sensitive
pull request plus main/nightly/release. See
tests/integration/database/README.md.
# Layer B — one certified dialect at a time (starts a disposable container)
pytest -m database_integration tests/integration/database --database=postgresql --no-cov
pytest -m database_integration tests/integration/database --database=mysql --no-cov
pytest -m database_integration tests/integration/database --database=mariadb --no-cov# Unit + integration tests (default — no live stack required)
pytest
# All live tests against a running stack
pytest -m live --no-cov
# Target a specific algorithm or token mode
pytest tests/live/test_security_universal.py --no-cov # any stack
pytest -m live_asymmetric --no-cov # RS256 / ES256 stacks
pytest -m live_hs256 --no-cov # HS256 stacks
pytest -m live_stateful --no-cov # TOKEN_MODE=stateful
pytest -m live_hybrid --no-cov # TOKEN_MODE=hybrid
pytest -m live_stateless --no-cov # TOKEN_MODE=statelessThe live suite is modular — each file carries a require_algorithm / require_token_mode mark so tests are automatically skipped when the running stack does not match. conftest.py auto-detects the stack's algorithm, token mode, and Redis availability at session start. Tests decorated with require_redis, as well as all live_stateful and live_hybrid tests, are automatically skipped when the /health/ endpoint reports redis=unavailable.
The defaults describe the maintained Compose examples (issuer on /user, the
bundled fastapi_full consumer on /fastapi, both behind
http://localhost:9000 and https://localhost:4430). Point the suite at any
other fa-auth-m8 + fastapi-m8 deployment with configuration alone — the same
way examples/docker_compose/shared_live_tests
is retargeted — with no source edit:
| Variable | Default | Selects |
|---|---|---|
LIVE_AUTH_BASE |
http://localhost:9000/user |
the issuer's base URL |
LIVE_SVC_BASE |
http://localhost:9000/fastapi |
the downstream consumer's base URL |
LIVE_SVC_PROTECTED_PATH |
/category/ |
an authenticated consumer list route with no path parameter |
LIVE_ADMIN_EMAIL / LIVE_ADMIN_PASSWORD |
the examples' seeded FIRST_SUPERUSER |
the account the live fixtures authenticate with |
LIVE_PUBLIC_BASE / LIVE_INTERNAL_BASE |
https://localhost:4430 / http://localhost:9000 |
the exposure matrix's public and internal entryPoints |
LIVE_AUTH_PREFIX / LIVE_SVC_PREFIX |
/user / /fastapi |
the exposure matrix's route prefixes |
LIVE_PRIVATE_API_SECRET |
unset (positive control skipped) | the inter-service secret for the jti-status positive control |
LIVE_PRIVATE_API_CLIENT_ID |
unset (legacy single-secret shape) | the X-Internal-Client consumer id a per-consumer issuer requires |
LIVE_PRIVATE_API_SECRET is deliberately distinct from PRIVATE_API_SECRET:
the suite's root conftest.py seeds the latter with a hermetic throwaway before
any import, so a positive control keyed on it would send a wrong secret to the
live stack instead of skipping.
| Module | Mark | Covers |
|---|---|---|
test_security_universal.py |
live_security |
13 attack categories (A–M): brute-force, JWT forgery, IDOR, rate-limit bypass, CORS, private API exposure, file upload, info disclosure, HTTP headers, cookie security, API key abuse |
test_asymmetric.py |
live_asymmetric |
alg=none confusion, JWKS exposure, attacker-generated key — RS256 / ES256 only |
test_hs256.py |
live_hs256 |
HS256-specific attacks |
test_stateful.py |
live_stateful |
Token revocation, session-chain invalidation |
test_role_downgrade_gate.py |
live_stateful |
The end-to-end writer→reader downgrade gate: a WRITER write, the role change with its auth_generation + revocation_enqueued response, revocation of the already-issued token, and the fresh reader token's read-only ceiling — driven through the bundled fastapi_full consumer |
test_hybrid.py |
live_hybrid |
Partial-Redis degraded mode behaviour |
test_stateless.py |
live_stateless |
No-Redis guarantees |
test_role_downgrade_gate.py requires the consumer to run stateful and
fail-closed (TOKEN_MODE=stateful, AUTH_SERVICE_ROLE=consumer, revocation
client plus event-stream bridge enabled — the default of every maintained
Compose example). In hybrid or stateless mode the issuer never revokes an
already-issued access token, so the old token stays usable until it expires;
the module is skipped on such a stack rather than reporting a false failure.
It also drives one concrete consumer contract — the WRITER-gated category
routes of examples/fastapi_full — so it is skipped, with that reason, when
LIVE_SVC_BASE points at a consumer that does not serve them.
The tests/security/ unit suite (no live stack required) covers JWT security, Redis resilience, refresh lifecycle, refresh key-rotation fallback (REFRESH_SECRET_KEY_OLD), input sanitisation, JWKS endpoint, OAuth adversarial, iss/aud validation, session-chain invalidation, exception handling, and client IP attribution.
Enabled with METRICS_ENABLED=true. The metric prefix is derived from API_PREFIX (e.g. /user → user_).
| Group | Metric | Type | Labels |
|---|---|---|---|
| traffic | {prefix}http_requests_total |
Counter | method, endpoint, status_code |
| performance | {prefix}http_request_duration_seconds |
Histogram | method, endpoint |
| reliability | {prefix}http_errors_total |
Counter | method, endpoint, status_class |
| health | {prefix}http_status_total |
Counter | status_code |
| auth | {prefix}auth_login_attempts_total |
Counter | result: success | wrong_credentials | inactive_user | rate_limited |
| auth | {prefix}auth_token_refresh_total |
Counter | result: success | invalid | revoked | rate_limited |
| auth | {prefix}auth_logout_total |
Counter | — |
| auth | {prefix}auth_token_validation_failures_total |
Counter | reason: invalid | revoked | inactive |
| auth | {prefix}auth_oauth_attempts_total |
Counter | provider, result: success | failed |
| auth | {prefix}auth_revocation_failure_total |
Counter | operation: access_blacklist | refresh_allowlist | db_session |
| auth | {prefix}auth_degraded_decision_total |
Counter | control: rate_limit | refresh_validation | session_write | access_revocation; mode: fail_open | fail_closed; reason: redis_unavailable | revocation_failed |
| auth | {prefix}auth_redis_circuit_breaker_open |
Gauge | 1 = Redis unavailable (circuit open), 0 = Redis healthy (circuit closed) |
| auth | {prefix}auth_degradation_mode_active |
Gauge | control × mode label pair; value always 1 for active mode; set at startup |
| auth | {prefix}auth_session_integrity_denial_total |
Counter | trigger: reuse_detected |
| auth | {prefix}auth_api_key_validations_total |
Counter | result: success | invalid | revoked | expired |
| auth | {prefix}auth_api_key_rate_limit_checks_total |
Counter | result: checked | allowed | blocked |
| auth | {prefix}auth_api_key_rate_limit_hits_total |
Counter | period: minute | hour | day | month |
| auth | {prefix}auth_api_key_lifecycle_total |
Counter | action: created | revoked |
| auth | {prefix}auth_api_key_flush_duration_seconds |
Histogram | — |
Alert rules for metrics_m8 and vault_dev_m8 stacks (prometheus/alerts.yml):
ApiKeyBlockRatioHigh— hits/checks > 10% over 5 minApiKeyRateLimitInvariantViolation— hits > checks × 1.1 (instrumentation sanity guard)ApiKeyFlushLatencyHigh— p99 flush latency > 500 msApiKeyHighInvalidRate— > 1 invalid/revoked/expired key/s over 5 min
Stack: Python 3.14 · FastAPI · SQLModel · Redis · MariaDB / PostgreSQL 16 · Traefik · Docker Compose · Prometheus · Grafana
- FastAPI
- SQLModel + Alembic
- fastapi-m8 — consumer service integration (CORS, health, auth deps, lifespan); consumer services install this
- auth-sdk-m8 — shared schemas, JWT validation, refresh token rotation, JWKS resolver, base controllers; bundled by
fastapi-m8, also used directly by the auth service - Redis — session revocation, refresh token allowlist, rate limiting, PKCE store, write-behind queue
- PyJWT + passlib + cryptography
- google-auth — Google OAuth2
Release (non-development) Docker images do not resolve the loose lower-bound
ranges in requirements_base.txt / requirements_prod.txt at build time. They
install from auth_user_service/requirements_prod.lock — a fully pinned,
sha256-hashed lock — with pip install --require-hashes. This guarantees that
rebuilding the same source cannot silently pull a different dependency graph, and
that the published SBOM describes exactly what shipped.
- The loose ranges remain the source of truth for what the service depends on.
- The lock is the pinned, hashed resolution of those ranges for release images.
- All packages (including the internal
auth-sdk-m8) resolve from public PyPI only — the lock carries no custom index URL.
Regenerate the lock whenever a range in requirements_base.txt or
requirements_prod.txt changes, then re-run the audit gate:
cd auth_user_service
pip-compile --generate-hashes --no-emit-index-url \
--output-file=requirements_prod.lock requirements_prod.txt
pip-audit -r requirements_prod.lock # must report no known vulnerabilitiesThe lock, the Dockerfile --require-hashes install, and the
"SBOM reflects the locked environment" invariant are enforced by
tests/security/test_dependency_lock.py.
Apache2 © Eli Serra