This guide states the threat model, the token and key design, the sender constraint model, the fail-closed policy, and the admin replacement procedure. The implementation is the authority; the citations point at it.
For a security reviewer and an operator: what the server protects, how, and what
to do when something goes wrong. The standards behind the design are RFC 9700
(BCP 240) and draft-ietf-oauth-v2-1-16 (published 3 September 2026; an
Internet-Draft, not an RFC, at
https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1).
trusted_proxy; otherwise they are stripped before any handler
reads them.This section records the container and host attack surface: how an attacker could reach the container or the host through the image, the controls already in place, and the residual risk. It is durable threat-model material, not a backlog; update it whenever the deployment surface changes (a new listener, mount, dependency class, or runtime setting).
| Vector | Existing controls | Residual risk |
|---|---|---|
Application-level RCE in a parser (form bodies, DPoP/JAR/private_key_jwt JWTs, DCR JSON, jwks_uri/request_uri fetches) |
#![forbid(unsafe_code)], no unwrap/panic in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims |
Fuzzing is not exhaustive. |
| Dependency / supply-chain compromise | Committed Cargo.lock, cargo deny/cargo audit in CI, SBOM + provenance + keyless cosign, digest-pinned bases |
A zero-day in a trusted crate. |
| Container escape via kernel or runtime bug | Non-root UID 65532, read-only root filesystem, all capabilities dropped, allowPrivilegeEscalation: false, seccompProfile: RuntimeDefault |
A kernel LPE or runc bug, mitigated but not eliminated. |
| Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP key live outside the database; the Deployment mounts the pepper and TOTP key as 0400 tmpfs files under /run/secrets and reads them through D_OAUTH_TOKEN_PEPPER_FILE / D_OAUTH_TOTP_ENCRYPTION_KEY_FILE, so no key material enters the environment |
database-url and redis-url are still secretKeyRef environment values; an attacker with code execution reads those. |
| Exposed management surface | The plaintext health listener (8081) is unauthenticated but not published in the Service (only 8080 is) | A misconfigured Service/Ingress could expose it; it must never be internet-facing. |
| Host access via mounted volumes | /certs is mounted read-only at mode 0400; no Docker socket is mounted |
Operator misconfiguration. |
| Unrestricted pod egress (SSRF to metadata, exfiltration) | networkpolicy.yaml default-denies egress except DNS (53 to kube-system) and Postgres/Redis (5432/6379), with link-local 169.254.0.0/16 excluded; ingress is limited to pod ports 8080/8081 |
Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. |
| Kubernetes API access from the pod | automountServiceAccountToken: false; the binary never calls the API |
A future feature that needs the API would have to re-enable it deliberately. |
| Image/registry compromise | Digest pinning, cosign verification, and a committed digest reference with no shippable :latest; CI writes the signed build digest into kustomization.yaml |
A registry compromise before the digest is recorded still ships; the pin step must run. |
Last reviewed: 2026-10-04. File-mounted key material, digest pinning,
NetworkPolicy, and automountServiceAccountToken: false are implemented in
the shipped manifests; the remaining mitigations are tracked in docs/TODO.md.
#![forbid(unsafe_code)] across the crate.unwrap, expect, and panic are denied by lint in request paths./.well-known/… metadata carries
Cache-Control: no-store (+ Pragma: no-cache for legacy clients). An
authorization page, consent page, token body, request handle, or error is
therefore never reused by a browser, the back/forward cache, or a shared
intermediary.GET /login and GET /consent pages (which carry a
session-bound CSRF token and the bound request_id), the /authorize
redirects and error responses, the token endpoint (success and error), PAR,
device authorization, DCR, introspection, and revocation.Cache-Control: no-store and
X-Content-Type-Options: nosniff on each probe response itself. A regression
test pins both headers so a route added to that listener cannot silently drop
the policy.Cache-Control: public, max-age=3600 (not browser heuristic
freshness). A metadata handler that fails closed returns a 503 error, which
is itself no-store.rotation_request_id derived from the old refresh token hash and the pepper.used and rotated_to, kept for the
refresh lifetime.token.refresh_reuse_detected.See REDIS.md for the barrier modes.
Reference: RFC 9449 (DPoP), RFC 8705 (mTLS).
Every sender-constrained grant requires a freshly verified proof on every use.
| Client class | Required binding |
|---|---|
| Public | DPoP only. A missing proof fails with invalid_grant. |
| Confidential | mTLS and/or DPoP. The confirmation records each proven component. |
cnf propagates to every newly minted token.cnf alone never mints: the proof is re-verified per use.private_key_jwt is client authentication only. It produces no confirmation
and never satisfies sender constraint. This is deliberate.tls_client_auth) or a
certificate thumbprint (self_signed_tls_client_auth), each unique per the
partial indexes in the schema.typ, or has a bad or replayed jti, htm, htu,
iat, or ath.Reference: RFC 9126, RFC 9101.
/authorize must use a PAR request_uri or a signed
request object. This is mandatory, not optional.none and symmetric algorithms are
rejected. iss must equal client_id, aud must be the issuer, and conflicts
with the query string are rejected.request_uri is one-time and client-bound, or exactly pre-registered, and
is fetched only through the SSRF guard (https only, no redirects, global IPs
only, size and time capped).KeyRing holds the token-pepper chain and the TOTP-encryption
chain, newest first.Debug prints key ids only, never key material.jwks_uri. The
keygen command emits keys for test fixtures and client-side DPoP only.The seven security endpoints (authorize, token, introspect, revoke,
par, device_authorization, register) are always fail-closed. An attempt to
configure one as fail-open is a load-time error.
503 temporarily_unavailable with
Retry-After: 1, never a fail-open success.503 while a dependency is down; liveness stays 200
because it touches no dependency.503 rather than an empty list.request_uri is consumed atomically.private_key_jwt jti values are recorded with SET NX, so a
replayed proof or assertion is rejected.S256 only. A verifier cannot be omitted to downgrade.requested ∩ client-registered ∩ user-entitled ∩
enabled, and refresh can only narrow.frame-ancestors 'none'. They use server-side sessions, CSRF protection, and
no open redirect.GET /logout is a server-specific session endpoint (not OAuth 2.1/OIDC
defined). It destroys the session and clears the cookie, then renders a
neutral page or redirects to a post_logout URI. It accepts no query
parameters: the target is read only from session meta, frozen when the
authorization flow was bound, and used only if the owning client is still
active and still registers that exact URI (exact-string match + RFC 8252
loopback). A forced navigation to /logout can therefore only end a session
and, at most, send the browser to that client's own pre-registered URI — never
to an attacker target. There is no post_logout_redirect_uri parameter and no
id_token_hint.No default admin credential ships. migrate seeds zero users and serve never
creates one. The first admin is created by admin bootstrap, which runs inside a
single transaction under an advisory lock, so two concurrent bootstraps cannot
both create an admin. Once any enabled admin exists, a second bootstrap refuses
unless --force is given, which prints a loud warning and emits
admin.bootstrap.force.
The bootstrap admin starts with must_change_password = true and
totp_enabled = false. Its first login yields a restricted session that can only
change the password and enroll TOTP; /authorize, the OAuth endpoints, and
admin-gated actions deny it until both complete.
Replace it immediately:
must_change_password and stamps
password_changed_at.d-oauth-server user create --admin --email <name>@example.com.d-oauth-server user disable --email <bootstrap>@example.com.The step-by-step command runbook lives in RUNBOOK.md. The split is deliberate:
RUNBOOK.md is the task-47 operator procedure, and this section is the task-48
security rationale and checklist.
token.refresh_reuse_detected means a rotated refresh token was replayed.
Treat the grant family as compromised.login.failure for one subject means a credential-stuffing attempt.admin.bootstrap.force means an admin was created despite an existing one.503 on security endpoints means a dependency is down; the server
is failing closed, not open.X-Request-Id header correlates a request to its logs;
audit events never contain a secret.A full dump is worse than one leaked token: it exposes the whole directory and every hash or ciphertext at once. The token pepper and the TOTP encryption key are supplied outside the database (environment variable or mounted file), so a pure dump does not carry them; treat them as compromised anyway unless the key store can be shown untouched.
| Dumped class | Protection at rest | What the dump yields |
|---|---|---|
| User passwords | Argon2id PHC, salted | Offline cracking only. |
| Client secrets | Argon2id PHC | Offline cracking only. |
| TOTP secrets | AES-256-GCM under HKDF-SHA256 (info totp-gcm-v2; pre-migration v1 blobs under bare SHA256(totp_key) still decrypt) |
Readable only if the TOTP key also leaks. |
| TOTP recovery codes | Argon2id PHC | Offline cracking only. |
| DCR registration access tokens | Keyed HMAC (registration_access_token_hash) |
Opaque while the pepper stays outside the dump. |
| Emails, client names, contacts, redirect URIs, scopes, entitlements | Plaintext | Full disclosure. |
Client public keys (jwks) |
Plaintext | Public by design. |
tls_client_auth_subject_dn and certificate thumbprints |
Plaintext | Identifier disclosure. |
is_admin, disabled, must_change_password, timestamps |
Plaintext | Metadata disclosure. |
If the key store leaked too, the dump is a full compromise: TOTP secrets decrypt, the Argon2id hashes are the only barrier left on passwords and client secrets, and registration-access-token hashes can be recomputed. Respond in the order below.
Rotate in this order. Each step retires a class of credential the dump already exposed; do not skip ahead because a later step looks cheaper.
user enroll-totp before the old key is
dropped, or the user cannot complete TOTP.PUT /register/{client_id}, which mints a fresh secret when the
method is secret-based and returns it once.PUT /register/{client_id} call
rotates the registration access token on every update, and the response
carries the only copy. Do not re-issue DCR tokens by replaying an
initial-access token; it is single-use and consumed at registration.The seven security endpoints stay fail-closed throughout. A Postgres or Redis
outage while responding returns 503 temporarily_unavailable with
Retry-After: 1, never a fail-open success; do not relax the policy to keep
service up mid-rotation. The operator checklist with concrete commands is in
RUNBOOK.md.
trusted_proxy as narrow as possible.src/crypto/ for token minting, hashing, TOTP, and constant-time compare.src/keys.rs for the key ring.src/protocol/token/types.rs for the sender-constraint matrix.src/jose/dpop.rs, src/jose/jar.rs,
src/protocol/client_auth/assertion.rs for proof verification.src/protocol/mtls.rs for mTLS matching.src/store/mod.rs, src/store/tokens.rs for rotation and tombstones.src/config/policy.rs for the fail-closed policy.src/middleware/ for redaction, rate limiting, and security headers.src/cli/admin.rs for bootstrap.RUNBOOK.md for the operator runbook (admin bootstrap, full-database-disclosure response).