Open with AI
Configuration
This document is the complete reference for the Issuerd server configuration
file (issuerd.toml). It is written for system operators and administrators
who deploy and run Issuerd. Every key, default, and behavior described here is
implemented in crates/issuerd-server/src/config.rs and the daemon startup path
(src/main.rs, crates/issuerd-server/src/state.rs). For first-time setup see
getting-started.md; for realm/client content as code see
provisioning.md.
Contents
Section titled “Contents”- How configuration is loaded
- Minimal configurations
- Root keys
- [storage]
- [tls]
- [logging]
- [web_ui]
- [proxy]
- [cors]
- [cluster]
- [cache]
- [oauth]
- [dpop]
- [mcp]
- [crypto.key_encryption]
- [outbound_http]
- [themes]
- [smtp]
- Token signing algorithm
- Complete annotated example
- Generating the example config
How configuration is loaded
Section titled “How configuration is loaded”Configuration is assembled from three layers, later layers winning:
-
Built-in defaults — every key has one (see the reference tables below). With no file and no environment, the daemon boots on in-memory storage, plain HTTP on
0.0.0.0:8080. -
Config file — passed with
-c/--config:Terminal window issuerd daemon -c /etc/issuerd/issuerd.tomlThe default path is
issuerd.toml, resolved against the daemon’s working directory. The same flag exists onissuerd provision(which reads the config only for the storage connection andissuer_url). The file format is inferred from the extension:.toml,.yaml/.yml, or.json(an unknown or missing extension is parsed as TOML). The examples in this document use TOML. -
Environment overrides — every variable prefixed with
ISSUERD_is merged on top of the file.__(double underscore) is the nesting separator and variable names are lowercased before mapping:Environment variable Overrides ISSUERD_PORT=9090portISSUERD_BIND=127.0.0.1bindISSUERD_ISSUER_URL=https://idp.example.comissuer_urlISSUERD_REDIS=redis://redis:6379redisISSUERD_CLUSTER__ENABLED=truecluster.enabledISSUERD_CLUSTER__JWKS_REFRESH_INTERVAL_SECS=15cluster.jwks_refresh_interval_secsISSUERD_CACHE__READ_CACHE_TTL_SECS=0cache.read_cache_ttl_secsISSUERD_OAUTH__AUTH_CODE_TTL_SECS=60oauth.auth_code_ttl_secsISSUERD_DPOP__NONCE__MODE=requireddpop.nonce.modeISSUERD_SMTP__ENABLED=true/ISSUERD_SMTP__HOST=mail.example.comsmtp.enabled/smtp.hostISSUERD_PROXY__TRUST_X_FORWARDED_FOR=falseproxy.trust_x_forwarded_forISSUERD_BOOTSTRAP_ADMIN_PASSWORD=...bootstrap_admin_passwordThe shipped cluster stack uses this mechanism for per-node identity (
ISSUERD_CLUSTER__NODE_IDindocker-compose.cluster.yml), so the same file can be mounted into every node.
Warning: list-valued keys (
cors.allowed_origins,mcp.allowed_origins,proxy.trusted_proxies,cluster.redis_nodes) cannot be expressed reliably as a single environment variable — set them in the config file (the cluster guide documentscluster.redis_nodesas TOML-only, see CLUSTERING.md).
Behavioral notes:
- Missing file is not an error. If the
-cpath does not exist, it is silently skipped and the daemon runs on defaults + environment. A typo in the path therefore boots a default server instead of failing — check theloaded configurationstartup log line when in doubt. - Invalid content fails the boot. A file that parses but has the wrong
type for a key (e.g.
port = "abc") aborts startup withconfig load failed: .... - Unknown keys are ignored. Misspelled keys do not cause an error — they silently have no effect.
- Environment overrides apply to every subcommand that loads configuration
(
daemon,provision).
Minimal configurations
Section titled “Minimal configurations”Development / evaluation (in-memory, zero dependencies):
# Everything else takes defaults: in-memory storage, plain HTTP on# 0.0.0.0:8080, no Redis, no TLS.issuer_url = "http://localhost:8080"The root issuerd.toml in the repository is a working example of this rig.
With in-memory (or JSON-file) storage the master realm is bootstrapped
automatically on first start (admin user admin, password from
bootstrap_admin_password or randomly generated and printed once to stderr;
the root rig pins admin/admin); see getting-started.md.
Production (PostgreSQL + Redis, TLS terminated at a proxy):
bind = "0.0.0.0"port = 8080issuer_url = "https://idp.example.com" # public URL, as clients reach the proxy
redis = "redis://redis:6379"
[storage.postgres]url = "postgres://issuerd:SECRET@postgres:5432/issuerd"
[proxy]trusted_proxies = ["10.0.0.0/8"] # your load balancer / ingress subnetModeled on examples/issuerd.demo.toml (the demo stack’s production-shaped
config). With PostgreSQL, automatic master-realm bootstrap does not run —
define master in a provision file (see examples/provision.demo.yaml and
provisioning.md) or create it via the Admin API.
Root keys
Section titled “Root keys”| Key | Type | Default | Description |
|---|---|---|---|
bind |
string (IP literal) | "0.0.0.0" |
Interface to bind. Parsed as an IP literal, so IPv6 ("::", "::1") works. A name or invalid literal fails the boot. |
port |
integer | 8080 |
TCP port for HTTP (or HTTPS when [tls] is set). |
issuer_url |
string (URL) | "http://localhost:8080" |
Public base URL of this server. See the deep-dive below. |
redis |
string (URL), optional | unset | Single Redis node, e.g. "redis://localhost:6379" (rediss:// for TLS). Unset ⇒ in-process ephemeral cache. Overridden by cluster.redis_nodes when that list is non-empty. |
provision |
path, optional | unset | Provision file (YAML/TOML/JSON by extension) applied exactly once at startup — a marker is claimed in storage before applying, so restarts and concurrent first boots skip it. Relative paths resolve against the working directory. A missing/unreadable file logs an error and boot continues. Format: provisioning.md. |
bootstrap_admin_password |
string, optional | unset | Password of the admin user the in-memory/JSON-file first-boot bootstrap seeds into the master realm (only when no realm exists yet). Unset or empty ⇒ a random password is generated and printed once to stderr (not through the log output). Prefer the ISSUERD_BOOTSTRAP_ADMIN_PASSWORD environment variable over writing it into the file; environment values are parsed like TOML values, so quote one that would read as a number, boolean, or array (ISSUERD_BOOTSTRAP_ADMIN_PASSWORD='"12345"') — unquoted, it fails the boot with config load failed. Ignored by PostgreSQL storage, which has no automatic bootstrap. |
About issuer_url
Section titled “About issuer_url”This is the most consequential value in the file:
- It must be an absolute
http(s)URL — anything else aborts the boot withissuer_url must be an absolute http(s) URL. - It is baked into every token’s
issclaim and into the OIDC discovery document as{issuer_url}/realms/{realm-name}(the realm name, never the internal UUID). Clients validateissstrictly, soissuer_urlmust be exactly how browsers and applications reach the server — scheme, host, and port included. If you terminate TLS at a proxy,issuer_urlkeeps the publichttpsscheme even though the daemon itself serves plain HTTP. - In a cluster it must be the load balancer’s URL and identical on every node (see CLUSTERING.md).
- Its scheme drives the
Secureattribute on every authentication cookie the server sets (SSO sessionissuerd_session_{realm-id}, remember-meissuerd_remember_{realm-id}, theissuerd_flow_{id}flow correlation cookie, and the logout clears):https⇒ all cookies carrySecure;http⇒ they do not, so plain-HTTP development rigs keep working. The flag is derived from this config value, never from the request. - Changing it later effectively changes every realm’s issuer: outstanding tokens, sessions, and stored client configurations that reference the old issuer stop validating, and users must re-authenticate. Treat it as permanent once realms are in use.
[storage]
Section titled “[storage]”The persistent store for realms, users, clients, sessions, signing keys, and events. Exactly one variant is active; the default is in-memory (no section at all).
| Variant | TOML shape | Durability | Use for |
|---|---|---|---|
| In-memory (default) | (omit the section) | None — empty on every boot | Development, tests, demos |
| PostgreSQL | [storage.postgres] + url |
Durable, shared between nodes | Production, clusters |
| JSON file | [storage.json_file] + path |
Durable, single node | Manual testing, small single-node rigs |
# PostgreSQL — recommended for production.[storage.postgres]url = "postgres://issuerd:issuerd_secret@localhost:5432/issuerd"- Schema migrations are applied automatically at boot (and by the
issuerd provisionsubcommand), so upgrades only require starting the new binary. See backup-and-upgrade.md. - This is the only backend that supports multi-node clusters (see
[cluster]) — in a cluster, token issuance is shared between
nodes via the shared
signing_keystable.
# JSON file — full-state snapshot persistence.[storage.json_file]path = "issuerd-data.json"- The entire state is loaded from the file at boot and rewritten in full on every write, serialized through a global lock — correct but slow, so it is meant for manual testing and single-node rigs, not production traffic.
- Like in-memory, it triggers the automatic master-realm bootstrap on first start.
Note: with in-memory storage, JWT signing keys are generated per boot, so all outstanding tokens are invalidated by a restart. The JSON-file backend persists them in its snapshot; with PostgreSQL they live in the shared
signing_keystable — both survive restarts and redeploys.
Optional. When present, the daemon itself serves HTTPS via rustls (TLS 1.2+); when absent, it serves plain HTTP.
| Key | Type | Description |
|---|---|---|
cert_path |
path (PEM) | Certificate chain file. |
key_path |
path (PEM) | Private key file. |
[tls]cert_path = "certs/idp.example.com.crt"key_path = "certs/idp.example.com.key"Both files are read at startup; unreadable or unparsable PEM fails the boot. Paths are resolved against the working directory.
In most production deployments it is simpler to terminate TLS at a reverse
proxy or load balancer and run Issuerd on plain HTTP behind it. If you do,
remember that issuer_url still uses the public https scheme, and configure
[proxy] so the daemon sees real client IPs. See
deployment.md and security.md.
[logging]
Section titled “[logging]”| Key | Type | Default |
|---|---|---|
format |
string | "pretty" |
level |
string | "info" |
level is one of error, warn (alias warning), info, debug,
trace. format is
pretty (human-readable console text) or json (one JSON object per event,
for log shippers). Unparsable values fall back to the defaults with a WARN at
startup.
The effective log level is resolved with this precedence:
RUST_LOG— when set, it wins completely. Any standardtracingEnvFilter directive string is accepted, e.g.RUST_LOG=issuerd_server=debug,issuerd_storage=warnfor per-crate control.-v/-qCLI flags (global, repeatable): default isinfo;-v→debug;-vv(or more) →trace;-q→warn;-qq(or more) →error.Terminal window issuerd -v daemon -c issuerd.toml[logging].levelfrom the config file.
The flag/config level is applied to Issuerd’s own crates (issuerd,
issuerd_server, issuerd_core, issuerd_auth_flow, issuerd_protocol, issuerd_token,
issuerd_storage, issuerd_cluster, issuerd_admin_api, issuerd_federation); use RUST_LOG
to also tune dependencies such as sqlx or tower.
Notes on output:
formatapplies to the console output only. On Linux under systemd (whenJOURNAL_STREAMis set) logs go to journald in its own structured format instead; if journald initialization fails, the daemon prints a notice to stderr and falls back to console formatting.- ANSI colors are emitted only when stdout is a terminal, so piped and container logs stay free of escape codes. (On Windows the console ANSI support is enabled explicitly at startup.) The JSON formatter never emits ANSI.
- Records from dependencies that log through the
logfacade (ldap3, redis, reqwest, rustls, sqlx, …) are bridged into tracing and filtered by the same directives.
See monitoring.md for metrics and health endpoints.
[web_ui]
Section titled “[web_ui]”| Key | Type | Default |
|---|---|---|
enabled |
boolean | true |
Warning: this key is currently parsed but not consulted. The consoles are served whenever the SPA assets were embedded into the binary at build time — release builds embed them — and there is no runtime switch to turn them off.
The served surfaces are the admin console at /admin/console and the account
console at /realms/{realm}/account. If the assets were not embedded (a
debug build without webclientsrc/dist), those paths answer 404; the Admin
API under /admin/... and all protocol endpoints are unaffected either way.
[proxy]
Section titled “[proxy]”Controls how the daemon determines the real client IP when it sits behind a reverse proxy or load balancer.
| Key | Type | Default | Description |
|---|---|---|---|
trusted_proxies |
list of strings | [] |
IPs or CIDR ranges of your proxies, e.g. ["10.0.0.1", "172.32.0.0/24"]. Forwarded headers are honored only when the direct peer matches an entry. |
trust_x_forwarded_for |
boolean | true |
Honor X-Forwarded-For from trusted peers. Within the header, the rightmost entry that is not itself a trusted proxy wins (entries to its right were appended by proxies you trust). |
trust_x_real_ip |
boolean | true |
Honor X-Real-IP from trusted peers (used when XFF yields nothing). |
[proxy]trusted_proxies = ["172.32.0.0/24"] # subnet of your LB / ingresstrust_x_forwarded_for = truetrust_x_real_ip = trueWith the default empty trusted_proxies, the trust flags are inert: every
forwarded header is ignored and the direct peer’s address is used — the safe
behavior when no proxy is in front.
Warning: getting this wrong breaks brute-force protection. Login-failure counters are keyed by client IP (
login-failure:{realm}:{username}:{ip}). Iftrusted_proxiesis empty behind an LB, every user appears to come from the LB’s address, so per-IP lockouts aggregate all users into one bucket; if you trusted headers from untrusted peers, attackers could spoof their IP to dodge lockout. List exactly the proxy subnets. See security.md and the LB requirements in CLUSTERING.md.
[cors]
Section titled “[cors]”| Key | Type | Default |
|---|---|---|
allowed_origins |
list of strings | [] |
Cross-origin browser access is fully locked down by default: with an empty list the server emits no CORS headers, so browsers block all cross-origin calls. The embedded admin and account consoles are same-origin and need no CORS; non-browser clients (service-to-service) are unaffected.
Add exact origins — scheme + host + port, no wildcards — only for browser applications hosted on other origins:
[cors]allowed_origins = ["https://app.example.com", "https://spa.internal:3443"]Allowed methods are GET, POST, PUT, PATCH, DELETE, OPTIONS and allowed
headers are Authorization and Content-Type. Entries that are not valid
header values are skipped with a startup warning. Cross-origin applications
also need the origin in their client registration (web_origins); see
client-integration.md.
[cluster]
Section titled “[cluster]”Opt-in multi-node mode. The full guide, including the load balancer contract and the Docker Compose demo, is CLUSTERING.md; this section is the key reference.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Enforce the multi-node contract at boot (below). |
node_id |
string, optional | $HOSTNAME, else a random id |
Stable node identity, used for log correlation only. |
redis_nodes |
list of strings | [] |
Redis Cluster node URLs, e.g. ["redis://r1:6379", "redis://r2:6379"]. When non-empty, overrides the single-node redis URL. |
jwks_refresh_interval_secs |
integer | 30 |
How often the node re-reads the shared signing-key set from storage and reloads its keystore + JWKS snapshot, so keys rotated by peers propagate without a restart. |
[cluster]enabled = truenode_id = "node-1"redis_nodes = []jwks_refresh_interval_secs = 30Boot-time contract: with enabled = true, boot fails unless storage
is PostgreSQL and a Redis cache is configured (redis or
cluster.redis_nodes), with the error
cluster.enabled requires PostgreSQL storage and a Redis cache (set `redis` or `cluster.redis_nodes`).
This refuses
split-brain configurations where, for example, an authorization code issued on
node A could not be redeemed on node B. The JWKS refresh task only runs with
PostgreSQL storage — the other backends are single-node by definition and have
nothing to poll.
[cache]
Section titled “[cache]”One knob governs every read-model cache on the token hot paths.
| Key | Type | Default | Description |
|---|---|---|---|
read_cache_ttl_secs |
integer | 60 |
TTL in seconds of every read-model cache entry. 0 disables all read-model caches (every lookup hits storage — pre-cache behavior). |
The read model keeps per-request work off the database: session-validity
snapshots behind userinfo/introspect, the realm-by-name resolution cache,
the claims read model (per-user claims bundles, the realm role/scope catalog,
client bundles and default scope assignments), and the rendered
userinfo/discovery response caches.
Mutations through the Admin API and the server’s own write paths invalidate
precisely — single-entity changes delete their keys, realm-wide definition
changes bump an epoch — so committed writes are visible immediately. A write
that bypasses both (e.g. a direct database edit) stays hidden for at most
read_cache_ttl_secs: the consciously accepted bounded-staleness window, the
same class as Keycloak’s Infinispan propagation. Set 0 for pure-DB behavior
(useful in tests, or when debugging unexpected staleness).
[oauth]
Section titled “[oauth]”OAuth/OIDC protocol tuning. The whole section is optional.
| Key | Type | Default | Description |
|---|---|---|---|
auth_code_ttl_secs |
integer | 600 |
Lifetime of an authorization code: how long the client has to redeem it at the token endpoint. Accepted range 10–600 seconds; an out-of-range value aborts the boot with a clear error (no silent clamping). |
[oauth]auth_code_ttl_secs = 600The default of 600 s (10 minutes) is the common interoperable value (Keycloak uses the same) and matches every pre-existing deployment — codes minted before an upgrade are unaffected (they keep the TTL they were written with).
Per-realm override. The realm attribute auth_code_ttl_secs (set via the
realm representation’s attributes map, the provision YAML attributes
block, or the admin console’s realm attributes) overrides the server value
for one realm. It must parse as an integer within the same 10–600 bounds; an
absent, malformed, or out-of-range attribute is ignored and the server-wide
value applies.
FAPI 2.0 note. A FAPI 2.0 high-assurance profile requires authorization
codes to expire within 60 seconds — set auth_code_ttl_secs = 60 (globally
or per realm) when assembling such a profile. This exposes only the TTL knob a
profile needs; full FAPI 2.0 conformance (message signing beyond the
already-implemented JARM, mTLS sender-constrained tokens) remains out of
scope.
[dpop]
Section titled “[dpop]”DPoP (RFC 9449) settings. The whole section is optional.
[dpop.nonce]
Section titled “[dpop.nonce]”Server-provided DPoP nonces (RFC 9449 §8/§9) as an opt-in strict mode. With
nonces enabled the server issues an unguessable random value in the
DPoP-Nonce response header; the client echoes it in the nonce claim of its
next proof. Nonces are stored in the distributed cache
(dpop-nonce:{realm}:{nonce}, TTL = lifetime_secs) and are single-use:
the first proof presenting a nonce consumes it, and every proof-carrying
response issues the next one. Without the section the behavior is exactly the
pre-feature one: replay protection rides on single-use jti plus the proof
acceptance window.
| Key | Type | Default | Description |
|---|---|---|---|
mode |
string | "disabled" |
"disabled" — no nonces issued or verified. "supported" — a fresh nonce rides every response to a proof-carrying request; proofs without a nonce claim are accepted, but a proof carrying an unknown/stale/used nonce is challenged. "required" — every proof must carry a live server-issued nonce; absence or an unknown/stale/used value is rejected. |
lifetime_secs |
integer | 30 |
Nonce lifetime (the cache TTL). Accepted range 5–300 seconds; an out-of-range value aborts the boot with a clear error. |
[dpop.nonce]mode = "disabled" # "disabled" | "supported" | "required"lifetime_secs = 30Environment equivalents: ISSUERD_DPOP__NONCE__MODE and
ISSUERD_DPOP__NONCE__LIFETIME_SECS.
Challenge contract. On a nonce-gate failure the client receives the
RFC 9449 retry signal — the token endpoint answers 400 with
{"error": "use_dpop_nonce"}, the resource endpoint (userinfo) answers 401
with WWW-Authenticate: DPoP error="use_dpop_nonce", and both carry a fresh
nonce in the DPoP-Nonce header. Compliant clients retry with that nonce and
recover transparently. Requests without a DPoP proof header (plain Bearer
flows) are never affected by the mode. Suggested rollout: supported first
(clients learn nonces, nothing breaks), then required (captured proofs
become useless within seconds — a fresh iat/jti cannot substitute for the
server-issued nonce). Nonces are realm-scoped and shared between the token
endpoint and userinfo (Issuerd is both the authorization and the resource
server); a nonce issued by one endpoint is valid at the other until consumed
or expired. RFC 9449 defines no discovery metadata for nonce support, so none
is advertised — clients discover the requirement from the
use_dpop_nonce/DPoP-Nonce signals.
MCP (Model Context Protocol) surface. The flag gates three pieces:
- The read-only MCP endpoint at
POST /realms/{realm}/mcp, with 15 curated tools, dual-era stateless transport, exact audience/provenance and DPoP enforcement, and distributed invocation limits. See mcp-server.md for the complete contract and client rigs. - RFC 9728 protected-resource metadata. The bare
GET /.well-known/oauth-protected-resourceis an explicitresource_metadataalias for the master realm, not origin-level metadata. The canonical per-realm URL isGET /.well-known/oauth-protected-resource/{issuer-path}/realms/{realm}/mcp(the issuer path is empty whenissuer_urlhas no path prefix). Metadata is served only for canonical realm names; legacy UUID aliases and mismatched paths answer 404. - RFC 8707 resource indicators at the authorization endpoint (plain,
PAR-pushed, and JAR request-object requests) and the token endpoint’s
authorization_code/refresh_tokengrants. The allowlist holds exactly one canonical realm-name value per realm —{issuer_url}/realms/{realm}/mcp, compared byte-for-byte. Each request may carry at most oneresource; repeated values are rejected withinvalid_targetbefore query/form maps can collapse them. A granted resource becomes the access token’saud([client_id, resource], client id first) and trustedresourceclaim for resource-server validation, and rides the refresh token as aresourceclaim. Code redemption may restate it, omit it (inherit), or introduce it when the code carried none; refresh may only restate or omit. Every other grant refusesresourcewhen MCP is enabled.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Master switch for the MCP surface. While false the MCP and metadata routes answer 404 (indistinguishable from an unknown realm). Supported authorization/code/refresh flows carrying resource fail closed with invalid_target; legacy token-exchange requests keep their historical ignore behavior. Requests without resource are unchanged. |
require_dpop |
boolean | true |
Require DPoP-bound tokens and proofs. Set false only for explicit Bearer compatibility; strict operation-bound step-up still requires DPoP. |
invocation_rate_limit_per_minute |
integer | 60 |
Distributed tool-invocation allowance for each client and subject per minute. |
allowed_origins |
list of strings | [] |
Exact browser origins allowed at the MCP transport. Empty rejects every present Origin when validation is enforced; non-browser requests without Origin remain supported. Set list values in the config file, not through a single environment variable. |
step_up.enabled |
boolean | false |
Independent [mcp.step_up] rollout gate for operation-bound behavior; it does not follow [mcp].enabled. Enable only after every cluster node and approval route supports it. |
[mcp]enabled = falserequire_dpop = trueinvocation_rate_limit_per_minute = 60allowed_origins = []
[mcp.step_up]enabled = falseEnvironment equivalents include ISSUERD_MCP__ENABLED,
ISSUERD_MCP__REQUIRE_DPOP,
ISSUERD_MCP__INVOCATION_RATE_LIMIT_PER_MINUTE, and
ISSUERD_MCP__STEP_UP__ENABLED.
The document advertises the realm issuer URL as the authorization server, only
mcp:read as the minimal basic scope, the actual DPoP requirement, and the
DPoP proof-signing algorithms (identical to
dpop_signing_alg_values_supported in the OIDC discovery document). Strict
DPoP omits bearer_methods_supported; explicit Bearer compatibility
advertises only ["header"]. Runtime scope challenges name the exact ordinary
scopes for the attempted operation and may therefore include mcp:write
without advertising it as a basic PRM scope. Operation-bound approval uses a
separate authentication challenge, not a general mcp:step_up scope challenge.
When MCP is enabled, its scopes require an administrator’s explicit optional assignment:
anonymous DCR rejects them in either scope list, and Admin API, provisioning,
and realm-default paths reject default assignments. With MCP disabled, legacy
custom scope names keep the previous default∪optional behavior.
Before enabling MCP, every cluster node must run the resource-aware version
(older nodes drop resource when rotating refresh tokens), and legacy custom
MCP scope definitions must be reconciled. Startup validates existing scopes
and assignments and aborts on mapper/role-bearing custom scopes, default
assignments, or open-registered clients carrying MCP scopes. MCP-disabled
legacy deployments do not run this validation and keep booting unchanged. See
backup-and-upgrade.md.
[crypto.key_encryption]
Section titled “[crypto.key_encryption]”Envelope encryption for the cluster-wide JWT signing keys at rest
(PostgreSQL storage only). Without this section the signing_keys table holds
private key material (PKCS#8 DER) in plaintext, and a database dump yields
keys that can mint tokens for every realm. With it, PostgresStorage
encrypts each key with a config-provided Key Encryption Key (KEK) before
writing (AES-256-GCM, random per-key nonce), and the table only ever holds
ciphertext. Decryption is transparent on read; the KEK is never stored in the
database.
| Key | Type | Default | Description |
|---|---|---|---|
key_id |
string | — | Identifier of the active KEK, persisted on every encrypted row (kek_kid) so a later rotation knows which KEK must decrypt it. Non-empty, ≤ 64 chars. |
key_base64 |
string | — | Base64-encoded 32-byte KEK (AES-256). Generate with openssl rand -base64 32. Prefer the env var over committing it to the file. |
previous_keys |
array of tables | [] |
Retired KEKs (key_id + key_base64) accepted for decryption only — the KEK-rotation window. File-only setting (env vars cannot index into arrays). |
[crypto.key_encryption]key_id = "kek-2026-01"key_base64 = "…" # prefer ISSUERD_CRYPTO__KEY_ENCRYPTION__KEY_BASE64
# KEK rotation window: the retired KEK stays until every row is re-encrypted.# [[crypto.key_encryption.previous_keys]]# key_id = "kek-2025-01"# key_base64 = "…"Environment equivalents: ISSUERD_CRYPTO__KEY_ENCRYPTION__KEY_ID and
ISSUERD_CRYPTO__KEY_ENCRYPTION__KEY_BASE64.
Behavior contract:
- Absent section = current plaintext behavior, plus a startup WARN on
PostgreSQL deployments (
signing keys are stored in PLAINTEXT …). Existing deployments boot unchanged. - First enablement / rotation: at boot — after migrations, before the
keystore loads — a sweep re-encrypts every row that is still plaintext or
was encrypted under a non-active KEK (
re-encrypted signing keys at restlog line with the row count). New keys and every admin rotation (keys/rotate,keys/{kid}/disable) are written encrypted from the start. - Fail closed: a boot whose KEK cannot decrypt a row (wrong key bytes,
unknown
kek_kid) aborts with asigning-key encryption errornaming the rowkidandkek_kid. There is never a plaintext fallback. An invalid section (bad base64, key not exactly 32 bytes, empty/duplicatekey_id) also aborts boot. - Mixed-version clusters: rows written by a KEK-enabled binary have
private_der = NULL, which a pre-encryption binary cannot read. Enable the section only after every node runs the new version, and give all nodes the identical section (see CLUSTERING.md). - KEK rotation: set the new
key_id/key_base64, move the old pair intoprevious_keys, roll the config to all nodes and restart — each boot sweep re-encrypts the rows under the new KEK. Verify withSELECT DISTINCT kek_kid FROM signing_keys, then drop theprevious_keysentry. Full procedure in backup-and-upgrade.md. - PostgreSQL-only: with the in-memory or JSON-file backends the section is ignored (boot WARN) — protect JSON snapshots at the filesystem level; they hold plaintext keys by design (dev/manual-test backend).
- Memory-hygiene limits: decrypted key material is zeroized on drop
(
StoredSigningKey, the keystore’s retired keys, and transient buffers). What cannot be zeroized is the resident signing key while it is in use and the internal copiesring/jsonwebtokenmake per sign call — the guarantee is “no additional long-lived plaintext copies beyond the resident key”.
[outbound_http]
Section titled “[outbound_http]”Trust roots for outbound HTTPS calls. Issuerd talks to other servers when
it fetches identity-provider metadata and JWKS documents, client jwks_url
documents and pairwise sector_identifier_uri documents, and when it delivers
back-channel logout notifications. All of these ride reqwest/rustls with the
public webpki root store; a deployment behind a private CA (corporate TLS
inspection, an internal CA, a hermetic test rig) adds its roots here.
| Key | Type | Default | Description |
|---|---|---|---|
extra_ca_bundle |
path | unset | PEM file with additional root CA certificates (the file may hold several). Additive to the public roots. A missing file, unparseable content, or a bundle with zero valid certificates aborts the boot — fail closed, so a typo never silently falls back to public roots only. |
allow_private_addresses |
bool | false |
Test rigs only. Lifts the public-IP check of the SSRF-guarded outbound fetches (client jwks_url documents, pairwise sector_identifier_uri documents, JAR-by-reference request_uri objects), so they may dial private/loopback/link-local addresses — e.g. the hermetic OIDC conformance network. The https-only scheme check, the no-redirect policy, the body-size cap, and the timeout stay enforced. Never enable in production: that check is what keeps those attacker-influenced URLs from probing the internal network. |
allow_private_identity_providers |
bool | false |
Breaking default — set it for an IdP on the internal network. By default the identity-broker calls — discovery, JWKS, token, userinfo, and the admin test-connection probe — only dial publicly routable addresses: every address a hostname resolves to, and every redirect hop, is checked (an IdP address that is private, loopback, link-local, CGNAT or otherwise non-public is refused as “identity provider unreachable”), so an admin who can configure an identity provider (manage-realm in any realm, master included) cannot aim server-side requests at the internal network. Only https IdPs work by default, and redirects never downgrade HTTPS to HTTP. Set allow_private_identity_providers = true for an internal development IdP that needs plain HTTP; otherwise redirects between public HTTPS hosts are followed. When an outbound proxy is set through HTTP_PROXY/HTTPS_PROXY/ALL_PROXY, the IdP host is resolved and checked locally before the proxied request (it must resolve on this host) and only same-host redirects are followed; without such variables, IdP calls do not use OS-configured proxies. Set true (internal Keycloak/ADFS, Keycloak in Docker, a localhost dev IdP) to restore the unrestricted behaviour — enabling it hands that reach back to every such admin. Responses are capped at 1 MiB in both modes. Independent of allow_private_addresses. |
[outbound_http]extra_ca_bundle = "/etc/issuerd/corp-root-ca.pem"Environment equivalent: ISSUERD_OUTBOUND_HTTP__EXTRA_CA_BUNDLE, ISSUERD_OUTBOUND_HTTP__ALLOW_PRIVATE_IDENTITY_PROVIDERS, and ISSUERD_OUTBOUND_HTTP__ALLOW_PRIVATE_ADDRESSES for the test-rig flag.
[themes]
Section titled “[themes]”| Key | Type | Default |
|---|---|---|
dir |
path | "themes" |
Root directory of login-theme assets, one subdirectory per theme. Relative
paths resolve against the daemon’s working directory. The repository ships the
built-in default theme at themes/issuerd.
- A realm’s
login_themesetting (realm configuration in the admin console / Admin API — see administration.md) selects the directory; assets are served at/realms/{realm}/theme/{path}. - Fallback is per file: anything a custom theme does not provide is served
from the built-in
issuerdtheme, so a custom theme only ships the files it overrides (e.g. justlogin.css). The default theme’slogin.cssis also compiled into the binary, so deployments without a mounted themes directory (the container image,cargo install) still serve it; a{themes.dir}/issuerd/login.csson disk takes precedence when present. - A theme may also carry
messages_{locale}.jsonbundles that merge over the built-in login/email translations — see the next section.
Localization (message bundles)
Section titled “Localization (message bundles)”Every server-rendered login-flow page — the login challenges (TOTP, email
code, WebAuthn/passkey), the CONFIGURE_TOTP enrollment, the required-action
pages (UPDATE_PASSWORD, UPDATE_PROFILE, TERMS_AND_CONDITIONS, VERIFY_EMAIL),
registration, reset-credentials, the broker first-login pages and the error
pages — and every outbound email (verify-email, reset-credentials, login-code,
execute-actions) is rendered from a flat key → text message bundle. Bundles
load in layers with per-key fallback, so a partial custom bundle only
overrides the keys it defines:
- built-in English (compiled in, always complete — the canonical key list is
the source-tree
crates/issuerd-server/i18n/messages_en.json), - built-in
{locale}(compiled in; shipped locales:en,de,fr,pt-BR,zh—pt-BRis Brazilian Portuguese,frserves everyfr-*region, andzhis Simplified-script Chinese and serves everyzh-*region), - theme English override (
{themes.dir}/{theme}/messages_en.json), - theme locale override (
{themes.dir}/{theme}/messages_{locale}.json), - theme full-tag override (
messages_{locale-with-region}.json, e.g.messages_de-AT.json) — when the resolved locale carries region/script subtags, its full tag layers over the primary subtag (Keycloak’s lookup order), so a regional variant only ships the keys it refines.
theme is the realm’s login_theme for pages and email_theme for emails;
when unset, the default theme issuerd is read, so dropping a bundle into
{themes.dir}/issuerd/ localizes every realm that has no theme configured —
no realm change needed. A key missing from the resolved locale’s layers keeps
the English text.
The UI locale resolves per request (Keycloak precedence): the ui_locales
authorization parameter (pinned on the paused login flow) → the
Accept-Language header → the realm’s default locale → en. The whole chain
is gated on the realm’s internationalization toggle, and candidates are
restricted to the realm’s supported locales (empty = unrestricted). The
resolved locale is not limited to the compiled-in set — theme-supplied locales
work everywhere. Outbound email resolves the recipient’s locale user
attribute first, then the realm default.
Enable it per realm in the admin console (Realm Settings → Localization;
the locales checkboxes list the compiled-in locales plus every
messages_*.json discovered under [themes] dir at boot — a bundle dropped
in later renders immediately but appears in the checkbox list only after a
server restart — the same list GET /admin/enums/locales and
GET /admin/serverinfo expose), or via the
Admin API / provision YAML realm keys internationalization_enabled,
supported_locales, default_locale (see provisioning.md).
To add a locale, drop a messages_<locale>.json into a theme directory —
e.g. {themes.dir}/issuerd/messages_es.json with { "login.title": "Iniciar sesión" } —
then enable internationalization on the realm and add the locale to its
supported locales (it appears in the console checkbox list automatically).
To refine a locale for one region, add a full-tag bundle next to it —
messages_de-AT.json overrides messages_de.json per key for de-AT
requests only, and regional file names surface in the locale lists under
their full tag.
Theme bundle files are plain JSON, re-read on every request, and treated as
deployment-local content: the [themes] dir is data, not code, and
themes/*/messages_*.json is gitignored by default. Note the admin console
and account console SPAs remain English-only; localization covers the
login-flow pages and outbound email.
[smtp]
Section titled “[smtp]”Email is used for address verification, password reset, execute-actions emails, and the optional email-code login flow.
| Key | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | false |
Master switch. |
host |
string | "127.0.0.1" |
SMTP server hostname/IP. |
port |
integer | 25 |
SMTP server port. |
from |
string | "issuerd@localhost" |
Envelope/header sender address. |
from_display |
string, optional | unset | Sender display name. |
reply_to |
string, optional | unset | Reply-To address. |
starttls |
boolean | false |
Upgrade the connection with STARTTLS. |
ssl |
boolean | false |
Implicit TLS (SMTPS) from connect. |
username / password |
string, optional | unset | SMTP AUTH credentials. |
[smtp]enabled = truehost = "mail.example.com"port = 587from_display = "Example ID"starttls = trueusername = "issuerd"password = "SECRET" # prefer ISSUERD_SMTP__PASSWORD over committing thisDisabled-by-default semantics: with enabled = false the server wires a
no-op sender, and any flow that needs to send mail fails loudly with
SMTP is not configured: set [smtp] enabled = true ... rather than silently
dropping the message. Do not turn on realm email features (email verification,
reset credentials, email-code login) before SMTP works, or users will hit that
error at login/registration.
Per-realm overrides. Following the Keycloak model, a realm can override
every SMTP value except enabled (which stays a global gate) through realm
attributes named smtpServer.*, set via the Admin API / console (see
administration.md):
| Realm attribute | Overrides config key |
|---|---|
smtpServer.host |
host |
smtpServer.port |
port |
smtpServer.from |
from |
smtpServer.fromDisplayName |
from_display |
smtpServer.replyTo |
reply_to |
smtpServer.starttls |
starttls ("true" / anything-else) |
smtpServer.ssl |
ssl |
smtpServer.user |
username |
smtpServer.password |
password |
Note the attribute names are camelCase and the credential attribute is user,
not username. Realms without these attributes use the global config.
The global username/password are inherited only while the realm’s
effective host, port, ssl and starttls all equal the global values. A
realm that points SMTP at another host, port or TLS mode must set its own
smtpServer.user/smtpServer.password; otherwise it connects without
authentication, so the operator’s relay credentials never reach a
realm-chosen server.
Token signing algorithm
Section titled “Token signing algorithm”There is no config-file key for the token-signing algorithm — it is an operational property of the shared signing-key set plus one realm attribute.
- Server default: EdDSA (Ed25519). On first boot (empty
signing_keystable) the server generates and persists an EdDSA key AND an RS256 (2048-bit) key, both active (bootstrap_crypto_providerincrates/issuerd-server/src/state.rs). The EdDSA key is the signing default: realms without an explicit setting sign with the newest active key of the server-default algorithm (CryptoConfig::default_alg). The RS256 key exists because OIDC Core §15.1 makes RS256 mandatory-to-implement — discovery must advertise it inid_token_signing_alg_values_supported, and realms explicitly pinned to RS256 work out of the box. Fresh deployments therefore issue EdDSA tokens by default while still supporting and advertising RS256. - Per-realm override. The realm attribute
default_signature_algorithm(set via the realm representation’sattributesmap, the provision YAMLattributesblock, or the admin console’s Tokens/Keys pages) pins a realm to one ofRS256/RS384/RS512,ES256/ES384/ES512,EdDSA. SymmetricHS*values are not applicable to realm token signing and are ignored (an HMAC key publishes no usable public verification material), as are unknown values. If no active key of the chosen algorithm exists, issuance falls back to the newest active key overall — rotate a key of that algorithm first (POST /admin/realms/{realm}/keys/rotate). - RS256 as an explicit compatibility choice. RSA remains fully supported
for clients that cannot consume Ed25519/ECDSA keys: fresh deployments
already carry an active RS256 boot key, and additional RSA keys can be
rotated in (
{"algorithm": "RS256", "key_size": 2048}); pin the realm attribute toRS256to use them. Note on the crypto backend: RS256/384/512 signing runs through the FIPS-validatedaws-lc-rslibrary (jsonwebtoken’saws_lc_rsbackend), and RSA key generation uses OpenSSL — the pure-Rustrsacrate affected byRUSTSEC-2023-0071(Marvin; RSA private-key operations incl. PKCS#1 v1.5 signing) is not in the compiled dependency graph at all, so the advisory carries no ignore entry indeny.toml. - Upgrading existing deployments. Stored keys are never touched by an
upgrade: an existing RS256-only key set keeps signing RS256 for
un-configured realms (the EdDSA default finds no matching key and falls
back). Rotating in an EdDSA key switches un-configured realms to EdDSA —
pin
default_signature_algorithm=RS256on realms that must stay on RSA before doing so. (The EdDSA+RS256 initial pair is only generated on a truly emptysigning_keystable; upgrades never gain keys implicitly.)
Complete annotated example
Section titled “Complete annotated example”This is examples/issuerd.example.toml with every key explained. It is a
reference, not a runnable default — it assumes PostgreSQL, Redis, and
certificate files exist. Generate a fresh copy with the CLI (next section).
# ---- Root keys -------------------------------------------------------------bind = "0.0.0.0" # listen interface (IP literal; "::" for IPv6 any)port = 8080 # listen portissuer_url = "http://localhost:8080" # PUBLIC base URL; baked into iss — see "About issuer_url"redis = "redis://localhost:6379" # ephemeral state: auth codes, pending flows, revocation, login-failure countersprovision = "provision.yaml" # applied exactly once at first startup; remove to start empty
# ---- TLS -------------------------------------------------------------------# Omit the whole section for plain HTTP (e.g. behind a TLS-terminating proxy).[tls]cert_path = "certs/issuerd.test.internal.crt" # PEM certificate chainkey_path = "certs/issuerd.test.internal.key" # PEM private key
# ---- Storage ---------------------------------------------------------------# Omit [storage] entirely for the in-memory default (dev only, empty per boot).# Alternative: [storage.json_file] path = "issuerd-data.json" (single-node).[storage.postgres]url = "postgres://issuerd:issuerd_secret@localhost:5433/issuerd"# Migrations run automatically at boot. With PostgreSQL, create the master# realm via the provision file — automatic bootstrap only covers in-memory/json.
# ---- Logging ---------------------------------------------------------------# Honored at startup; RUST_LOG and -v/-q flags take precedence (see [logging]).[logging]format = "pretty"level = "info"
# ---- Web UI ----------------------------------------------------------------# Parsed but currently NOT honored — consoles are served when embedded at build.[web_ui]enabled = true
# ---- Reverse proxy ---------------------------------------------------------[proxy]trusted_proxies = [] # LB/ingress IPs or CIDRs, e.g. ["10.0.0.0/8"]; required to honor the headers belowtrust_x_forwarded_for = truetrust_x_real_ip = true
# ---- CORS ------------------------------------------------------------------[cors]allowed_origins = [] # empty = browsers blocked cross-origin; add "https://app.example.com" style origins
# ---- Cluster ---------------------------------------------------------------[cluster]enabled = false # true REQUIRES [storage.postgres] + redis (boot fails otherwise)redis_nodes = [] # Redis Cluster URLs; non-empty overrides the `redis` key abovejwks_refresh_interval_secs = 30 # signing-key set polling interval (PostgreSQL only)# node_id = "node-1" # optional; defaults to $HOSTNAME; log correlation only
# ---- SMTP ------------------------------------------------------------------# Disabled by default; email flows fail loudly until enabled and reachable.[smtp]enabled = falsehost = "127.0.0.1"port = 1025 # 25 default; 1025 here matches a local Mailpit-style sinkfrom_display = "Issuerd"starttls = falsessl = false# reply_to = "[email protected]"# username = "issuerd"# password = "SECRET"
# ---- Cache ------------------------------------------------------------------[cache]read_cache_ttl_secs = 60 # read-model cache TTL; 0 disables all read-model caches (pure-DB behavior)
# ---- OAuth/OIDC protocol tuning ----------------------------------------------[oauth]auth_code_ttl_secs = 600 # authorization-code redemption window; 10-600, FAPI 2.0 profiles want <= 60
# ---- DPoP (RFC 9449) ----------------------------------------------------------[dpop.nonce]mode = "disabled" # server-provided proof nonces: "disabled" | "supported" | "required"lifetime_secs = 30 # single-use nonce TTL; 5-300
# ---- MCP ----------------------------------------------------------------------# Groundwork for the MCP endpoint: gates the RFC 9728 protected-resource# metadata routes (/.well-known/oauth-protected-resource — MCP clients read it# to discover the authorization server) and RFC 8707 resource indicators (the# `resource` parameter; the only allowlisted value is the realm's MCP resource# URI). The routes 404 and supported flows reject `resource` while disabled;# legacy token exchange keeps its historical ignore behavior.[mcp]enabled = falserequire_dpop = trueinvocation_rate_limit_per_minute = 60allowed_origins = [] # empty = reject every present Origin; non-browser clients omit Origin
[mcp.step_up]enabled = false # independent operation-bound rollout gate
# ---- Signing-key encryption at rest ------------------------------------------# Omitted = signing keys stored in PLAINTEXT in PostgreSQL (startup WARN).# Uncomment to envelope-encrypt them (AES-256-GCM; KEK from env, never the DB).# Enable only after EVERY cluster node runs a version that supports it.# [crypto.key_encryption]# key_id = "kek-2026-01" # persisted on each row; change on KEK rotation# key_base64 = "..." # 32 bytes, base64 — prefer env:# # ISSUERD_CRYPTO__KEY_ENCRYPTION__KEY_BASE64# [[crypto.key_encryption.previous_keys]] # retired KEKs, decrypt-only (rotation window)# key_id = "kek-2025-01"# key_base64 = "..."
# ---- Outbound HTTP ----------------------------------------------------------# Omitted = outbound HTTPS (IdP/JWKS/sector fetches, back-channel logout)# trusts the public webpki roots only. Add a bundle for private-CA endpoints;# a bad path/empty bundle aborts the boot.# [outbound_http]# extra_ca_bundle = "certs/corp-root-ca.pem" # PEM file, may hold several roots
# ---- Themes -----------------------------------------------------------------[themes]dir = "themes" # one subdirectory per login themeGenerating the example config
Section titled “Generating the example config”The binary writes a fully populated example itself — the committed
examples/issuerd.example.toml is produced the same way, and a unit test in
crates/issuerd-server/src/config.rs verifies it stays loadable:
# TOML (extension chooses the format: .toml, .yaml/.yml, .json)issuerd example server-config -o my.toml
# YAMLissuerd example server-config -o my.yaml
# The provision file has a generator too (see provisioning.md)issuerd example provision-config -o provision.yamlThe generated file enables TLS and PostgreSQL with the same placeholder values
shown above — treat it as a checklist to edit, not as-is configuration. The
-o default is examples/issuerd.example.toml (the repo’s committed
reference), so pass an explicit output path.