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).
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/PII keys live outside the database; the Deployment mounts every secret — pepper, TOTP key, PII key, and the database/Redis passwords — as 0400 tmpfs files under /run/secrets (read through their *_FILE variables), and the passwordless DSNs come from the ConfigMap, so no secret enters the environment |
An attacker with code execution can still read the mounted secret files under /run/secrets; that is the same capability the server has. |
| 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) scoped to the namespace hosting them — no internet-wide 0.0.0.0/0 peer; any ipBlock peer excludes 169.254.0.0/16; ingress is limited to pod ports 8080/8081 |
Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. A managed database outside the cluster needs a specific CIDR or a CNI FQDN policy. The default-deny also blocks the JAR request_uri HTTPS fetch — remote request objects are unsupported unless the deployment adds a scoped allowance (see docs/DEPLOYMENT.md). |
Operator exec into a running pod |
No shell and a non-root, read-only-rootfs container limit what runs; every secret is a file the container user already reads, and no secret is in the environment | pods/exec (and docker exec) bypass the application's own authorization; restrict it with RBAC and audit it (docs/OPERATIONS.md). |
| 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-05. File-mounted DB/Redis passwords, digest pinning,
NetworkPolicy, automountServiceAccountToken: false, and scheduled fuzzing are
implemented in the shipped manifests and workflows.
#![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. Its exp may lie
at most 300 s past now (plus clock skew), and the jti replay marker is
held for the assertion's full remaining validity, so a long-lived assertion
cannot be replayed once its marker expires. RS256, PS256, ES256, and
EdDSA are accepted; none and HMAC are rejected.tls_client_auth) or a
certificate thumbprint (self_signed_tls_client_auth), each unique per the
partial indexes in the schema.client_id, and only while inside its validity window. A
cross-wired or expired certificate is invalid_client and never binds a
cnf.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). The connection is pinned to the addresses
validated before the fetch, so a rebinding hostname cannot redirect it to a
blocked address.KeyRing holds three independent chains, newest first: the
token-pepper chain, the TOTP-encryption chain, and the PII field-encryption
chain. Each purpose also has an optional retained previous key._enc
PII blob carry their own key id, and the blind index carries its key id._enc value and recomputes every blind
index (pii rotate); rotating the TOTP key re-wraps the per-row DEKs
(totp rewrap). See
OPERATIONS.md.Debug prints key ids only, never key material.jwks_uri. The
keygen command emits keys for test fixtures and client-side DPoP only.Reference: AES-256-GCM (ring), HKDF-SHA256, HMAC-SHA256.
src/crypto/field.rs). The key is derived from the PII master key with
HKDF-SHA256 (info pii-field-gcm-v1), never from the TOTP key.{kid}:{hex}, info pii-blind-index-v1) is stored
for the columns that are looked up or must stay unique: email_index, the
two mTLS indexes, and uri_index. Encrypted bytes cannot be compared or made
unique directly, so the index is what enforces uniqueness and equality.totp-kek-v1). The data AAD excludes
the key id, so a DEK can be re-wrapped under a new key without re-encrypting
the secret. Versions 1 and 2 still decrypt.AppError::PiiDecrypt
(temporarily_unavailable, 503) and emits the pii.decrypt_failure audit
event with the request id. No value or plaintext is written to the event.KeyRing
(src/keys.rs), loaded once at startup from configuration. Nothing re-reads
or fetches them later.token_pepper, totp_encryption_key,
and pii_encryption_key, or the mounted-file forms token_pepper_file,
totp_encryption_key_file, and pii_encryption_key_file. When both are set
the inline value wins (resolve_secret in src/config/build.rs). The key ids
default to v1 through token_pepper_id / totp_key_id / pii_key_id
(src/config/raw.rs). Each purpose may retain one previous key
(*_previous_key / *_previous_key_file) with an explicit, distinct
*_previous_key_id; a previous key without its active key is a config error.0400 on a tmpfs volume at /run/secrets, and
the loader reads them through D_OAUTH_TOKEN_PEPPER_FILE,
D_OAUTH_TOTP_ENCRYPTION_KEY_FILE, and D_OAUTH_PII_ENCRYPTION_KEY_FILE
(deploy/k8s/secret.yaml, deploy/k8s/configmap.yaml). The ConfigMap carries
only non-secret settings; the previous keys stay optional and are never
required in a workload.Custody, in order of preference:
0400 tmpfs file that the server
reads through its *_FILE key. This is what the shipped manifests expect.Custody and rotation interact. Retain the old pepper key id for at least the
refresh lifetime, or live tokens stop verifying; retain the old TOTP key id
only long enough to re-enroll users or re-wrap blobs, then drop it; retain the
old PII key id until pii rotate has re-encrypted every value and recomputed
every blind index, then drop it. A rotation that removes an id early is a
deliberate invalidation of what that key protects.
The operator procedure for custody and KMS/HSM sourcing lives in DEPLOYMENT.md.
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. A private_key_jwt marker lives for
the assertion's full remaining validity, bounded by the 300 s lifetime cap.S256 only. A verifier cannot be omitted to downgrade.requested ∩ client-registered ∩ user-entitled ∩
enabled. Refresh can only narrow and re-checks the current client-registered
and user-entitled sets (a fresh Postgres read), so revoking a scope or an
entitlement drops it on the next refresh.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.pii.decrypt_failure means a stored PII or TOTP ciphertext failed to open
(tamper, truncation, or a missing key id). Treat it as possible data
corruption or key-store drift; it carries only the request id, never a value.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 | Per-row DEK wrapped by AES-256-GCM under HKDF-SHA256 (info totp-kek-v1; a re-wrap keeps the data ciphertext unchanged); v1/v2 blobs 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, JAR request_uris, jwks_uri, software ids, mTLS subject DNs and certificate thumbprints |
Sealed AES-256-GCM under HKDF-SHA256 (info pii-field-gcm-v1), with keyed-HMAC blind indexes in _index |
Opaque without the PII key. The blind indexes are deterministic tags, not plaintext, but they do reveal equality within one key generation. |
| Scopes, entitlements, timestamps, actor ids | Plaintext | Metadata disclosure. |
Client public keys (jwks) |
Plaintext | Public by design. |
is_admin, disabled, must_change_password, totp_enabled, timestamps |
Plaintext | Metadata disclosure. |
Client registration metadata (grant_types, response_types, token_endpoint_auth_method, software_id, active, dpop_bound_access_tokens, client_id_issued_at, client_secret_expires_at) and catalog metadata (scope/redirect description, enabled, kind, actor ids granted_by/created_by/attached_by) |
Plaintext | Metadata disclosure. |
If the key store leaked too, the dump is a full compromise: TOTP secrets and the sealed directory 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.
totp rewrap to re-wrap every v3 per-row DEK under the new key (the data
ciphertext is untouched), or re-enroll with user enroll-totp before the old
key is dropped. Once the old key is removed, a blob still naming it cannot
decrypt.pii rotate under a write freeze (the app tier scaled
to zero). Rotation re-encrypts every _enc value and recomputes every blind
index under the new key, in bounded transactions under an advisory lock. The
blind index is key-dependent, so uniqueness is only enforced within one key
generation during the window; the freeze is what keeps a duplicate from
slipping between generations. Run pii verify and only then retire the old
key. See
OPERATIONS.md.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.
src/crypto/ for token minting, hashing, TOTP, and constant-time compare.src/crypto/field.rs for the PII field codec and keyed blind index.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).