Configuration
oidc-exchange is configured entirely via TOML files and environment variables. A single configuration file controls every aspect of the service: server settings, token lifetimes, storage backends, identity providers, audit logging, and telemetry.
Config loading order
Section titled “Config loading order”Configuration is loaded and merged in the following order, with later sources overriding earlier ones:
config/default.toml: baseline defaults shipped with the binaryconfig/{OIDC_EXCHANGE_ENV}.toml: environment-specific overrides. TheOIDC_EXCHANGE_ENVenvironment variable selects the file (e.g.,production,staging,local). If unset, onlydefault.tomlis loaded.- Environment variable overrides: structural overrides using double-underscore delimiters,
OIDC_EXCHANGE__{section}__{key}(e.g.,OIDC_EXCHANGE__SERVER__PORT=9090) ${VAR_NAME}placeholder resolution: any value in the TOML containing${VAR_NAME}is resolved from the environment at load time; an unset value is a configuration error- Closed-domain resolution: the merged configuration is narrowed into typed values; invalid security-relevant values (including non-HTTPS issuer, provider, and webhook URLs) fail before startup
Secrets (client secrets, API keys, KMS ARNs) should always use ${VAR_NAME} placeholders and be injected via environment variables. Never hardcode secrets in TOML files.
Full annotated config example
Section titled “Full annotated config example”The following shows the configuration sections and the most common options. In practice, most deployments only need a subset.
# ─── Server ───────────────────────────────────────────────────────[server]host = "0.0.0.0" # bind addressport = 8080 # listen portissuer = "https://auth.example.com" # issuer URL for JWTs (iss claim)role = "exchange" # "exchange" (default), "admin", or "all" (see Upgrading below)request_timeout = "30s" # per-request timeout enforced by the servermax_request_body_bytes = 2097152 # max request body (2 MiB) buffered before processing# base_path = "/prod" # optional path prefix stripped from request paths before routing# Trust X-Forwarded-For only from these peer CIDRs. Empty means never trust it.trusted_proxies = ["10.0.0.0/8"]trusted_proxy_hops = 1 # select this many entries from X-Forwarded-For's right side
# ─── Registration policy ──────────────────────────────────────────[registration]# "open": any authenticated user gets a record created# "existing_users_only": user must already exist (created via /internal/users)mode = "open"# Optional. If set, only these email domains are allowed (applies in both modes)# Exact match: "example.com"# Wildcard: "*.acme.corp" (matches any subdomain depth)# domain_allowlist = ["example.com", "*.acme.corp"]
# ─── Token settings ───────────────────────────────────────────────[token]access_token_ttl = "15m" # short-lived JWT lifetimerefresh_token_ttl = "30d" # long-lived refresh token lifetimeaudience = "https://api.example.com" # aud claim in access tokensrefresh_rotation = true # rotate the refresh token on every redemption (default)refresh_rotation_grace = "10s" # window the just-superseded token still redeems (max 60s)refresh_reuse_retention = "24h" # how long a retired token is remembered for reuse detection
# Custom claims added to every access token JWT.# Static values are used as-is.# Template values reference the User model with {{ field }} syntax.# The | default: filter provides a fallback if the field is missing.# Reserved protocol claim names cannot be used as keys here or as per-user# claims via the internal API; a configuration carrying one fails startup.[token.custom_claims]org = "example"role = "{{ user.metadata.role | default: 'user' }}"tier = "{{ user.metadata.membership | default: 'free' }}"
# ─── Grants ───────────────────────────────────────────────────────# authorization_code and refresh_token are always served. The direct# ID-token grant is opt-in and also gates the POST /nonce route.[grants]id_token = false # serve the direct ID-token assertion grant (default off)nonce_ttl = "10m" # how long a minted nonce stays claimablemax_assertion_lifetime = "1h" # ceiling on an accepted provider ID token's remaining life
# ─── Key management ───────────────────────────────────────────────[key_manager]adapter = "local" # "local" or "kms"
# Local key signing: load a PEM private key from disk[key_manager.local]private_key_path = "./keys/ed25519.pem"algorithm = "EdDSA" # EdDSA only: the local adapter signs Ed25519 keyskid = "key-1" # key ID for JWT kid header
# AWS KMS: sign with a KMS asymmetric key[key_manager.kms]key_id = "arn:aws:kms:us-east-1:123456789:key/abcd-1234"algorithm = "ES256" # JWS signing algorithm (ECC_NIST_P256)kid = "key-2024-01"
# ─── User and session storage ─────────────────────────────────────[repository]adapter = "dynamodb" # "dynamodb", "postgres", or "sqlite"
[repository.dynamodb]table_name = "oidc-exchange"region = "us-east-1" # optional, uses SDK default if omitted
[repository.postgres]url = "postgres://user:pass@localhost:5432/oidc_exchange"max_connections = 5run_migrations = true # run schema migrations at startup (default true)
[repository.sqlite]path = "./data/oidc-exchange.db"
# ─── Session-only storage (optional) ──────────────────────────────# If set, session/refresh-token operations use this backend instead of# the main repository. Useful for pairing a relational user store with# a fast session store.[session_repository]adapter = "valkey" # "valkey" or "lmdb"cleanup_interval = "1h" # how often expired sessions/retirement records are swept
[session_repository.valkey]url = "redis://localhost:6379"key_prefix = "oidc:"
[session_repository.lmdb]path = "./lmdb"max_size_mb = 64
# ─── Audit logging ────────────────────────────────────────────────[audit]adapter = "stdout" # "noop", "stdout", "stderr", "auto", or "sqs" (default: stdout)# Best-effort events below this severity are not dispatched. Shipped security events use# the mandatory channel, which no threshold suppresses.emit_threshold = "info"# Best-effort sink-failure policy threshold (RFC 5424 severities).blocking_threshold = "warning"# Mandatory security-event sink failures: "observe" logs degradation; "enforce" fails the operation.durability = "observe"
# ─── Public-route rate limiting ───────────────────────────────────[rate_limit]enabled = truestore = "in_process" # "in_process" or "none"window = "1m"per_ip = 60per_ip_failures = 10 # consumed only by authentication failuresper_subject = 10per_provider = 600max_concurrent_requests = 256max_entries = 10000
# SQS adapter: send audit events to an SQS queue (e.g., for a Firehose to S3/Iceberg pipeline)[audit.sqs]queue_url = "https://sqs.us-east-1.amazonaws.com/123456789/audit-queue"region = "us-east-1" # optional, uses SDK default if omitted
# ─── User sync (webhook) ──────────────────────────────────────────[user_sync]enabled = false # set to true to enableadapter = "webhook" # "webhook" or "noop"
[user_sync.webhook]url = "https://internal-api.example.com/user-events"secret = "${SYNC_WEBHOOK_SECRET}" # HMAC-SHA256 signing secrettimeout = "5s"retries = 2
# ─── Telemetry (OpenTelemetry) ────────────────────────────────────[telemetry]enabled = trueexporter = "otlp" # "otlp", "stdout", "xray", "prometheus", or "none"endpoint = "http://localhost:4317" # OTLP collector endpointservice_name = "oidc-exchange"sample_rate = 1.0 # 0.0 to 1.0protocol = "grpc" # "grpc" or "http"
# ─── Internal admin API ───────────────────────────────────────────[internal_api]enabled = true # serves /internal/* on the dedicated admin listenerhost = "127.0.0.1" # admin listener bind address (loopback by default)port = 8081# Authentication mechanisms, tried in the order given.# Values: "operator_token", "mtls", "shared_secret". The legacy singular# `auth_method` key is still accepted and read as a one-element list.auth_methods = ["operator_token"]# Shared secret for the "shared_secret" compatibility mechanism. While that# mechanism is enabled it must be at least 32 bytes, non-empty is not enough.shared_secret = "${INTERNAL_API_SECRET}"
# "operator_token" mechanism: operator JWTs are verified against THIS# service's own key manager ([key_manager]) and require a real (non-noop)# adapter plus a non-empty [server].issuer.token_audience = "internal" # aud an operator token must carry; must differ from [token].audiencerequired_claim = "role" # claim name a verified operator token must carryrequired_value = "${OPERATOR_ROLE_VALUE}" # value required_claim must carry
# Failed-authentication throttle, keyed by peer address on the admin listener:max_auth_failures = 5 # failed attempts one peer may spend...auth_failure_window = "1m" # ...per this window before lockoutauth_lockout = "5m" # how long a locked-out peer stays denied
# How long cached dashboard counts (active sessions) may be served before a# re-scan; consumed by the DynamoDB adapter only. Valid range: 1s–3600s.stats_cache_ttl = "60s"
# "mtls" mechanism: the client-certificate subject is asserted by the# TLS-terminating proxy via this header. Trustworthy only while the admin# listener is unreachable except through that proxy (which must also strip# client-supplied copies of the header); a startup warning fires when this# mechanism is enabled on a non-loopback listener.[internal_api.mtls]subject_header = "x-client-cert-subject"
# ─── Identity providers ───────────────────────────────────────────# Each [providers.<name>] block registers a provider. The name is used# in POST /token requests as the "provider" field.
[providers.google]adapter = "oidc"issuer = "https://accounts.google.com"client_id = "${GOOGLE_CLIENT_ID}"client_secret = "${GOOGLE_CLIENT_SECRET}"scopes = ["openid", "email", "profile"]# Extra origins Google's discovery document may name beyond accounts.google.com:# token/revocation endpoints on oauth2.googleapis.com, JWKS on www.googleapis.com.# Each entry is a bare https origin; defaults to empty (issuer's origin only).endpoint_origins = ["https://oauth2.googleapis.com", "https://www.googleapis.com"]
[providers.apple]adapter = "apple"client_id = "com.example.app"team_id = "${APPLE_TEAM_ID}"key_id = "${APPLE_KEY_ID}"private_key_path = "/secrets/apple.p8"
# atproto is a planned provider; see .specs/changes/2026-06-24-add_atproto_provider.mdEnvironment variable overrides
Section titled “Environment variable overrides”Any config value can be overridden at runtime using environment variables with double-underscore delimiters:
OIDC_EXCHANGE__{section}__{key}=valueExamples:
| Environment variable | Config path | Effect |
|---|---|---|
OIDC_EXCHANGE__SERVER__PORT=9090 |
server.port |
Change listen port |
OIDC_EXCHANGE__REGISTRATION__MODE=existing_users_only |
registration.mode |
Restrict registration |
OIDC_EXCHANGE__TOKEN__ACCESS_TOKEN_TTL=5m |
token.access_token_ttl |
Shorten token lifetime |
OIDC_EXCHANGE__TELEMETRY__ENABLED=true |
telemetry.enabled |
Enable telemetry |
Secret placeholder syntax
Section titled “Secret placeholder syntax”Values containing ${VAR_NAME} are resolved from the environment at config load time. This allows secrets to be injected without appearing in TOML files:
[providers.google]client_id = "${GOOGLE_CLIENT_ID}"client_secret = "${GOOGLE_CLIENT_SECRET}"At startup, if GOOGLE_CLIENT_ID is set to 123456.apps.googleusercontent.com, the config value becomes that string. If the environment variable is not set, the service fails to start with a configuration error.
Use oidc-exchange config check path/to/config.toml to run the same side-effect-free resolver used at startup. It merges the file with committed defaults but intentionally ignores environment variables and overlays, so pass a fully materialized deployment file. It reports missing deployment inputs (such as unresolved placeholders) separately from invalid value domains, and redacts secrets in its rendered output.
Defaults
Section titled “Defaults”| Setting | Default |
|---|---|
server.host |
0.0.0.0 |
server.port |
8080 |
server.role |
exchange |
server.issuer, token.audience |
https://auth.example.com / https://api.example.com (deployment placeholders; replace before production) |
registration.mode |
open |
registration.domain_allowlist |
none (all domains allowed) |
token.access_token_ttl |
15m |
token.refresh_token_ttl |
30d |
token.refresh_rotation / refresh_rotation_grace / refresh_reuse_retention |
true / 10s / 24h |
grants.id_token / nonce_ttl / max_assertion_lifetime |
false / 10m / 1h |
server.request_timeout / max_request_body_bytes |
30s / 2097152 (2 MiB) |
session_repository.cleanup_interval |
1h |
telemetry.enabled |
false |
telemetry.exporter |
none |
telemetry.sample_rate |
1.0 |
audit.adapter |
stdout |
audit.durability |
observe |
audit.emit_threshold |
info |
rate_limit.enabled / store / window |
true / in_process / 1m |
rate_limit.per_ip / per_ip_failures / per_subject / per_provider |
60 / 10 / 10 / 600 |
server.trusted_proxies / trusted_proxy_hops |
[] / 1 |
audit.blocking_threshold |
warning |
providers.<name>.endpoint_origins |
none; the provider is pinned to its issuer’s origin (plus the origins of explicitly configured endpoints) |
user_sync.enabled |
false |
internal_api.enabled |
false |
internal_api.host / .port |
127.0.0.1:8081 |
internal_api.auth_methods |
none (empty; must be set explicitly when the internal API is served, or startup fails) |
internal_api.token_audience |
"internal" |
internal_api.required_claim / .required_value |
"role" / "admin" |
internal_api.mtls.subject_header |
"x-client-cert-subject" |
internal_api.max_auth_failures |
5 |
internal_api.auth_failure_window |
"1m" |
internal_api.auth_lockout |
"5m" |
internal_api.stats_cache_ttl |
"60s" |
Upgrading: server.role now defaults to exchange
Section titled “Upgrading: server.role now defaults to exchange”Earlier releases defaulted server.role to all: a deployment that only set
internal_api.enabled = true got the internal admin API served on the same
process as the public /token endpoint without ever naming that decision. The
default is now exchange, which serves only the public exchange plane;
admin reachability must be a deliberate deployment decision, visible in
configuration.
If an installation relied on the implicit all, set the role explicitly when
upgrading:
[server]role = "all" # public exchange plane + internal admin API on this processPrefer splitting the planes when you do: keep role = "exchange" (or omit the
key) on internet-facing processes, and run a separate process with
role = "admin" and internal_api.enabled = true for the admin API, reachable
only from your operator network. An exchange-role process never mounts
/internal/*, so a forgotten internal_api.enabled = true there fails closed
instead of publishing the privilege-assignment primitive.
Trusted proxies and rate limiting
Section titled “Trusted proxies and rate limiting”The server uses the connection peer as the client address by default. It reads
X-Forwarded-For only when that peer belongs to server.trusted_proxies; it then selects
trusted_proxy_hops entries from the right of the comma-separated chain. This makes the
address forwarded; direct peers are peer. A forwarding header from any other peer is
client-asserted and is never used as a rate-limit key or authorization input.
The shipped in-process limiter is per process. Use an API gateway, WAF, or other edge control for a global limit across replicas or Lambda execution environments.