Skip to content
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.

Configuration is assembled from three layers, later layers winning:

  1. 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.

  2. Config file — passed with -c / --config:

    Terminal window
    issuerd daemon -c /etc/issuerd/issuerd.toml

    The default path is issuerd.toml, resolved against the daemon’s working directory. The same flag exists on issuerd provision (which reads the config only for the storage connection and issuer_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.

  3. 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=9090 port
    ISSUERD_BIND=127.0.0.1 bind
    ISSUERD_ISSUER_URL=https://idp.example.com issuer_url
    ISSUERD_REDIS=redis://redis:6379 redis
    ISSUERD_CLUSTER__ENABLED=true cluster.enabled
    ISSUERD_CLUSTER__JWKS_REFRESH_INTERVAL_SECS=15 cluster.jwks_refresh_interval_secs
    ISSUERD_CACHE__READ_CACHE_TTL_SECS=0 cache.read_cache_ttl_secs
    ISSUERD_OAUTH__AUTH_CODE_TTL_SECS=60 oauth.auth_code_ttl_secs
    ISSUERD_DPOP__NONCE__MODE=required dpop.nonce.mode
    ISSUERD_SMTP__ENABLED=true / ISSUERD_SMTP__HOST=mail.example.com smtp.enabled / smtp.host
    ISSUERD_PROXY__TRUST_X_FORWARDED_FOR=false proxy.trust_x_forwarded_for
    ISSUERD_BOOTSTRAP_ADMIN_PASSWORD=... bootstrap_admin_password

    The shipped cluster stack uses this mechanism for per-node identity (ISSUERD_CLUSTER__NODE_ID in docker-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 documents cluster.redis_nodes as TOML-only, see CLUSTERING.md).

Behavioral notes:

  • Missing file is not an error. If the -c path 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 the loaded configuration startup 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 with config 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).

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 = 8080
issuer_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 subnet

Modeled 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.

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.

This is the most consequential value in the file:

  • It must be an absolute http(s) URL — anything else aborts the boot with issuer_url must be an absolute http(s) URL.
  • It is baked into every token’s iss claim and into the OIDC discovery document as {issuer_url}/realms/{realm-name} (the realm name, never the internal UUID). Clients validate iss strictly, so issuer_url must be exactly how browsers and applications reach the server — scheme, host, and port included. If you terminate TLS at a proxy, issuer_url keeps the public https scheme 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 Secure attribute on every authentication cookie the server sets (SSO session issuerd_session_{realm-id}, remember-me issuerd_remember_{realm-id}, the issuerd_flow_{id} flow correlation cookie, and the logout clears): https ⇒ all cookies carry Secure; 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.

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 provision subcommand), 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_keys table.
# 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_keys table — 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.

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:

  1. RUST_LOG — when set, it wins completely. Any standard tracing EnvFilter directive string is accepted, e.g. RUST_LOG=issuerd_server=debug,issuerd_storage=warn for per-crate control.
  2. -v / -q CLI flags (global, repeatable): default is info; -v → debug; -vv (or more) → trace; -q → warn; -qq (or more) → error.
    Terminal window
    issuerd -v daemon -c issuerd.toml
  3. [logging].level from 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:

  • format applies to the console output only. On Linux under systemd (when JOURNAL_STREAM is 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 log facade (ldap3, redis, reqwest, rustls, sqlx, …) are bridged into tracing and filtered by the same directives.

See monitoring.md for metrics and health endpoints.

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.

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 / ingress
trust_x_forwarded_for = true
trust_x_real_ip = true

With 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}). If trusted_proxies is 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.

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.

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 = true
node_id = "node-1"
redis_nodes = []
jwks_refresh_interval_secs = 30

Boot-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.

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/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 = 600

The 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 (RFC 9449) settings. The whole section is optional.

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 = 30

Environment 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-resource is an explicit resource_metadata alias for the master realm, not origin-level metadata. The canonical per-realm URL is GET /.well-known/oauth-protected-resource/{issuer-path}/realms/{realm}/mcp (the issuer path is empty when issuer_url has 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_token grants. 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 one resource; repeated values are rejected with invalid_target before query/form maps can collapse them. A granted resource becomes the access token’s aud ([client_id, resource], client id first) and trusted resource claim for resource-server validation, and rides the refresh token as a resource claim. 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 refuses resource when 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 = false
require_dpop = true
invocation_rate_limit_per_minute = 60
allowed_origins = []
[mcp.step_up]
enabled = false

Environment 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.

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 rest log 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 a signing-key encryption error naming the row kid and kek_kid. There is never a plaintext fallback. An invalid section (bad base64, key not exactly 32 bytes, empty/duplicate key_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 into previous_keys, roll the config to all nodes and restart — each boot sweep re-encrypts the rows under the new KEK. Verify with SELECT DISTINCT kek_kid FROM signing_keys, then drop the previous_keys entry. 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 copies ring/jsonwebtoken make per sign call — the guarantee is “no additional long-lived plaintext copies beyond the resident key”.

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.

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_theme setting (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 issuerd theme, so a custom theme only ships the files it overrides (e.g. just login.css). The default theme’s login.css is 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.css on disk takes precedence when present.
  • A theme may also carry messages_{locale}.json bundles that merge over the built-in login/email translations — see the next section.

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:

  1. built-in English (compiled in, always complete — the canonical key list is the source-tree crates/issuerd-server/i18n/messages_en.json),
  2. built-in {locale} (compiled in; shipped locales: en, de, fr, pt-BR, zh — pt-BR is Brazilian Portuguese, fr serves every fr-* region, and zh is Simplified-script Chinese and serves every zh-* region),
  3. theme English override ({themes.dir}/{theme}/messages_en.json),
  4. theme locale override ({themes.dir}/{theme}/messages_{locale}.json),
  5. 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.

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 = true
host = "mail.example.com"
port = 587
from_display = "Example ID"
starttls = true
username = "issuerd"
password = "SECRET" # prefer ISSUERD_SMTP__PASSWORD over committing this

Disabled-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.

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_keys table) the server generates and persists an EdDSA key AND an RS256 (2048-bit) key, both active (bootstrap_crypto_provider in crates/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 in id_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’s attributes map, the provision YAML attributes block, or the admin console’s Tokens/Keys pages) pins a realm to one of RS256/RS384/RS512, ES256/ES384/ES512, EdDSA. Symmetric HS* 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 to RS256 to use them. Note on the crypto backend: RS256/384/512 signing runs through the FIPS-validated aws-lc-rs library (jsonwebtoken’s aws_lc_rs backend), and RSA key generation uses OpenSSL — the pure-Rust rsa crate affected by RUSTSEC-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 in deny.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=RS256 on realms that must stay on RSA before doing so. (The EdDSA+RS256 initial pair is only generated on a truly empty signing_keys table; upgrades never gain keys implicitly.)

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 port
issuer_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 counters
provision = "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 chain
key_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 below
trust_x_forwarded_for = true
trust_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 above
jwks_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 = false
host = "127.0.0.1"
port = 1025 # 25 default; 1025 here matches a local Mailpit-style sink
from_display = "Issuerd"
starttls = false
ssl = 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 = false
require_dpop = true
invocation_rate_limit_per_minute = 60
allowed_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 theme

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:

Terminal window
# TOML (extension chooses the format: .toml, .yaml/.yml, .json)
issuerd example server-config -o my.toml
# YAML
issuerd example server-config -o my.yaml
# The provision file has a generator too (see provisioning.md)
issuerd example provision-config -o provision.yaml

The 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.