The server is configured from D_OAUTH_* environment variables plus an optional
file named by D_OAUTH_CONFIG. Configuration is validated at load time and the
process refuses to start on an insecure or inconsistent setting.
This guide lists every configuration key, its default, and its validation rule,
so an operator can build a correct environment without reading the source. The
loader lives in src/config.rs and its submodules.
Config::load builds a layered source:
D_OAUTH, prefix separator _, and
nesting separator __, lowercased and parsed with type coercion.D_OAUTH_CONFIG (YAML or TOML by extension). It is
not required.DATABASE_PASSWORD_FILE sets
database.password_file, and REDIS_PASSWORD_FILE sets
redis.password_file.The environment spelling of a nested key is D_OAUTH_ followed by the path
with every . replaced by __, uppercased. For example:
| YAML path | Environment variable |
|---|---|
database.url |
D_OAUTH_DATABASE__URL |
redis.connect_timeout_secs |
D_OAUTH_REDIS__CONNECT_TIMEOUT_SECS |
ttl.access_secs |
D_OAUTH_TTL__ACCESS_SECS |
failure_policy.request_timeout_secs |
D_OAUTH_FAILURE_POLICY__REQUEST_TIMEOUT_SECS |
tls.cert_path |
D_OAUTH_TLS__CERT_PATH |
A secret may be given inline or as a mounted file. Inline wins when both are set. Mounted files are read and trimmed of trailing newlines.
String-list keys (cors_allowed_origins, rate_limit.trusted_proxies,
protected_resource_ids, redis.nodes, redis.seeds) accept a real array in
the file and either a JSON array string or a comma-separated string from the
environment, because config-rs 0.15 keeps env values as strings and never
coerces arrays. Both
D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='["10.0.0.5"]' and
D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='10.0.0.5,10.0.1.0/24' load; a blank
string loads as empty, and malformed JSON array text fails the load instead
of silently meaning empty.
The key ring follows the same pattern: token_pepper_file,
totp_encryption_key_file, and pii_encryption_key_file (environment
spellings D_OAUTH_TOKEN_PEPPER_FILE, D_OAUTH_TOTP_ENCRYPTION_KEY_FILE, and
D_OAUTH_PII_ENCRYPTION_KEY_FILE) name mounted files. The cluster Deployment
uses those file forms so the material never enters the process environment;
mount them 0400 on a tmpfs (the shipped manifests project them at
/run/secrets with group 65532). The inline forms are retained and win when
both are set, so an existing environment-based deployment still starts. Each
purpose also has an optional retained previous key (*_previous_key and its
*_previous_key_file form) so a rotation can keep old ciphertext readable.
Every resolved key — token_pepper, totp_encryption_key,
pii_encryption_key, and each retained *_previous_key — must be at least 32
bytes (256 bits). A shorter key is rejected at load with WeakKeyMaterial,
which names the offending key.
| Key | Default | Rule |
|---|---|---|
issuer |
required | Absolute https URL with no trailing slash and no whitespace in the authority. |
bind_addr |
0.0.0.0:8080 |
A socket address. The TLS public listener. |
health_addr |
0.0.0.0:8081 |
A socket address. serve refuses a loopback address; config check does not. The plaintext health listener. |
environment |
production |
production or development. Development relaxes the database TLS requirement. |
cookie_domain |
none (host-only) | Reserved: any non-blank value is rejected at load (InvalidCookieDomain). The __Host- session cookie forbids Domain, so sessions stay host-only; blank values are treated as absent. |
cookie_secure |
true |
Adds the Secure attribute and uses the __Host-session_id cookie name. false is a development-only localhost exception (production refuses to load without Secure with InsecureSessionCookie); it drops Secure and the cookie name falls back to plain session_id, because browsers reject a __Host- cookie without Secure. Both variants are host-only (Path=/, HttpOnly, SameSite=Lax, no Domain). In production the server reads only __Host-session_id; the plain session_id cookie is never honored there, so a cookie planted by a sibling subdomain cannot fixate a login onto an attacker presession. The development variant reads plain session_id first and may additionally fall back to __Host-session_id for a cookie left over from before a config flip. POST /logout clears every name the reader honors. The false gate checks only environment = production: a non-production environment (for example staging) running cookie_secure = false re-opens plain-cookie sibling-subdomain fixation, so staging must use the production environment or keep secure cookies. |
allow_open_registration |
false |
When false, DCR requires a valid initial-access token. |
dcr_redirect_policy |
catalog |
catalog, catalog_loopback, or open. See below. |
cors_allowed_origins |
empty | An explicit origin allowlist. Empty means no CORS layer. A lone * is silently dropped (no CORS layer, no error). |
allow_token_exchange |
false |
Enables the token-exchange grant. |
token_exchange_allowlist |
empty | Rules of {caller, subject, scopes[]} for token exchange. |
resource_indicators_enabled |
false |
Honors the RFC 8707 resource indicator on the authorization, PAR, token, device, and introspection endpoints. Off plus any resource is invalid_target, never a silent drop. |
cimd_enabled |
false |
Enables Client ID Metadata Document lookup (an https:// client_id is fetched and validated per request). When on, the AS metadata advertises client_id_metadata_document_supported and keeps none advertised. |
protected_resources_enabled |
false |
Emits the RFC 9728 protected_resources metadata member. |
protected_resource_ids |
empty | Identifiers advertised in protected_resources when protected_resources_enabled is on. |
allow_localhost_redirect |
false |
Accepts the RFC 8252 host localhost loopback redirect (127.0.0.1/[::1] stay loopback regardless). |
jwt_access_tokens_enabled |
false |
Global kill-switch for JWT access tokens (at+jwt). Opaque tokens remain the only format while off. |
jwt_signing_key (_file) |
none | Active JWT signing key: exactly 32 bytes of raw material. Inline wins; a mounted file is trimmed of trailing newlines. |
jwt_signing_kid |
v1 |
Key id placed in the JOSE header; must match [A-Za-z0-9_-]+. |
jwt_signing_previous_keys |
empty | Rotated-out keys of {kid, key} for verification only; every key must be exactly 32 bytes and every kid distinct. |
jwt_access_token_signing_alg |
ES256 |
ES256 or EdDSA only. Both take exactly-32-byte raw key material; RSA is not supported. |
cimd_fetch_timeout_secs |
5 |
CIMD document fetch timeout, 1-10 seconds. |
cimd_max_bytes |
5120 |
CIMD document size cap, 1-5120 bytes. |
| Key | Default | Rule |
|---|---|---|
tls.cert_path |
required | PEM certificate chain for the public listener. |
tls.key_path |
required | PEM private key matching the chain. |
tls.client_ca_path |
none | Optional client-CA bundle for in-process mTLS peer verification. |
Production requires sslmode=verify-full in database.url unless
require_verify_full is set to false.
| Key | Default |
|---|---|
database.url |
required |
database.password_file |
none |
database.root_cert_path |
none (sslrootcert) |
database.max_connections |
15 |
database.min_connections |
2 |
database.acquire_timeout_secs |
3 |
database.idle_timeout_secs |
300 |
database.max_lifetime_secs |
1800 |
database.statement_timeout_secs |
3 |
database.lock_timeout_secs |
1 |
database.idle_in_transaction_timeout_secs |
30 |
database.application_name |
oauth21-server |
database.require_verify_full |
true |
max_connections must be greater than zero and at least min_connections.
database.root_cert_path (or sslrootcert in the URL) is required in practice
for sslmode=verify-full unless the server certificate chains to a system root;
the cluster ConfigMap pins /certs/db-ca.crt.
Exactly one topology is selected: url for single node, nodes plus
master_name for Sentinel, or seeds for Cluster. Setting more than one, or
none, is rejected as ambiguous.
| Key | Default | Notes |
|---|---|---|
redis.url |
none | Single-node redis:// or rediss:// URL. |
redis.nodes |
[] |
Sentinel host:port addresses. |
redis.master_name |
none | Required for Sentinel. |
redis.seeds |
[] |
Cluster seed host:port addresses. |
redis.sentinel_auth |
none | Sentinel node password. |
redis.data_auth |
none | Sentinel data-node password. |
redis.auth |
none | Cluster password. |
redis.tls |
false |
TLS for Sentinel and Cluster connections. |
redis.connect_timeout_secs |
3 |
Connection timeout. |
redis.response_timeout_secs |
2 |
Per-command response timeout. |
redis.retry_budget |
6 |
Reconnect attempts. |
redis.retry_min_delay_ms |
100 |
Minimum backoff. |
redis.retry_max_delay_ms |
5000 |
Maximum backoff. |
redis.durability |
required | wait, waitaof, or appendfsync_always. See REDIS.md. |
redis.noeviction_required |
true |
Requires maxmemory-policy noeviction at startup. |
redis.aof_required |
true |
Requires appendonly yes at startup. |
redis.token_key_prefix |
oauth:{state}: |
Must contain the {state} hash tag. |
redis.ratelimit_key_prefix |
oauth:{rl}: |
Must contain the {rl} hash tag and differ from the token prefix. |
redis.root_cert_path |
none | Root certificate for rediss:// endpoints. |
redis.password_file |
none | Mounted password for the data node. |
Durability must match the topology. Single node requires waitaof or
appendfsync_always; replicated topologies require wait. A mismatch is
rejected. redis.password_file holds the raw password and is applied to the
connection directly, so it may contain any bytes (no URL encoding required).
| Key | Default | Notes |
|---|---|---|
token_pepper |
none | Inline token-pepper secret. Required unless token_pepper_file is set. |
token_pepper_file |
none | Mounted token-pepper secret. |
token_pepper_id |
v1 |
Version id stored with the hash so peppers can rotate. |
token_pepper_previous_key |
none | Optional retained previous token-pepper secret for rotation. Required unless token_pepper_previous_key_file is set. |
token_pepper_previous_key_file |
none | Mounted previous token-pepper secret. |
token_pepper_previous_key_id |
none | Required when a previous pepper key is set; must differ from token_pepper_id. |
totp_encryption_key |
none | Inline AES-256-GCM key for TOTP secrets. Required; the server refuses to start without it (or totp_encryption_key_file). |
totp_encryption_key_file |
none | Mounted TOTP key. |
totp_key_id |
v1 |
Version id carried in the encrypted TOTP blob. |
totp_previous_key |
none | Optional retained previous TOTP key for rotation. |
totp_previous_key_file |
none | Mounted previous TOTP key. |
totp_previous_key_id |
none | Required when a previous TOTP key is set; must differ from totp_key_id. |
pii_encryption_key |
none | Inline AES-256-GCM master key for PII field encryption and blind indexes. Required unless pii_encryption_key_file is set. |
pii_encryption_key_file |
none | Mounted PII key. |
pii_key_id |
v1 |
Version id carried in every _enc blob and blind index. |
pii_previous_key |
none | Optional retained previous PII key for rotation. |
pii_previous_key_file |
none | Mounted previous PII key. |
pii_previous_key_id |
none | Required when a previous PII key is set; must differ from pii_key_id. |
pii_dual_write |
false |
Rolling-upgrade switch. true also writes the plaintext column and emits v2 TOTP blobs so version N-1 replicas keep working; false is encrypt-only. |
The exact environment spellings:
| Key | Environment variable |
|---|---|
token_pepper |
D_OAUTH_TOKEN_PEPPER |
token_pepper_file |
D_OAUTH_TOKEN_PEPPER_FILE |
token_pepper_id |
D_OAUTH_TOKEN_PEPPER_ID |
token_pepper_previous_key |
D_OAUTH_TOKEN_PEPPER_PREVIOUS_KEY |
token_pepper_previous_key_file |
D_OAUTH_TOKEN_PEPPER_PREVIOUS_KEY_FILE |
token_pepper_previous_key_id |
D_OAUTH_TOKEN_PEPPER_PREVIOUS_KEY_ID |
totp_encryption_key |
D_OAUTH_TOTP_ENCRYPTION_KEY |
totp_encryption_key_file |
D_OAUTH_TOTP_ENCRYPTION_KEY_FILE |
totp_key_id |
D_OAUTH_TOTP_KEY_ID |
totp_previous_key |
D_OAUTH_TOTP_PREVIOUS_KEY |
totp_previous_key_file |
D_OAUTH_TOTP_PREVIOUS_KEY_FILE |
totp_previous_key_id |
D_OAUTH_TOTP_PREVIOUS_KEY_ID |
pii_encryption_key |
D_OAUTH_PII_ENCRYPTION_KEY |
pii_encryption_key_file |
D_OAUTH_PII_ENCRYPTION_KEY_FILE |
pii_key_id |
D_OAUTH_PII_KEY_ID |
pii_previous_key |
D_OAUTH_PII_PREVIOUS_KEY |
pii_previous_key_file |
D_OAUTH_PII_PREVIOUS_KEY_FILE |
pii_previous_key_id |
D_OAUTH_PII_PREVIOUS_KEY_ID |
pii_dual_write |
D_OAUTH_PII_DUAL_WRITE |
The token pepper, the TOTP encryption key, and the PII field-encryption key are
required: without any of them the key ring cannot be built and the server
refuses to start with ConfigError::KeyRing(KeyRingError::MissingTokenPepper)
for the token pepper, ConfigError::KeyRing(KeyRingError::MissingTotpKey) for
the TOTP key, or ConfigError::KeyRing(KeyRingError::MissingPiiKey) for the
PII key. Key ids (token_pepper_id,
totp_key_id, pii_key_id, and any *_previous_key_id) are stored verbatim:
token hashes as <kid>.<digest> (split on the first .) and PII blind
indexes as <kid>:<digest>, so an id must contain neither . (token split)
nor : (PII ambiguity); keep to [A-Za-z0-9_-]+. The shipped cluster manifests set
D_OAUTH_TOKEN_PEPPER_FILE, D_OAUTH_TOTP_ENCRYPTION_KEY_FILE, and
D_OAUTH_PII_ENCRYPTION_KEY_FILE rather than the inline keys; see
DEPLOYMENT.md.
These three values are the server's master keys and are supplied entirely by
the deployment. There is a hard custody requirement that they never live in the
database or in the same backup as the database; a KMS/HSM or a secret manager
should hold them and inject them through a *_FILE path. The server has no
built-in KMS integration and never fetches a key itself. The full contract and
the forbidden locations are in
SECURITY.md; the operator procedure is in
DEPLOYMENT.md.
Security endpoints are always fail-closed. Setting open on any of them is a
load-time error.
| Key | Default |
|---|---|
failure_policy.default_mode |
closed |
failure_policy.endpoints |
the seven security endpoints mapped to closed |
failure_policy.retryable |
io, timeout, connection_reset, temporarily_unavailable, pool_timed_out |
failure_policy.retry_budget |
3 |
failure_policy.request_timeout_secs |
10 |
The security endpoints are authorize, token, introspect, revoke, par,
device_authorization, and register.
| Key | Default |
|---|---|
ttl.access_secs |
300 |
ttl.refresh_secs |
2592000 (30 days) |
ttl.code_secs |
60 |
ttl.par_secs |
90 |
ttl.device_secs |
600 |
ttl.dcr_initial_access_secs |
86400 (24 hours) |
ttl.session_secs |
43200 (12 hours) |
argon2.memory_kib |
19456 (19 MiB) |
argon2.iterations |
2 |
argon2.parallelism |
1 |
Every TTL must be non-zero, and the access-token TTL must not exceed the
refresh-token TTL. The authorization-code lifetime is additionally capped at
60 s (CODE_TTL_CAP in src/protocol/code.rs), so a ttl.code_secs value
above 60 is effectively clamped. Argon2 memory must be at least 8192 KiB, with a non-zero
iteration count and parallelism.
| Key | Default |
|---|---|
rate_limit.enabled |
true |
rate_limit.window_secs |
60 |
rate_limit.per_ip |
120 |
rate_limit.per_client |
300 |
rate_limit.login_per_ip |
20 |
rate_limit.token_per_client |
60 |
rate_limit.trusted_proxies |
empty |
The window must be non-zero.
rate_limit.trusted_proxies (environment spelling
D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES) is the L7 reverse-proxy allowlist for
rate-limit IP attribution only. Each entry is an exact IP (10.0.0.1) or a
CIDR (10.0.0.0/8, 2001:db8::/32). From the environment use a JSON array
string (D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='["10.0.0.5"]') or a
comma-separated string (D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='10.0.0.5'); in
the file use a real array (trusted_proxies = ["10.0.0.5"]). Default empty means forwarded headers
are never honored: the limiter keys the IP bucket off the verified TCP peer.
When the peer belongs to the allowlist, the limiter attributes the
rightmost-untrusted X-Forwarded-For entry instead; a header from an
untrusted peer is ignored, and a malformed header falls back to the peer, so
spoofing cannot evade or shift the bucket. A malformed entry, an out-of-range
prefix, a trust-everything /0, or more than 64 entries fails the load with
InvalidTrustedProxy. Entries are not deduplicated, so duplicates count
toward the 64-entry cap. A value starting with [ parses as a JSON array, so
a bracketed IPv6 literal ([2001:db8::1]) fails the load — always use bare
IPs, in entries and in arrays. This trust never authenticates a client and never
carries a client certificate: in-process mTLS still requires L4/TCP
passthrough (see DEPLOYMENT.md).
scope_registry is an optional declarative input applied only by config sync.
It is never required to be non-empty. Each entry is {name, description?,
allowed_grants?, allowed_client_classes?}. The name must match the RFC 6749
scope-token grammar, may not be openid, and may not exceed 128 characters.
token_exchange_allowlist entries are {caller, subject, scopes[]}; caller and
subject may not be empty.
Every optional feature is off by default and none of these keys changes
default-on behavior. The feature flags (resource_indicators_enabled,
cimd_enabled, protected_resources_enabled, jwt_access_tokens_enabled, and
allow_localhost_redirect) default to false; protected_resource_ids and
jwt_signing_previous_keys default to empty.
CIMD is the registration-free client path. Enabling it does not change the
POST /register gate: allow_open_registration and dcr_redirect_policy still
decide dynamically registered clients, and allow_localhost_redirect is the
single server-wide switch that makes the host localhost a loopback redirect
for both registered and CIMD clients. The operator runbook, including a
validating example document, is in
OPERATIONS.md.
| Key | Default | Rule |
|---|---|---|
D_OAUTH_JWT_ACCESS_TOKENS_ENABLED |
false |
Global kill-switch for JWT access tokens. Enabling it requires a signing key; a configured jwt client with the switch off gets 503. |
D_OAUTH_JWT_SIGNING_KEY |
none | Active signing key: the literal UTF-8 bytes of the string, exactly 32 bytes. Shorter or longer material is WeakKeyMaterial; unusable EC/EdDSA material is InvalidJwtSigningKey. Inline values are whitespace-trimmed. |
D_OAUTH_JWT_SIGNING_KEY_FILE |
none | Mounted form of the active key. Read verbatim except that trailing CR/LF are stripped; the inline form wins when both are set. |
D_OAUTH_JWT_SIGNING_KID |
v1 |
Key id for the active key, [A-Za-z0-9_-]+. It is the JOSE kid and the JWKS entry id. |
D_OAUTH_JWT_SIGNING_PREVIOUS_KEYS |
empty | Rotated-out keys, a list of {kid, key} objects. Each key is validated exactly like the active key (32 bytes); kids must be distinct and different from the active kid (DuplicateJwtSigningKid); configuring any entry requires an active key. Supply as a YAML/TOML array in the D_OAUTH_CONFIG file, not as one environment string. |
D_OAUTH_JWT_ACCESS_TOKEN_SIGNING_ALG |
ES256 |
ES256 or EdDSA only. Both use 32-byte raw key material, so there is no RSA/RS256 branch. All retained keys share this algorithm. |
jwt_signing_key is the active JOSE signing key. It must be exactly 32
bytes of raw material: shorter or longer material is rejected at load with
WeakKeyMaterial (this is stricter than the pepper/TOTP/PII chains, which
enforce only a minimum). A jwt_signing_key_file mount works exactly like the
other *_file keys and is trimmed of trailing newlines; the inline form wins
when both are set. The key material is turned into a real JOSE signing key at
load, so an all-zero or out-of-range EC scalar fails at boot with
InvalidJwtSigningKey, not at the first mint. Enabling
jwt_access_tokens_enabled without a signing key fails closed with
MissingJwtSigningKey.jwt_signing_previous_keys retains rotated-out keys as a list of
{kid, key} objects so a token signed under an old kid can still verify.
Each key is validated exactly like the active key (exactly 32 bytes) and
every kid must be distinct and different from the active jwt_signing_kid;
a duplicate is rejected with DuplicateJwtSigningKid.jwt_access_token_signing_alg accepts only ES256 or EdDSA. Both use
exactly-32-byte raw key material, so there is no RSA / RS256 branch.Config's Debug impl and
JwtSigningKey's Debug impl emit the key id and algorithm only.cimd_fetch_timeout_secs must be between 1 and 10 seconds
(InvalidCimdFetchTimeout) and cimd_max_bytes between 1 and 5120
(InvalidCimdMaxBytes). A malformed value fails the load rather than
silently meaning "no limit".The four database and request timeouts must satisfy:
lock_timeout < statement_timeout <= acquire_timeout < request_timeout
With the defaults that is 1s < 3s <= 3s < 10s. A violation is a load-time
error, which prevents a request from outliving a pool slot or a lock outliving
the statement.
catalog: only redirect URIs present in the runtime catalog may be
registered. This is the default.catalog_loopback: catalog URIs plus dynamic loopback URIs (RFC 8252).
The host localhost counts as loopback here only when
allow_localhost_redirect is on.open: any absolute https redirect URI, or an RFC 8252 loopback URI, may
be registered. This is an explicit opt-in and is not the default; other
schemes and plaintext non-loopback URIs are still rejected. As with
catalog_loopback, localhost is loopback only when
allow_localhost_redirect is on.Config's Debug impl redacts the database URL,
Redis URLs, and every password.sslmode=verify-full is refused.WeakKeyMaterial).0400 tmpfs file, so the
material avoids the process environment; the inline forms are supported and
win when both are set.D_OAUTH_CONFIG pointing at a missing file is not an error; the file is
optional.token_pepper of only whitespace is treated as absent.pii_previous_key / totp_previous_key (including a zero-byte or
whitespace-only *_previous_key_file) is treated as absent, so an empty mount
never becomes a live key.*_previous_key_id, a blank id, or an id equal to the
active id is a load-time error (ConfigError::MissingPreviousKeyId).MissingPiiKey /
MissingTotpKey, so a retained key can never silently become the active one.*_file path is a load-time error (ConfigError::SecretFile),
never a silent fallback to an absent key.scope delete and redirect-uri delete disable a referenced row instead of
deleting it. See OPERATIONS.md.dcr_redirect_policy = open and mTLS is a deployment
error, not a load error: open registration lets a caller register any
https/loopback redirect URI without pre-registration.Validate any environment before starting the server:
D_OAUTH_ISSUER=https://auth.example.com cargo run --bin d-oauth-server -- config check
config check prints config ok: issuer=... and exits 0 on success, or prints
the first validation error and exits 1. It does not connect to Postgres or
Redis.
src/config.rs for the loader and ConfigError.src/config/build.rs for defaults and cross-field validation.src/config/db.rs, src/config/redis.rs, src/config/types.rs,
src/config/policy.rs, src/config/scope.rs for the per-area keys.src/config/tests.rs for the asserted defaults.