Configuration
ThunderID's behavior is controlled by a combination of default configurations and deployment-specific overrides. This page covers all available configuration options and how to customize them.
Configuration System
ThunderID uses a two-tier configuration system:
- Default Configuration. Provides sensible defaults for all settings. These are built into ThunderID.
- Deployment Configuration. Located at
deployment.yamlin your ThunderID installation directory. This file overrides specific defaults for your deployment.
Only settings you want to override need to be specified in deployment.yaml. All other values will use the built-in defaults.
ThunderID must be restarted after any configuration change.
Server Configuration
Controls the ThunderID server's network settings and identity.
| Setting | Default | Description |
|---|---|---|
server.hostname | localhost | The hostname or IP address the server binds to |
server.port | 8090 | Port the server listens on |
server.http_only | false | If true, disables HTTPS and uses HTTP only (not recommended for production) |
server.public_url | (derived from server hostname, port and protocol) | The public URL clients use to reach the server, if it differs from the bind address. Derived when unset. |
server.identifier | default-deployment | Unique identifier for this deployment instance. Every stored resource is scoped by it, so changing it on a server that already holds data hides that data rather than renaming it. |
gateway.max_gateways | 1 | Guards how many gateways this deployment administers, through the Gateway API (POST /gateways) or a gateway declared in a file. A standalone deployment pairs with one; raise it only where something else manages the fleet, or set it to 0 for a deployment that administers none. A registration beyond the number is refused with 409. This is a guard rather than a guarantee: two registrations arriving at the same moment can leave one more gateway than configured, so do not rely on it as a licensing or security boundary. |
gateway.max_versions | 3 | How many captured configuration versions this deployment keeps (POST /configuration-versions). Capturing one beyond the number removes the oldest, except a version some gateway holds or could be reverted to. Those are kept even when that leaves more versions than the number, so applying and reverting keep working whatever it is. The number is never less than one. |
gateway.allow_private_addresses | false | Whether a gateway may be registered at, and reached on, a loopback, link-local or private address such as localhost, 10.0.0.5 or 169.254.169.254. Off, a base URL naming one is refused with 400 (GTW-1027), and a hostname that resolves to one is refused when the gateway is called, so this deployment's calls, which carry the gateway's key, cannot be pointed at something inside its own network. Turn it on for a local or single-host setup whose gateways sit on such addresses. A base URL must be https and carry no credentials whatever this is set to. |
gateway.removal_confirmation_threshold | 5 | How many resources an apply or revert may remove from a gateway without being confirmed. One that would remove more is refused with 409 (GTW-1030) unless the request sets confirmRemovals; a dry run shows the removals first. A rollback to a much smaller version is the usual case. 0 turns the check off. |
gateway.store | composite | Where gateways are read from. mutable is the database alone, and gateways declared in files are not read. declarative is files alone, and registration through the API is refused. composite is both: listings merge the two, registration writes to the database, and a gateway declared in a file cannot be changed or removed through the API, because the file is read again on every start and would undo it. |
notification.template.store | composite | Where notification templates are read from. mutable is the database alone, and templates are editable through the Console and API. declarative is files alone, and writes through the API are refused with 403. composite is both: file-declared templates are read-only and are merged with editable database templates. |
Resources are stored against this identifier, so it is fixed for the life of a deployment's data.
Every read and write acts for one deployment: the one a request carries, which on a server that holds one
deployment is always server.identifier. That covers every stored resource, the gateways a deployment
registers, its configuration versions, the version each gateway runs and the changes a gateway is set to
leave alone. A server built to serve several deployments from one process keeps each one's gateways and
versions apart by that identifier, so none sees another's.
One registered gateway is the default gateway, marked isDefault in the Gateway API. The first gateway registered through POST /gateways becomes the default. Registering another with isDefault: true moves the default to it, so no more than one gateway is the default at a time. The Console shows the default gateway's baseUrl in the runtime endpoints it lists for applications and agents. With no default gateway, it shows the server's own URL. A gateway declared in a file is never the default.
When server.public_url is set, the gate_client settings below default to the corresponding hostname, port, and protocol from that URL. Most deployments only need to configure the server section.
Gate Client Configuration
Configures the connection to ThunderID Gate (the login UI). Every setting is optional. By default, the gate_client settings are derived from server.public_url, so you only need to configure this section when Gate is hosted separately from the server.
| Setting | Default | Description |
|---|---|---|
gate_client.hostname | (server's hostname) | The hostname where ThunderID Gate is hosted |
gate_client.port | (server's port) | Port where ThunderID Gate is accessible |
gate_client.scheme | (server's scheme) | Protocol scheme (http or https) |
gate_client.path | /gate | Base path for ThunderID Gate |
TLS Configuration
Controls HTTPS/TLS settings for secure communication.
| Setting | Default | Description |
|---|---|---|
tls.min_version | 1.3 | Minimum TLS version to accept (1.2 or 1.3) |
tls.cert_file | config/certs/server.cert | Path to TLS certificate file |
tls.key_file | config/certs/server.key | Path to TLS private key file |
For quickstart and development, ThunderID generates a self-signed certificate at config/certs/server.cert during setup. For production, replace it with a certificate from a trusted Certificate Authority.
Logging Configuration
Controls the verbosity and destination of server logs.
| Setting | Default | Description |
|---|---|---|
log.level | info | Minimum log level to emit (debug, info, warn, or error) |
log:
level: "info"
If log.level is omitted, ThunderID defaults to info. An unrecognized value causes the server to exit at startup.
On Kubernetes, set the logging properties under configuration.log in your Helm values rather than editing deployment.yaml. See Logging.
Log Output
By default ThunderID writes logs to the console (stdout) only. You can additionally, or instead, write them to a file. Console and file are independent outputs: enable either, both, or neither (with neither enabled, ThunderID falls back to the console so logs are never silently lost).
| Setting | Default | Description |
|---|---|---|
log.output.console.enabled | true | Write logs to stdout |
log.output.file.enabled | false | Write logs to a file |
log.output.file.path | logs | Directory for the log file. A relative path resolves under the ThunderID home directory; an absolute path is used as-is |
log.output.file.file_name | thunderid.log | Name of the active log file |
log.output.file.format | text | Log line format: text or json. Despite its position under file, this setting applies to console output as well, so set it to json to emit JSON to stdout without enabling file output |
log.output.file.rotation.size.enabled | false | Roll the file over once it reaches max_size_mb |
log.output.file.rotation.size.max_size_mb | 10 | Size (in MB, fractional values allowed, e.g. 0.5) at which the file is rotated |
log.output.file.rotation.time.enabled | false | Roll the file over on a time schedule |
log.output.file.rotation.time.interval_days | 1 | Roll the file over every N days at local midnight |
log.output.file.rotation.max_backups | 0 | Maximum number of rotated files to keep (0 keeps all) |
log.output.file.rotation.max_age_days | 0 | Delete rotated files older than this many days (0 disables age-based deletion) |
log.output.file.rotation.compress | false | Gzip-compress rotated files |
log:
level: "info"
output:
console:
enabled: true
file:
enabled: true
path: "logs"
file_name: "thunderid.log"
format: "text"
rotation:
size:
enabled: true
max_size_mb: 10
time:
enabled: false
interval_days: 1
max_backups: 20
max_age_days: 0
compress: false
With the configuration above, logs are written to <THUNDER_HOME>/logs/thunderid.log. When the active file reaches the configured size limit, it is renamed with a timestamp suffix (for example thunderid.log.2026-07-06-14-30-00) and a fresh file is started. Older rotated files are then pruned by the configured backup count and age limit.
Size and time triggers are independent and can run together: with both enabled, the file rolls whenever it exceeds the size limit or reaches the next time interval, whichever comes first. Time-based rotation rolls at local midnight and skips days with no log output (so idle days do not create empty files).
The format applies to every component's log lines, regardless of when that component was created. The exception is the lines emitted before the logging configuration is applied, during server home resolution and configuration loading. Those use the default text format on stdout, because the format itself is part of the configuration still being loaded.
This is the ThunderID application log (the INFO/WARN/ERROR lines the server emits). It is separate from the observability event file sink described in the Observability guide, which records structured domain events such as token issuance and flow execution.
Access Log
ThunderID writes an access log line for every HTTP request. The Gate and Console frontend paths (/gate/ and /console/) are always excluded.
| Setting | Default | Description |
|---|---|---|
log.access.exclude_paths | [] | Additional path prefixes to exclude, alongside the always-excluded /gate/ and /console/ paths |
To exclude other paths, add their prefixes in deployment.yaml. For example, to also exclude the health probe endpoints:
log:
access:
exclude_paths:
- "/health/"
Database Configuration
ThunderID uses four separate databases for different purposes. Each database can be configured independently.
Connection parameters are grouped under a type-specific sub-key (postgres, sqlite, or redis). Only type is a top-level field; all other settings belong under the matching sub-key.
Config Database
Stores identity provider configurations, applications, and authentication flows.
| Setting | Default | Description |
|---|---|---|
database.config.type | sqlite | Database type (sqlite or postgres) |
database.config.postgres.* is only read when database.config.type: postgres:
| Setting | Default | Description |
|---|---|---|
database.config.postgres.hostname | "" | Database server hostname |
database.config.postgres.port | 0 | Database server port |
database.config.postgres.name | "" | Database name |
database.config.postgres.username | "" | Database username |
database.config.postgres.password | "" | Database password |
database.config.postgres.sslmode | "" | SSL mode (disable, require, verify-ca, verify-full) |
database.config.postgres.max_open_conns | 500 | Maximum number of open connections |
database.config.postgres.max_idle_conns | 100 | Maximum number of idle connections |
database.config.postgres.conn_max_lifetime | 3600 | Maximum connection lifetime in seconds |
database.config.postgres.max_retries | 3 | Maximum retry attempts for transient errors |
database.config.postgres.min_retry_backoff_ms | 50 | Minimum delay before retrying in milliseconds |
database.config.postgres.max_retry_backoff_ms | 2000 | Maximum delay before retrying in milliseconds |
database.config.sqlite.* is only read when database.config.type: sqlite:
| Setting | Default | Description |
|---|---|---|
database.config.sqlite.path | database/configdb.db | SQLite database file path |
database.config.sqlite.options | _pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1) | SQLite connection options |
database.config.sqlite.max_open_conns | 500 | Maximum number of open connections |
database.config.sqlite.max_idle_conns | 100 | Maximum number of idle connections |
database.config.sqlite.conn_max_lifetime | 3600 | Maximum connection lifetime in seconds |
database.config.sqlite.max_retries | 3 | Maximum retry attempts for transient errors |
database.config.sqlite.min_retry_backoff_ms | 50 | Minimum delay before retrying in milliseconds |
database.config.sqlite.max_retry_backoff_ms | 2000 | Maximum delay before retrying in milliseconds |
Runtime-transient Database
Stores short-lived runtime data such as authorization codes, authorization requests, PAR requests, and nonces. The runtime-transient database supports three backend types: sqlite, postgres, and redis.
| Setting | Default | Description |
|---|---|---|
database.runtime_transient.type | sqlite | Database type (sqlite, postgres, or redis) |
database.runtime_transient.postgres.* is only read when database.runtime_transient.type: postgres:
| Setting | Default | Description |
|---|---|---|
database.runtime_transient.postgres.hostname | "" | Database server hostname |
database.runtime_transient.postgres.port | 0 | Database server port |
database.runtime_transient.postgres.name | "" | Database name |
database.runtime_transient.postgres.username | "" | Database username |
database.runtime_transient.postgres.password | "" | Database password |
database.runtime_transient.postgres.sslmode | "" | SSL mode (disable, require, verify-ca, verify-full) |
database.runtime_transient.postgres.max_open_conns | 500 | Maximum number of open connections |
database.runtime_transient.postgres.max_idle_conns | 100 | Maximum number of idle connections |
database.runtime_transient.postgres.conn_max_lifetime | 3600 | Maximum connection lifetime in seconds |
database.runtime_transient.postgres.max_retries | 3 | Maximum retry attempts for transient errors |
database.runtime_transient.postgres.min_retry_backoff_ms | 50 | Minimum delay before retrying in milliseconds |
database.runtime_transient.postgres.max_retry_backoff_ms | 2000 | Maximum delay before retrying in milliseconds |
database.runtime_transient.sqlite.* is only read when database.runtime_transient.type: sqlite:
| Setting | Default | Description |
|---|---|---|
database.runtime_transient.sqlite.path | database/runtime_transient.db | SQLite database file path |
database.runtime_transient.sqlite.options | _pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1) | SQLite connection options |
database.runtime_transient.sqlite.max_open_conns | 500 | Maximum number of open connections |
database.runtime_transient.sqlite.max_idle_conns | 100 | Maximum number of idle connections |
database.runtime_transient.sqlite.conn_max_lifetime | 3600 | Maximum connection lifetime in seconds |
database.runtime_transient.sqlite.max_retries | 3 | Maximum retry attempts for transient errors |
database.runtime_transient.sqlite.min_retry_backoff_ms | 50 | Minimum delay before retrying in milliseconds |
database.runtime_transient.sqlite.max_retry_backoff_ms | 2000 | Maximum delay before retrying in milliseconds |
database.runtime_transient.redis.* is only read when database.runtime_transient.type: redis:
| Setting | Default | Description |
|---|---|---|
database.runtime_transient.redis.address | "" | Redis server address in host:port format, for example localhost:6379 |
database.runtime_transient.redis.username | "" | Redis ACL username. Leave empty if ACLs are not configured |
database.runtime_transient.redis.password | "" | Redis password or ACL user password |
database.runtime_transient.redis.db | 0 | Redis logical database index (0 to 15) |
database.runtime_transient.redis.key_prefix | "" | Prefix applied to all Redis keys written by ThunderID, for example thunderid: |
database.runtime_transient.redis.max_retries | 5 | Maximum retry attempts for transient Redis network and timeout issues |
database.runtime_transient.redis.min_retry_backoff_ms | 10 | Minimum delay before Redis retries in milliseconds |
database.runtime_transient.redis.max_retry_backoff_ms | 100 | Maximum delay before Redis retries in milliseconds |
database.runtime_transient.redis.dial_timeout_ms | 5000 | Redis connection (dial) timeout in milliseconds |
database.runtime_transient.redis.read_timeout_ms | 3000 | Redis read timeout in milliseconds |
database.runtime_transient.redis.write_timeout_ms | 3000 | Redis write timeout in milliseconds |
Redis Connection Requirements
- ThunderID requires Redis 6.0 or later.
- ThunderID connects to a single Redis node. Cluster mode and Sentinel mode are not currently supported.
- The default Redis port is
6379. Ensure the port is reachable from the ThunderID server. - ThunderID does not enforce TLS at the client configuration level. To encrypt traffic, place a TLS-terminating proxy in front of Redis and point
database.runtime_transient.redis.addressat the proxy endpoint. - If your Redis deployment uses Access Control Lists (ACLs), create a dedicated user and grant the following commands:
GET,SET,DEL,EXPIRE,EVAL,EVALSHA. Setdatabase.runtime_transient.redis.usernameanddatabase.runtime_transient.redis.passwordaccordingly. - ThunderID calls
PINGat startup to verify connectivity. The process terminates if the Redis server is unreachable.
Database Retry Behavior
ThunderID applies exponential backoff with jitter for transient failures on non-transactional SQL read operations (Query path). Retry behavior is configurable per database via max_retries, min_retry_backoff_ms, and max_retry_backoff_ms under the relevant postgres or sqlite sub-key.
- Retryable classes include transient connectivity errors (
ErrBadConn,ErrConnDone, connection reset/refused), timeouts, deadlocks (40P01), and PostgreSQL resource/availability states (53xxx,57P01,57P02,57P03, too-many-connections). - Non-retryable conditions include no-row outcomes (
ErrNoRows), syntax/validation issues, and other permanent SQL conditions. - Write operations executed via
Executeare not retried automatically to avoid accidental duplicate writes. If your write path is explicitly idempotent, add idempotency at the business layer (for example with deterministic IDs or upsert semantics). - Retry metrics are emitted through OpenTelemetry metric instruments (
thunderid_db_retry_attempts_total,thunderid_db_retry_backoff_seconds,thunderid_db_operation_seconds), which can be exported to Prometheus via your OpenTelemetry collector pipeline.
Entity Database
Stores user profiles and credentials.
| Setting | Default | Description |
|---|---|---|
database.entity.type | sqlite | Database type (sqlite or postgres) |
database.entity.postgres.* is only read when database.entity.type: postgres:
| Setting | Default | Description |
|---|---|---|
database.entity.postgres.hostname | "" | Database server hostname |
database.entity.postgres.port | 0 | Database server port |
database.entity.postgres.name | "" | Database name |
database.entity.postgres.username | "" | Database username |
database.entity.postgres.password | "" | Database password |
database.entity.postgres.sslmode | "" | SSL mode (disable, require, verify-ca, verify-full) |
database.entity.postgres.max_open_conns | 500 | Maximum number of open connections |
database.entity.postgres.max_idle_conns | 100 | Maximum number of idle connections |
database.entity.postgres.conn_max_lifetime | 3600 | Maximum connection lifetime in seconds |
database.entity.postgres.max_retries | 3 | Maximum retry attempts for transient errors |
database.entity.postgres.min_retry_backoff_ms | 50 | Minimum delay before retrying in milliseconds |
database.entity.postgres.max_retry_backoff_ms | 2000 | Maximum delay before retrying in milliseconds |
database.entity.sqlite.* is only read when database.entity.type: sqlite:
| Setting | Default | Description |
|---|---|---|
database.entity.sqlite.path | database/entitydb.db | SQLite database file path |
database.entity.sqlite.options | _pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1) | SQLite connection options |
database.entity.sqlite.max_open_conns | 500 | Maximum number of open connections |
database.entity.sqlite.max_idle_conns | 100 | Maximum number of idle connections |
database.entity.sqlite.conn_max_lifetime | 3600 | Maximum connection lifetime in seconds |
database.entity.sqlite.max_retries | 3 | Maximum retry attempts for transient errors |
database.entity.sqlite.min_retry_backoff_ms | 50 | Minimum delay before retrying in milliseconds |
database.entity.sqlite.max_retry_backoff_ms | 2000 | Maximum delay before retrying in milliseconds |
Runtime-persistent Database
Stores SSO sessions, revoked tokens, consent records, and account access state (sign-in locks and failed-attempt counts). This database is required. ThunderID fails to start when database.runtime_persistent is not configured.
| Setting | Default | Description |
|---|---|---|
database.runtime_persistent.type | sqlite | Database type (sqlite or postgres) |
database.runtime_persistent.postgres.* is only read when database.runtime_persistent.type: postgres:
| Setting | Default | Description |
|---|---|---|
database.runtime_persistent.postgres.hostname | "" | Database server hostname |
database.runtime_persistent.postgres.port | 0 | Database server port |
database.runtime_persistent.postgres.name | "" | Database name |
database.runtime_persistent.postgres.username | "" | Database username |
database.runtime_persistent.postgres.password | "" | Database password |
database.runtime_persistent.postgres.sslmode | "" | SSL mode (disable, require, verify-ca, verify-full) |
database.runtime_persistent.postgres.max_open_conns | 500 | Maximum number of open connections |
database.runtime_persistent.postgres.max_idle_conns | 100 | Maximum number of idle connections |
database.runtime_persistent.postgres.conn_max_lifetime | 3600 | Maximum connection lifetime in seconds |
database.runtime_persistent.postgres.max_retries | 3 | Maximum retry attempts for transient errors |
database.runtime_persistent.postgres.min_retry_backoff_ms | 50 | Minimum delay before retrying in milliseconds |
database.runtime_persistent.postgres.max_retry_backoff_ms | 2000 | Maximum delay before retrying in milliseconds |
database.runtime_persistent.sqlite.* is only read when database.runtime_persistent.type: sqlite:
| Setting | Default | Description |
|---|---|---|
database.runtime_persistent.sqlite.path | database/runtime_persistent.db | SQLite database file path |
database.runtime_persistent.sqlite.options | _pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1) | SQLite connection options |
database.runtime_persistent.sqlite.max_open_conns | 500 | Maximum number of open connections |
database.runtime_persistent.sqlite.max_idle_conns | 100 | Maximum number of idle connections |
database.runtime_persistent.sqlite.conn_max_lifetime | 3600 | Maximum connection lifetime in seconds |
database.runtime_persistent.sqlite.max_retries | 3 | Maximum retry attempts for transient errors |
database.runtime_persistent.sqlite.min_retry_backoff_ms | 50 | Minimum delay before retrying in milliseconds |
database.runtime_persistent.sqlite.max_retry_backoff_ms | 2000 | Maximum delay before retrying in milliseconds |
Cache Configuration
ThunderID includes both in-memory and Redis-backed caching to improve performance.
| Setting | Default | Description |
|---|---|---|
cache.disabled | false | If true, disables caching entirely |
cache.type | inmemory | Cache type (inmemory or redis). When cache.type is redis, ThunderID does not use the periodic cleanup routine. |
cache.size | 1000 | Maximum number of cache entries |
cache.ttl | 3600 | Default time-to-live for cache entries in seconds |
cache.eviction_policy | LRU | Cache eviction policy (LRU - Least Recently Used) |
cache.cleanup_interval | 300 | In-memory only. Interval in seconds to clean up expired entries. Not used when cache.type is redis. |
cache.properties[] | [] | Per-cache overrides for name, disabled, size, ttl, and eviction_policy |
cache.redis.address | "" | Redis server address in host:port format |
cache.redis.username | "" | Redis username |
cache.redis.password | "" | Redis password |
cache.redis.db | 0 | Redis database index |
cache.redis.key_prefix | thunderid | Prefix added to all Redis cache keys |
cache.redis.max_retries | 5 | Maximum retry attempts for transient Redis network and timeout issues |
cache.redis.min_retry_backoff_ms | 10 | Minimum delay before Redis retries in milliseconds |
cache.redis.max_retry_backoff_ms | 100 | Maximum delay before Redis retries in milliseconds |
cache.redis.dial_timeout_ms | 5000 | Redis connection (dial) timeout in milliseconds |
cache.redis.read_timeout_ms | 3000 | Redis read timeout in milliseconds |
cache.redis.write_timeout_ms | 3000 | Redis write timeout in milliseconds |
Cache Property Overrides
Use cache.properties when you want to override cache behavior for a specific internal cache instead of changing the global cache settings for all caches.
Each item in cache.properties supports these fields:
| Field | Required | Description |
|---|---|---|
name | Yes | Internal cache name to override. The value must match the cache name exactly. |
disabled | No | Disables only that cache when set to true. |
size | No | Maximum number of entries for that cache. This setting applies to in-memory caches only. |
ttl | No | Time-to-live for that cache in seconds. |
eviction_policy | No | Eviction policy for that cache. Supported values are LRU and LFU. This setting applies to in-memory caches only. |
If you omit a field in a cache.properties entry, ThunderID falls back to the corresponding global cache.* setting.
That means you can override only the fields you need. ThunderID continues to use the global cache settings for any fields you leave unset.
ThunderID currently uses cache names such as:
ApplicationByIDCacheApplicationByNameCacheOAuthAppCacheFlowByIDCacheFlowByHandleCacheCertificateByIDCacheCertificateByReferenceCacheEntityTypeByIDCacheEntityTypeByHandleCacheFlowGraphCache
FlowGraphCache is always in-memory. It caches process-local flow graph Go objects during flow execution, not shared system-level cache data.
When cache.type is redis, per-cache ttl and disabled remain useful. Per-cache size and eviction_policy do not affect Redis behavior because Redis manages memory and eviction independently.
Deployment-Scoped Entries
Every cache entry belongs to the deployment the request acts for, the same deployment the stores read and write rows for. Two deployments served by one ThunderID process, or sharing one Redis server, never read or remove each other's entries, even when they hold entries under the same key.
- Work that runs outside a request, such as a background task, acts for the deployment in
server.identifier. - Clearing an in-memory cache removes its entries for every deployment. A Redis cache is not cleared; its entries expire after their time-to-live.
Redis Cache Configuration
Set cache.type to redis and configure cache.redis.address to enable Redis-backed cache storage.
cache:
type: "redis"
ttl: 3600
redis:
address: "localhost:6379"
username: ""
password: ""
db: 0
key_prefix: "thunderid"
max_retries: 5
min_retry_backoff_ms: 10
max_retry_backoff_ms: 100
dial_timeout_ms: 5000
read_timeout_ms: 3000
write_timeout_ms: 3000
When Redis caching is enabled, ThunderID expires keys automatically based on TTL.
If ThunderID cannot connect to Redis during startup, it disables the cache layer.
JWT Configuration
Controls JWT (JSON Web Token) generation and validation.
| Setting | Default | Description |
|---|---|---|
jwt.validity_period | 3600 | Default JWT validity period in seconds (1 hour) |
jwt.audience | application | Default audience claim for JWTs |
jwt.preferred_key_id | default-key | Key ID to use for signing JWTs |
jwt.leeway | 30 | Clock skew tolerance in seconds for token validation |
OAuth Configuration
OAuth 2.0 and OpenID Connect settings.
| Setting | Default | Description |
|---|---|---|
oauth.refresh_token.renew_on_grant | true | If true, issues a new refresh token on each access token grant. Set to false to reuse the existing refresh token until it expires |
oauth.refresh_token.revoke_previous_on_renew | true | If true, revokes the consumed refresh token after a successful renewal. Applies when renew_on_grant is enabled |
oauth.refresh_token.validity_period | 86400 | Refresh token validity period in seconds (24 hours) |
oauth.token_revocation.enabled | true | If true, enables token revocation on the authorization server: the RFC 7009 endpoint (/oauth2/revoke), the implicit revocation triggers, and the check that rejects revoked tokens during introspection, the refresh grant, and token exchange. Setting it to false turns off all of them, not only the endpoint. See Token Revocation |
oauth.revocation.token_family.on_explicit_revoke | true | If true, revoking a token through /oauth2/revoke also revokes every other token of the same authorization grant |
oauth.revocation.token_family.on_refresh_replay | true | If true, presenting a refresh token that has already been rotated out revokes every token of that grant. Requires oauth.refresh_token.renew_on_grant, since a refresh token can only be replayed once it has been rotated |
oauth.revocation.token_family.on_code_replay | true | If true, redeeming an authorization code a second time revokes every token issued from that code |
oauth.token_exchange.token_family | none | Whether an exchanged token joins the subject token's authorization grant. none issues an independent token; inherit copies the subject token's token family, so revoking the grant also revokes the exchanged token |
oauth.authorization_code.validity_period | 600 | Authorization code validity period in seconds (10 minutes) |
oauth.authorization_request.validity_period | 3600 | How long the authorization request context stays valid while the user completes the login flow, in seconds (60 minutes). A non-positive value falls back to the default |
oauth.dcr.enabled | true | If true, enables the Dynamic Client Registration endpoint |
oauth.dcr.insecure | false | If true, allows insecure dynamic client registration (development only) |
oauth.client_assertion.max_lifetime | 300 | Maximum lifetime of a private_key_jwt client assertion in seconds. The exp claim must not be later than iat plus this value, or the current time plus this value when iat is absent |
oauth.client_assertion.max_iat_age | 60 | Maximum age of a private_key_jwt client assertion's iat claim in seconds. Assertions with an older iat are rejected |
oauth.cimd.enabled | true | If true, advertises client_id_metadata_document_supported in the authorization server metadata, so MCP clients that support Client ID Metadata Documents identify themselves with one. Set it to false if MCP clients should register through Dynamic Client Registration instead. It doesn't affect registering clients from a document in the Console. See Register an MCP Client from Its Metadata Document |
oauth.allowed_auth_methods | ["client_secret_basic", "client_secret_post", "private_key_jwt", "none"] | Client token endpoint authentication methods allowed during client registration |
oauth.allowed_response_types | ["code"] | OAuth response types allowed during client registration |
oauth.allowed_grant_types | ["client_credentials", "authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange", "urn:openid:params:grant-type:ciba", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | OAuth grant types allowed during client registration |
oauth.allow_wildcard_redirect_uri | false | If true, allows wildcard patterns in registered redirect URIs: * and ** in the path component, and * in the host component (label-internal, alphanumeric only). When false, only exact redirect URI matching is performed and registering a wildcard URI returns a 400 Bad Request error. |
oauth.send_server_errors_to_client | false | If true, an authentication flow failure that maps to the OAuth server_error code is reported to the client, as RFC 6749 section 4.1.2.1 requires. If false, the authorization code flow shows the error page instead of redirecting to the client, and CIBA leaves the request pending so the polling client times out. Denials (access_denied) are always reported to the client and are not affected by this setting. |
Set oauth.client_assertion.max_lifetime and oauth.client_assertion.max_iat_age together, each greater than 0. When both are unset, the iat and lifetime checks on client assertions are skipped.
Enabling oauth.allow_wildcard_redirect_uri affects all applications in the deployment. See Use Wildcard Redirect URIs for pattern syntax and matching rules.
Flow Configuration
Authentication and registration flow settings.
| Setting | Default | Description |
|---|---|---|
flow.max_version_history | 10 | Maximum number of flow versions to retain |
flow.auto_infer_registration | true | If true, automatically infers registration from authentication flows |
flow.executors | (all built-in executors) | Whitelist of built-in executor names to register at startup. Omit the key or leave the list empty to register every built-in executor. When set, only the listed executors are available; flows that reference other executors fail validation at startup. Unknown names cause startup to fail. Duplicate names are ignored. |
Built-in Executors
Use flow.executors to limit which built-in flow executors the server registers. This setting applies only to built-in executors. When you embed ThunderID and register custom executors programmatically, call RegisterExecutor on the registry returned by Initialize after startup; flow.executors does not affect custom executors.
Register all built-in executors (default):
Omit flow.executors or leave it empty:
flow:
max_version_history: 10
auto_infer_registration: true
# executors: [] # optional; omit or empty registers all built-in executors
Register a subset:
List only the executor names you need. Built-in executors not in the list are excluded:
flow:
executors:
- CredentialsAuthExecutor
- InviteExecutor
For the complete list of built-in executor names and configuration details, see Executor Details and Configuration.
User Configuration
User management settings.
| Setting | Default | Description |
|---|---|---|
user.indexed_attributes | ["username", "email", "mobile_number", "sub"] | User attributes that are indexed for fast lookups |
An indexed attribute's value can be a single scalar value or a list of scalar values. A list indexes each scalar element separately, so a user can be looked up by any one of them. Non-scalar elements in the list (for example, a nested object) are skipped and are not indexed. For example, a user with email set to ["primary@example.com", "secondary@example.com"] can be identified, searched, or filtered by either address.
A list can hold at most 100 scalar values for one indexed attribute. A request that exceeds this fails schema validation.
List values aren't checked for uniqueness, so two users can hold the same value in a list. A value that more than one user holds can't be used to identify either of them.
If the same attribute name is present in both schema attributes and system attributes, the system attribute's scalar value or values are indexed and the schema attribute's scalar value or values are not. If the system attribute has no scalar value to index (for example, it is empty), the schema attribute's values are indexed instead.
Declarative Resources
Controls declarative configuration support.
| Setting | Default | Description |
|---|---|---|
declarative_resources.enabled | false | If true, enables declarative resource configuration |
Resource Loading Precedence
At startup, ThunderID resolves declarative resources using the following precedence. Only the first matching source is used. Sources are not merged.
| Priority | Source | When active |
|---|---|---|
| 1 | Single file passed via ./start.sh <file> | --resources flag is set |
| 2 | *.yaml/*.yml files placed directly in config/resources/ (not in subdirectories) | Flag not set; at least one root-level YAML file exists |
| 3 | Per-type subdirectories under config/resources/ (e.g. config/resources/groups/) | Flag not set; no root-level YAML files found |
If --resources is set, config/resources/ is not read at all, regardless of what files it contains. The two sources never merge.
Start ThunderID With a Resources File
Pass a single multi-document YAML file to the server at startup as a positional argument to start.sh. The file is forwarded to the server binary as the -resources flag and read directly at startup.
./start.sh resources.yml
With environment variable substitution:
./start.sh resources.yml --env my.env
Each document in the file must declare its type with a resource_type: <type> field. Supported types:
application, connection, flow, group, layout, notification_template,
organization_unit, resource_server, role, theme, translation, user, user_schema
Example single file:
resource_type: application
name: My App
ouHandle: default
authFlowHandle: default-flow
---
resource_type: group
name: admins
description: Administrators
When --resources is active, any files in config/resources/, including per-type subdirectories, are silently ignored. If you need files from the directory to be loaded, do not pass a --resources argument.
Resources Directory (No Startup Flag)
If you do not pass a --resources argument, ThunderID automatically picks up multi-document YAML files placed directly in the config/resources/ directory (not in subdirectories). This is priority 2 in the loading precedence and requires no configuration change or restart argument.
Place a multi-document YAML file in config/resources/:
repository/
resources/
my-resources.yaml # loaded automatically at startup
groups/ # used only when no root-level YAML files exist
admins.yaml
The file must use the same resource_type: <type> document fields as the single-file startup format. On startup, ThunderID scans all *.yaml and *.yml files at that root level and loads every document whose resource_type matches a configured declarative resource type.
If any *.yaml or *.yml file is found at the config/resources/ root, per-type subdirectories (priority 3) are skipped entirely for that resource type.
Helm Deployment
For Helm deployments, this setting is managed through declarativeResources.enabled in the Helm values. When enabled, the chart mounts declarative resource files from either a ConfigMap or Secret into the ThunderID config/resources directory.
Two mounting modes are available. Use the one that fits your resource layout.
Combined File Mode
Use singleFile.keys to mount one or more combined YAML files (multi-document format with resource_type: <type> fields) directly under config/resources. Each key in your ConfigMap or Secret is mounted as <key>.yaml at that path. ThunderID scans the directory at startup and loads every document whose resource_type matches a configured declarative resource type.
declarativeResources:
enabled: true
readOnly: true
singleFile:
keys:
- resources # mounted as config/resources/resources.yaml
- extra-resources # mounted as config/resources/extra-resources.yaml
configMap:
name: declarative-resources
The same works with a Secret source (for files containing sensitive values):
declarativeResources:
enabled: true
readOnly: true
singleFile:
keys:
- resources
secret:
name: declarative-resources-secret
Individual Files Mode
Use configMap.items (or secret.items) to mount individual files into typed subdirectories under config/resources. Each item maps a ConfigMap/Secret key to a specific path.
declarativeResources:
enabled: true
readOnly: true
configMap:
name: declarative-resources
items:
# Format 1: string shorthand; key and path are the same
- "connections/google.yaml"
# Format 2: explicit key/path mapping
- key: app1
path: applications/application1.yaml
# Format 3: per-item mountPath override (absolute path)
- key: org-default
path: organization_units/default.yaml
mountPath: /opt/thunderid/config/resources/organization_units/default.yaml
singleFile.keys and configMap.items / secret.items are mutually exclusive.
Notification templates (email and SMS) are managed at runtime and are also supported as declarative resources (resource_type: notification_template). See Notification Templates for authoring and Console management, and Import Declarative Resources for the declarative schema.
Resource Configuration
Authorization resource settings.
| Setting | Default | Description |
|---|---|---|
resource.default_delimiter | : | Default delimiter for resource hierarchies |
Resource Sharing Configuration
Controls whether a resource can reach an organization unit outside the tree that owns it.
| Setting | Default | Description |
|---|---|---|
resource_sharing.allow_child_ou_cross_tree_sharing | false | If true, an organization unit that has a parent can share a resource with a root organization unit in another tree. If false, only a root organization unit can share outside its own tree. |
Organization units form separate trees. Sharing within a tree is always permitted and is not affected by this setting.
A root organization unit can always share with another root OU. This setting governs the other case, where an organization unit that sits inside a tree shares outward. Keeping it false means a resource leaves a tree only by decision of that tree's root.
resource_sharing:
allow_child_ou_cross_tree_sharing: false
Observability Configuration
Monitoring and observability settings. ThunderID supports three output backends: console, file, and OpenTelemetry (OTel). You can enable any combination of them independently.
Top-Level Settings
| Setting | Default | Description |
|---|---|---|
observability.enabled | false | If true, enables the observability system. All output backends are inactive when this is false. |
observability.failure_mode | lenient | Controls how initialization failures in subscribers are handled. lenient logs a warning and continues with the remaining subscribers. strict stops initialization and returns an error if any subscriber fails. |
Console Output
Writes events to standard output. Useful for local development and containerized deployments that collect stdout logs.
| Setting | Default | Description |
|---|---|---|
observability.output.console.enabled | false | If true, outputs observability events to the console |
observability.output.console.format | json | Output format: json or text |
observability.output.console.categories | ["observability.all"] | Event categories to output. See Event Categories for valid values. |
File Output
Writes events to a local file. The file subscriber appends events in the configured format and creates the output directory automatically if it does not exist.
| Setting | Default | Description |
|---|---|---|
observability.output.file.enabled | false | If true, outputs observability events to a file |
observability.output.file.file_path | logs/observability/observability.log (relative to the installation directory) | Path to the output file. The directory is created automatically. |
observability.output.file.format | json | Output format: json |
observability.output.file.categories | ["observability.all"] | Event categories to write. See Event Categories for valid values. |
OpenTelemetry Output
Exports events as OpenTelemetry spans to an OTLP-compatible backend such as Jaeger, Grafana Tempo, or an OpenTelemetry Collector. Events with the same TraceID are grouped into a single trace.
| Setting | Default | Description |
|---|---|---|
observability.output.opentelemetry.enabled | false | If true, exports observability events as OpenTelemetry spans |
observability.output.opentelemetry.exporter_type | otlp | Exporter type: otlp (gRPC, for production) or stdout (for debugging) |
observability.output.opentelemetry.otlp_endpoint | "" | OTLP gRPC endpoint, for example localhost:4317. Required when exporter_type is otlp. |
observability.output.opentelemetry.service_name | "" | Service name included in all emitted spans, for example thunderid-iam |
observability.output.opentelemetry.service_version | "" | Service version included in span resource attributes, for example 1.0.0 |
observability.output.opentelemetry.environment | "" | Deployment environment included in span resource attributes, for example production |
observability.output.opentelemetry.sample_rate | 1.0 | Fraction of traces to sample. 1.0 samples all traces; 0.5 samples half. |
observability.output.opentelemetry.categories | ["observability.all"] | Event categories to export. See Event Categories for valid values. |
observability.output.opentelemetry.insecure | false | If true, disables TLS for the OTLP connection. Use only in development environments. |
Event Categories
Use these values in any categories list to filter which events a subscriber receives:
| Value | Description |
|---|---|
observability.all | Matches all events regardless of type (default) |
observability.authentication | Token issuance events, automatic account locks, and self-service unlocks from a recovery flow |
observability.authorization | Authorization-related events |
observability.flows | Authentication and registration flow execution events |
observability.administration | Administrative changes: operator unlock and server-config writes |
Example
The following deployment.yaml snippet enables console output and OpenTelemetry export to a local collector:
observability:
enabled: true
failure_mode: lenient
output:
console:
enabled: true
format: json
categories:
- observability.all
opentelemetry:
enabled: true
exporter_type: otlp
otlp_endpoint: "localhost:4317"
service_name: "thunderid-iam"
service_version: "1.0.0"
environment: "production"
sample_rate: 1.0
insecure: false
categories:
- observability.all
Crypto Configuration
Cryptographic settings for encryption and signing.
Encryption
| Setting | Default | Description |
|---|---|---|
crypto.encryption.key | file://config/certs/crypto.key | Path to encryption key file |
Password Hashing
| Setting | Default | Description |
|---|---|---|
crypto.password_hashing.algorithm | PBKDF2 | Password hashing algorithm |
crypto.password_hashing.parameters.iterations | 600000 | Number of hashing iterations |
crypto.password_hashing.parameters.key_size | 32 | Derived key size in bytes |
crypto.password_hashing.parameters.salt_size | 16 | Salt size in bytes |
Signing Keys
Signing keys are configured as an array. Each key has the following properties:
| Setting | Description |
|---|---|
crypto.keys[].id | Unique identifier for the key |
crypto.keys[].cert_file | Path to certificate file |
crypto.keys[].key_file | Path to private key file |
Default Key:
- id: "default-key"
cert_file: "config/certs/signing.cert"
key_file: "config/certs/signing.key"
The key type under crypto.keys determines the algorithm in id_token_signing_alg_values_supported in the OIDC discovery document. RSA keys advertise RS256; ECDSA P-256, P-384, and P-521 keys advertise ES256, ES384, and ES512; Ed25519 keys advertise EdDSA. If multiple keys are configured, all resulting algorithms are included without duplicates.
Attribute Cache Configuration
During an authorization flow, ThunderID caches the resolved user attributes in the runtime store and references them from tokens through the aci claim. These attributes are stored as plaintext by default. Enable encryption to store them encrypted at rest using the key from crypto.encryption.key.
| Setting | Default | Description |
|---|---|---|
attribute_cache.encryption.enabled | false | When true, cached user attributes are encrypted (AES-GCM) before being written to the runtime store and decrypted on read. Requires crypto.encryption.key to be configured. |
Treat attribute_cache.encryption.enabled as a set-once value. It is applied on both write and read with no format detection, so changing it while cached entries still exist causes failures: after enabling it, entries written as plaintext can no longer be decrypted; after disabling it, encrypted entries are read without decryption. Before changing it, clear the attribute cache (or wait for all entries to expire), then restart.
Email Configuration
ThunderID sends email through email providers that you create as connections, and each email step in a flow selects the provider it sends through. See Configure an Email Provider.
If you are upgrading from an earlier release, remove the email.smtp block from deployment.yaml, or configuration.email.smtp from your Helm values. The server does not start while deployment.yaml has an email block, and the Helm chart does not render while configuration.email.smtp.host is set. To move those settings to an email provider, see Migrate From email.smtp.
Notification Configuration
Controls notification delivery behaviour, including OTP generation parameters.
| Setting | Default | Description |
|---|---|---|
notification.otp.length | 6 | OTP character length. Must be in [4, 10]. |
notification.otp.use_numeric_only | true | If true, OTPs use digits only; if false, OTPs use a mixed alphanumeric character set. |
notification.otp.validity_period_seconds | 120 | OTP validity period in seconds. Must be in [30, 600]. |
Authentication Provider Configuration
External authentication provider settings.
| Setting | Default | Description |
|---|---|---|
authn_provider.rest.enabled | false | Set to true to register the REST authentication provider. When false, the rest of the rest.* settings are ignored. |
authn_provider.rest.credential_types | [] | Credential keys routed to the REST provider instead of default. Valid keys: provisionedEntityID, passkey, otp, federated, magiclink, openid4vp, password, clientSecret, flowSecret. |
authn_provider.rest.base_url | "" | Base URL for REST authentication provider |
authn_provider.rest.timeout | 10 | Request timeout in seconds |
authn_provider.rest.correlation_id_header | X-Correlation-ID | Header used to forward the request's correlation ID (trace ID) to the REST provider. Set a different name if the provider expects one. |
authn_provider.rest.security.api_key | "" | API key for REST provider authentication |
Correlation ID Propagation
Each request is assigned a correlation ID (trace ID), which the server generates when the client does not supply one. ThunderID forwards this ID on outbound HTTP calls so a request can be traced across services. The REST authentication provider, the consent service, the flow HTTP request executor, and the Vonage notification provider all set the X-Correlation-ID header when it is missing. For the REST authentication provider, the header name is configurable via authn_provider.rest.correlation_id_header.
Example: forward the correlation ID under a custom header name:
authn_provider:
rest:
enabled: true
base_url: "https://my-authn-service"
correlation_id_header: "X-Trace-Token"
CORS Configuration
Controls which browser origins may call the ThunderID API. Origins are stored in the server-config cors section, not in deployment.yaml. No origins are allowed by default.
| Field | Description |
|---|---|
allowedOrigins | List of allowed origins. Each entry is a literal origin (for example https://app.example.com) or a regex object ({ regex: "^https://...$" }) matched against the request Origin header. The literal "*" is rejected, and "null" matches the CORS null origin. Any origin not in the list is rejected. |
Add origins in either of these ways:
- Declarative - create
config/resources/server_configs/cors.yaml, then restart:name: cors
value:
allowedOrigins:
- "https://app.example.com"
- regex: '^https://[a-z0-9-]+\.staging\.example\.com(:[0-9]+)?$' - Runtime - send the new value to
PUT /server-config/cors; it is applied immediately and merged with the declarative file:PUT /server-config/cors
Content-Type: application/json
{ "allowedOrigins": ["https://app.example.com"] }
Literal origins are matched after the request Origin is normalized. Scheme and host are case-insensitive, a trailing host dot is ignored, and IPv6 hosts use bracket form. Internationalized domain names are compared in ASCII form. Ports must match exactly, so list each port separately. Methods, headers, credentials, and the preflight cache TTL are fixed per route in code; only the origin list is configurable.
Anchor every regex with ^ and $ and escape literal dots. Unanchored patterns produce a startup warning, and invalid patterns stop the server at startup.
The null origin is shared by sandboxed iframes, file:// and data: documents, and some redirects, so allowing "null" cannot identify the caller. List it only when you intend to trust those contexts. With credentialed responses, it lets any such page make authenticated requests.
Content Security Policy
CSP headers are not enforced in development. The policy is applied only when static assets are served through the backend server binary.
Controls where the browser may load scripts, styles, images, fonts, and other resources for the ThunderID Console and Gate UIs. The policy is deny-first: any resource that is not explicitly allowed is blocked.
The CSP header is sent only on ThunderID Console (/console/) and Gate (/gate/) responses.
| Field | Description |
|---|---|
reportOnly | When true (the default), the browser reports violations to its console but still loads the resource, using the Content-Security-Policy-Report-Only header. Set it to false to enforce the policy and block disallowed resources. |
reportUri | Optional endpoint the browser posts violation reports to, mapped to the report-uri directive. Use a relative path or an http/https URL. |
directives | Map of directive name (for example img-src) to its list of allowed sources. A directive you list here replaces that directive's baseline value on every CSP path. |
paths | List of per-path overrides. Each entry has a location prefix (for example /console/) and its own directives. The longest matching location applies, and its directives override directives, which override the baseline. |
Deny-First Baseline
Every directive you do not configure falls back to this baseline:
default-src 'none';
script-src 'self';
style-src 'self';
img-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'none';
form-action 'self'
A resource type without a matching directive (for example font-src) falls back to default-src 'none' and is blocked. On HTML responses, ThunderID Gate gets a fresh per-request nonce to the script-src and style-src-elem directives so its own inline scripts and styles load.
Listing a directive replaces its baseline value completely. Include 'self' in your list when you still need same-origin resources. For example, img-src: ["https://cdn.example.com"] blocks same-origin images until you add 'self'.
Configure the Policy
Set the policy in either of these ways:
- Declarative - create
config/resources/server_configs/csp.yaml, then restart.name: csp
value:
reportOnly: false
paths:
- location: "/console/"
directives:
img-src:
- "'self'"
- "https://cdn.example.com"
style-src-elem:
- "'self'"
- "https://fonts.googleapis.com"
font-src:
- "'self'"
- "https://fonts.gstatic.com" - Runtime - send the new value to
PUT /server-config/csp; it is applied immediately:PUT /server-config/csp
Content-Type: application/json
{
"reportOnly": false,
"paths": [
{
"location": "/console/",
"directives": {"img-src": ["'self'", "https://cdn.example.com"]}
}
]
}
Directives at the top level of directives apply to every CSP path, while a paths entry applies only to requests under its location and overrides the top-level value for that directive.
A PUT replaces the entire runtime layer, so include every runtime value you want to keep. ThunderID then merges that layer with the declarative csp.yaml per directive and path, and any directive you configure in neither layer stays at the deny-first baseline.
Directive names must match the pattern ^[a-z][a-z0-9-]*$, and no source may contain whitespace, a semicolon, or a comma. An invalid value is rejected with SCF-1003 ("Invalid server configuration value").
Default Resource Server
Sets the resource server for permission-bearing token requests that omit the resource parameter. It is stored in the server-config defaultResourceServer section, not in deployment.yaml. When no default is configured, a request that contains permission scopes but omits resource fails with invalid_target. OIDC-only and scopeless requests are not bound to a resource server, so they use the application's default audience (token.accessToken.defaultAudience), or the client_id when it is unset.
| Field | Description |
|---|---|
resourceServerId | The ID of a registered resource server. It must reference an existing resource server, otherwise the update is rejected with SCF-1003 ("Invalid server configuration value"). |
Set it in either of these ways:
- Declarative - create
config/resources/server_configs/defaultResourceServer.yaml, then restart:name: defaultResourceServer
value:
resourceServerId: "<resource-server-id>" - Runtime - send the new value to
PUT /server-config/defaultResourceServer; it is applied immediately:PUT /server-config/defaultResourceServer
Content-Type: application/json
{ "resourceServerId": "<resource-server-id>" }
A declarative default takes precedence and locks the setting: while defaultResourceServer.yaml sets a value, runtime PUT /server-config/defaultResourceServer requests are rejected. Remove the declarative value to manage the default at runtime.
Flow Defaults
Controls server-wide default flows and context expiry times for each flow type. These settings are stored in the server-config flow section, not in deployment.yaml.
Each flow type has two fields:
| Field | Description |
|---|---|
<type>.defaultHandle | Handle of the flow to use when the application and the organization unit do not pin a specific flow. Empty string means no server default, and the flow type is treated as not configured at the server level. |
<type>.expirySeconds | How long (in seconds) a started flow context remains valid before it expires. Must be a positive integer. |
Supported flow types are authFlow, registrationFlow, userOnboardingFlow, recoveryFlow, signOutFlow, and userDeletionFlow.
userDeletionFlow is resolved differently from the others: it names an administration flow that runs when a user is deleted from the Console, rather than a flow an inbound request starts. Its shipped default revokes the user's tokens and terminates their sessions before the record is removed. Deleting a user through the DELETE /users/{id} API removes the record only and does not run this flow. See Token Revocation.
The account unlock administration flows have their own types: userUnlockFlow and agentUnlockFlow. Like userDeletionFlow, each names an administration flow run against an existing identity, not a flow an inbound request starts.
Resolution order: When an inbound request starts a flow, the effective flow is resolved as follows:
- Application-level override (the flow ID pinned on the application or agent)
- Organization unit default (the flow ID configured on the owning OU)
- Server default from this section (the handle is looked up to find the flow ID)
If none of the layers supplies a value, registration, recovery, user-onboarding, and sign-out are left not configured (the feature is treated as disabled for that client). Authentication has no such fallback: a missing auth flow causes startup validation to fail.
Configure flow defaults in either of these ways:
- Declarative - create
config/resources/server_configs/flow.yaml, then restart:name: flow
value:
authFlow:
defaultHandle: default-flow
expirySeconds: 3600
registrationFlow:
expirySeconds: 3600
recoveryFlow:
expirySeconds: 1800
signOutFlow:
expirySeconds: 1800 - Runtime - send the new value to
PUT /server-config/flow; it is applied immediately and merged with any declarative file:PUT /server-config/flow
Content-Type: application/json
{
"authFlow": { "defaultHandle": "default-flow", "expirySeconds": 3600 },
"registrationFlow": { "expirySeconds": 3600 }
}
Set defaultHandle only for flow types that have a corresponding flow in your deployment. The handle is validated when the configuration is written. An unknown handle or a handle belonging to a different flow type is rejected with SCF-1003. For optional types (registration, recovery, user-onboarding, sign-out), omit defaultHandle to leave the server-level default empty.
Account Access
Controls the automatic account lock: how many failed sign-ins lock a user or an agent, and for how long. The policy is stored in the server-config accountAccess section. Only the user and agent categories can be configured; applications are never locked automatically, and an application block is rejected.
Only password and one-time-password failures are counted. A lock:
- Refuses new sign-ins only through the method that failed, or through every method when
lockGranularityisentity. - Does not end existing sessions or revoke tokens.
A refused sign-in by a locked identity states that the account is locked, without saying which method is locked or when the lock ends. An unknown identifier keeps its own answer. A wrong password or one-time password presented to an account held for that method gets the same answer as the correct one and is not counted. At the recovery step, a wrong code that the lock gives way to keeps its usual answer.
With observability enabled, every lock that forms, and every unlock, is published as an event. See Event Categories.
Settings
These fields of a category (user, agent) control the lock. All are optional, so a layer states only what it changes.
| Field | Description |
|---|---|
lockGranularity | authentication_method (default) locks only the sign-in method that failed. entity counts every lockable failure together and locks every sign-in method. |
default | The policy for every sign-in method in the category (see below). |
A policy has these fields:
| Field | Description |
|---|---|
enabled | Turns the automatic lock on or off. A policy that omits it is enabled when it sets any other policy field. An enabled policy, after it is laid over the base, must have threshold, failureWindowSeconds and lockDurationsSeconds, or the write is rejected. |
threshold | Failed attempts that form a lock. Must be at least 1 while the policy is enabled. |
failureWindowSeconds | A failure more than this long after the previous one restarts the count. 0 never restarts it on time. Required while the policy is enabled. |
lockDurationsSeconds | The lock duration for each consecutive lock, in seconds. The last value repeats. 0 locks until an administrator unlocks or the user completes account recovery. Must have at least one entry while the policy is enabled. |
lockDecaySeconds | How long after a lock ends the consecutive lock count resets. 0 never resets it. |
maxConsecutiveFailures | Consecutive failed attempts after which the lock does not expire. Only a successful sign-in with that method (any method with entity granularity), a completed recovery, or an unlock resets the count. 0 sets no limit; otherwise it must be at least threshold. |
Every seconds value must be a whole number from 0 to the largest number of seconds a duration can hold.
Product Defaults
The defaults ship as the account_access block of default.json, with the keys in snake_case (lock_granularity, lock_durations_seconds, and so on):
| Category | Threshold | Failure window | Lock durations | Decay | Consecutive failures |
|---|---|---|---|---|---|
user | 5 | 3600 s | 300, 900, 1800, 3600 s | 86400 s | 50 |
agent | 5 | 900 s | 60, 300, 900 s | 3600 s | 50 |
Both categories default to authentication_method granularity.
Override the block in deployment.yaml, which is the base of the section:
account_access:
user:
default:
threshold: 3
lock_durations_seconds: [600, 3600]
Layers and Locking
The effective policy is built from layers, read each time it is needed, so a change on any node applies without a restart:
- The base: the
account_accessblock fromdefault.jsonanddeployment.yaml. - A declarative document, when one exists, is laid over the base field by field. It locks the section: runtime writes are rejected until the declarative value is removed.
- Otherwise the runtime value is laid over the base field by field.
Within a category, a non-empty lockDurationsSeconds replaces the lower one. If the section cannot be read, the base applies.
Configure the Policy
Set the section in any of these ways:
- Declarative: create
config/resources/server_configs/accountAccess.yaml, then restart. The section uses camelCase keys:name: accountAccess
value:
user:
lockGranularity: authentication_method
default:
threshold: 3
failureWindowSeconds: 1800
lockDurationsSeconds: [600, 3600] - Runtime: send the new value to
PUT /server-config/accountAccess. It is applied immediately:PUT /server-config/accountAccess
Content-Type: application/json
{
"user": {
"default": {
"threshold": 3,
"failureWindowSeconds": 1800,
"lockDurationsSeconds": [600, 3600]
}
}
}
A PUT replaces the entire runtime layer, so include every runtime value you want to keep. To remove the runtime layer and return to the base, send an empty value.
In a Helm deployment, set the base with the configuration.accountAccess chart value, in camelCase. The chart's values.yaml carries the product defaults, so override only the keys you need. The API and a declarative server_configs/accountAccess.yaml mounted with declarativeResources (see Helm Deployment) work as in any other deployment.
Validation
An invalid value is rejected with HTTP 400 and SCF-1003 ("Invalid server configuration value"), and nothing is changed. The section rejects:
- Unknown fields, including a misspelled key and an
applicationblock. - A
lockGranularityother thanauthentication_methodorentity. - A negative
thresholdor seconds value, and a seconds value above the maximum. - A negative
maxConsecutiveFailures, or one above0and below the policy'sthreshold. - An enabled policy, after it is laid over the base, that cannot form a lock: it needs a
thresholdof at least1, afailureWindowSecondsand a non-emptylockDurationsSeconds. Setenabled: falseto switch the policy off. - Any write while a declarative
accountAccessdocument exists.
The same rules check the base on its own at startup. An enabled policy in the account_access block of default.json and deployment.yaml must have its threshold, failure_window_seconds and lock_durations_seconds without any later layer. An invalid base stops the server from starting.
Identity Governance Provider
| Setting | Default | Description |
|---|---|---|
identity_governance_provider.type | default | Set to disabled to turn off the identity governance checks the flow engine makes, and the unlock flows. A recovery flow still completes and releases no lock. Sign-in through the default authentication provider and the Direct API still enforces locks. With disabled, also set account_access.<category>.default.enabled: false for user and agent, and keep it off in the accountAccess section: otherwise locks still form and none can be released early. |
Passkey Configuration
WebAuthn/Passkey settings (typically defined in deployment.yaml).
| Setting | Description |
|---|---|
passkey.allowed_origins | Array of allowed origins for passkey operations |
Example:
passkey:
allowed_origins:
- "https://localhost:8090"
Direct API Configuration
Controls the Direct API authentication endpoints.
| Setting | Default | Description |
|---|---|---|
direct_api.enabled | true | If false, ThunderID does not register the Direct API authentication endpoints (/auth/** and /register/passkey/**), and requests to them return 404. AuthZEN access endpoints (/access/**) are not affected |
Example:
direct_api:
enabled: false
To hide the passkey registration endpoints in the Console as well, set direct_api.enabled to false in the Console config.js:
direct_api: {
enabled: false,
},
Security Configuration
Controls server-wide security behavior that is not specific to any single authenticator. Maps to SecurityConfig in the backend, nested under server.security.
| Setting | Default | Description |
|---|---|---|
server.security.jwks_cache_ttl | 300 | JWKS cache TTL in seconds. Applies to every JWKS consumer in the server (trusted issuer validation, federated OIDC authenticators such as Google, and so on). Fetched signing keys are reused from the in-process cache for this duration before being re-fetched. Plan external-server key rotations with at least this much overlap. Set to 0 to disable caching |
server.security.system_permission_prefix | "" (empty) | Prefix for system permission strings used in API authorization. When empty, permissions use their base names (for example, system). When set, the prefix is prepended to every system permission (for example, mgmt:system). If you set a prefix, update the Console scopes to match. Changes require a server restart |
server.security.direct_auth_secret | "" (empty) | Secret that gates the Direct API authentication endpoints (/auth/** and /register/passkey/**) and protected AuthZEN access endpoints (/access/**). AuthZEN discovery (/.well-known/authzen-configuration) remains public. The protected endpoints are secure by default. While this is empty they are blocked with 401. When set, callers must send the value in the Direct-Auth-Secret header; a missing or incorrect value is rejected with 401. See Integration Models |
server.security.rest.audience | "" (unset) | Audience (RFC 8707 resource indicator) a self-issued token must carry to be accepted by the REST API gate, normally the identifier of the resource server your management tokens are bound to. While unset the audience is not checked and REST authorizes by scope alone, so this is opt-in. When set, it must be an absolute URI without a fragment; an explicitly empty value fails startup rather than silently disabling the check |
server.security.mcp.audience | "" (derived) | The MCP resource identifier: both the audience an MCP token must carry and the resource value published in the RFC 9728 protected resource metadata. Unlike the REST audience this check is mandatory and cannot be disabled. Leaving it unset derives <server public URL>/mcp rather than turning it off. Set it only when your registered resource server uses a different identifier; it must then match that identifier, or MCP clients following the published metadata will be rejected |
server.security.management_api_key_hash | "" (empty) | SHA-256 hex digest of the management API key that authenticates calls to this deployment's import API (/import, /import/**) and variable store (/variables, /variables/**, /secrets, /secrets/**), so a deployment pipeline can push configuration without first obtaining OAuth client credentials. The key is presented in its own header, API-Key: <key>, never in Authorization. This setting holds the digest, not the key: the deployment only needs to recognize the key, so a leaked configuration file yields nothing a caller can present. Generate the pair with any SHA-256 tool, for example `printf '%s' "$KEY" |
server.security.token_revocation.enabled | true | If true, revoked tokens are rejected on requests to protected APIs. See Token Revocation |
server.security.token_revocation.source | db | Where the revocation snapshot is read from. db is the only supported value; any other value fails startup validation |
server.security.token_revocation.sync_interval_seconds | 60 | How often the in-memory revocation snapshot refreshes, in seconds. This sets the normal propagation delay before a revoked token stops being accepted on protected API requests. Token introspection is unaffected and always reads current state |
If you set a custom system_permission_prefix, update the Console scopes to match the new permission strings and restart the server.
Trusted Issuer Configuration
Maps to TrustedIssuerConfig in the backend, nested under server.security.trusted_issuer. Setting server.security.trusted_issuer.issuer activates the feature: ThunderID trusts access tokens issued by an external authorization server and validates them against the external server's JWKS endpoint. When issuer is set, jwks_url and audience are required and ThunderID fails to start if either is missing. This is used for federated authentication scenarios where a central ThunderID instance issues tokens that tenant instances accept.
Enabling a trusted issuer is additive and non-breaking. ThunderID still accepts the tokens it issues itself, routed by the token's iss claim. Existing client_credentials, system, and console callers keep working without configuration changes. See Self-Issued Tokens in the Trusted Issuer guide.
| Setting | Default | Description |
|---|---|---|
server.security.trusted_issuer.issuer | "" | Expected value of the token's iss claim. Must match the external authorization server's issuer URL exactly |
server.security.trusted_issuer.jwks_url | "" | URL of the external authorization server's JWKS endpoint used to fetch signing keys. Must use HTTPS (HTTP allowed only for localhost) |
server.security.trusted_issuer.audience | "" | Expected value of the token's aud claim. This should be this server's own identifier (typically its public URL) |
server.security.trusted_issuer.user_id_claim | sub | The claim a caller's id is read from where a control plane records who acted, such as who captured a configuration version or applied one to a gateway (createdBy, appliedBy) |
server.security.trusted_issuer.user_lookup.url | "" | The issuer's endpoint for one user, with {id} where the user's id goes, such as https://cloud.example.com/users/{id}. When set, who captured a version or applied one (createdBy, appliedBy) is given out with id and name, the name read from this endpoint when the record is read and never stored. Unset, those fields are left out; the ids are still recorded, so setting it later names the history |
server.security.trusted_issuer.user_lookup.token_url | "" | The issuer's token endpoint, asked for a client credentials token for the lookups. Required when url is set |
server.security.trusted_issuer.user_lookup.client_id | "" | The machine-to-machine application registered at the issuer for the lookups. Required when url is set |
server.security.trusted_issuer.user_lookup.client_secret | "" | That application's secret. Required when url is set |
server.security.trusted_issuer.user_lookup.scopes | [] | The scopes the token is asked for, as the user endpoint requires |
server.security.trusted_issuer.user_lookup.name_attribute | username | The attribute of the user the name is read from |
server.security.trusted_issuer.user_lookup.cache_ttl | 300 | How long a name once read is kept, in seconds. The token is kept until shortly before it expires |
server.security.trusted_issuer.required_claims | [] | List of claims that every accepted token must contain. Each entry has a claim name and an expected value. If any required claim is missing or does not match, the token is rejected |
Example:
server:
security:
trusted_issuer:
issuer: "https://cloud.example.com"
jwks_url: "https://cloud.example.com/oauth2/jwks"
audience: "https://tenant.example.com"
required_claims:
- claim: "ouId"
value: "tenant-123"
user_lookup:
url: "https://cloud.example.com/users/{id}"
token_url: "https://cloud.example.com/oauth2/token"
client_id: "control-plane-lookup"
client_secret: "<the application's secret>"
scopes: ["internal_user_mgt_view"]
The frontend must include a matching resource parameter in the authorization request so the external authorization server sets the token's aud claim to this server's identifier. See the Trusted Issuer guide for the end-to-end setup.