FerrisKeyDocs

Configuration

The FerrisKey API is configured entirely through command-line flags, each of which has a matching environment variable. Every setting has a default that works for local development, so you only override what your deployment needs.

Run ferriskey-api --help to see the flags and their long descriptions straight from the binary.

Environment variables

Admin

The admin account is created on first boot in the master realm.

VariableDefaultDescription
ADMIN_USERNAMEadminInitial admin username
ADMIN_PASSWORDadminInitial admin password
ADMIN_EMAILadmin@localInitial admin email

FerrisKey applies ADMIN_PASSWORD only while the admin user has no password. Changing the variable on a running deployment does not change the password: use the console. Starting with a different ADMIN_USERNAME creates a second admin user instead of renaming the first.

Database

VariableDefaultDescription
DATABASE_HOSTlocalhostPostgreSQL host
DATABASE_PORT5432PostgreSQL port
DATABASE_NAMEferriskeyDatabase name
DATABASE_USERferriskeyDatabase user
DATABASE_PASSWORDferriskeyDatabase password
DATABASE_SCHEMApublicDatabase schema
DATABASE_MAX_CONNECTIONS20, or DATABASE_MIN_CONNECTIONS when that is higherMost connections one instance opens to the database
DATABASE_MIN_CONNECTIONS2Connections one instance keeps open when idle

The pool is per instance. A connection idle for 60 seconds is closed, down to the minimum. Each connection costs PostgreSQL a few MiB, so a smaller pool is lighter on the database.

Count more than the pool when you size PostgreSQL’s max_connections. With the config cache on (see Caching), each instance holds two extra connections outside the pool for LISTEN/NOTIFY. Across replicas, replicas × (DATABASE_MAX_CONNECTIONS + 2) has to fit under the server’s limit, next to every other client of that database.

Server

VariableDefaultDescription
SERVER_HOST0.0.0.0Bind address
SERVER_PORT3333HTTP port
SERVER_ROOT_PATHemptyURL path prefix, for deployments behind a reverse proxy. A leading / is added if you omit it, and trailing slashes are removed
SERVER_PUBLIC_URLunsetThe origin browsers and service providers reach this deployment at, as scheme://host[:port]. Required for SAML
SERVER_TRUST_FORWARDED_FORfalseRead the caller’s address from X-Forwarded-For instead of the TCP peer. See Client addresses and throttles
SERVER_TOKEN_MAX_IN_FLIGHT32 per CPUMost client_credentials token requests served at once. Past it, a request gets 503 with Retry-After: 1 instead of queuing. Must be at least 1
ALLOWED_ORIGINSemptyComma-separated browser origins allowed on every route
ENVdevelopmentdevelopment or production. Reported back by /config and otherwise unused. Deprecated, kept for compatibility

ALLOWED_ORIGINS deserves a note of its own. Each entry must be a serialized origin (scheme://host[:port]), with no path and no wildcard, and an invalid entry stops the server at start. It applies to every route, including the ones that carry no realm: /config, the health probes, and the API documentation. Clients also declare their own web origins per realm, but those only cover realm-scoped routes, so a console served from a different origin than the API still needs its origin listed here.

SERVER_PUBLIC_URL and SAML

SAML will not work without SERVER_PUBLIC_URL. With it unset, every SAML request (the sign-in endpoint, its continuation and the metadata descriptor) answers HTTP 500 with an error page reading “Single sign-on is not configured on this server. Set SERVER_PUBLIC_URL.” There is no fallback to the Host header, because the entity id is signed into every assertion and must not change when a request arrives through a different hostname.

Other features do not need it. OpenID Connect issuers are still built from each request, using its Host header and X-Forwarded-Proto, so behind a proxy make sure both reach the API unchanged.

VariableDefaultDescription
WEBAPP_URLhttp://localhost:5555URL of the admin console. Used for the login and consent pages, emailed links and the WebAuthn origin, and to register the console’s callback as a redirect URI at start
WEBAPP_URL_FROM_REQUESTfalseBuild those links from the scheme and Host of each request instead of WEBAPP_URL
WEBAPP_ALLOWED_HOSTSemptyComma-separated hosts the webapp is served on. Required when WEBAPP_URL_FROM_REQUEST is on

Leave WEBAPP_URL_FROM_REQUEST off unless one deployment serves several hostnames, each with the webapp at / and the API under SERVER_ROOT_PATH. When it is on:

  • The server refuses to start without WEBAPP_ALLOWED_HOSTS. Links are built from the Host header, so an unlisted host could otherwise put an attacker’s address in password reset and magic link emails.
  • Each entry is host, host:port or *.domain, where * stands for exactly one label: *.example.com matches acme.example.com, not example.com and not a.b.example.com. Case and one trailing dot are ignored. An entry without a port only matches the default port of the scheme.
  • The scheme is https when X-Forwarded-Proto is https and http otherwise, so the proxy must set or overwrite that header.
  • A request whose Host is missing, repeated, not ASCII or not on the list is refused with 400 on the routes that build a webapp link.
  • A passkey stays bound to the host it was registered on: one registered on a.example.com does not work on b.example.com.

WEBAPP_URL still matters at start in this mode: it seeds the redirect URI of the admin console.

TLS

Both variables are required together. Set neither to serve plain HTTP and terminate TLS at your proxy.

VariableDefaultDescription
SERVER_TLS_CERTunsetPath to the certificate file, in PEM format
SERVER_TLS_KEYunsetPath to the private key file, in PEM format

Caching

VariableDefaultDescription
CONFIG_CACHE_TTL_SECONDS5How long an instance keeps realm configuration in memory before reading it again. 0 turns the caches off

Each instance caches realms and their settings, clients, the scopes assigned to a client with their protocol mappers, and the roles and groups of a service account. A write made through the API clears the entry at once on the instance that handled it. The other replicas see the change after the TTL.

The service account state matters most for the client_credentials grant: a change to a service account’s roles or groups, made on any instance, reaches new tokens only after the TTL.

Sessions and revoked tokens are cached for the same TTL, capped at 30 seconds. Replicas tell each other about changes through PostgreSQL LISTEN/NOTIFY on the channel ferriskey_token_state, so a logout or a revocation on one replica empties the caches of the others right away. While an instance has no working listener, it skips the cache and reads sessions from the database. LISTEN needs a connection that keeps its session, so connect to PostgreSQL directly or through a pooler in session mode, not in transaction mode.

With CONFIG_CACHE_TTL_SECONDS=0 nothing is cached, there is no listener and no extra connection per instance.

The per-client web origins that drive CORS are cached for 30 seconds per realm, whatever the TTL, and that delay is fixed. Changing them through the API refreshes the instance that handled the change at once. Other replicas catch up within 30 seconds.

Password hashing

New password hashes use Argon2id with the cost below.

VariableDefaultDescription
PASSWORD_HASH_MEMORY_KIB7168Memory cost of a new hash, in KiB
PASSWORD_HASH_ITERATIONS5Iterations of a new hash
PASSWORD_HASH_PARALLELISM1Parallelism of a new hash
PASSWORD_HASH_MEMORY_BUDGET_MIB256Memory Argon2 may use at once, across every hash and verification in flight, in MiB

These values only apply to new hashes. Verifying a password reads the cost from the stored hash, and when a user signs in with a stored hash that used another cost, FerrisKey replaces it with a hash at the configured cost. Raise the cost, and each user’s hash is upgraded at their next login.

The server checks the values at start and refuses these:

SettingLimit
PASSWORD_HASH_ITERATIONSAt least 1, at most 16
PASSWORD_HASH_PARALLELISMAt least 1, at most 8
PASSWORD_HASH_MEMORY_KIBAt most 262144 (256 MiB), and at least the OWASP minimum for the iteration count: 47104 for 1 iteration, 19456 for 2, 12288 for 3, 9216 for 4, 7168 for 5 or more
PASSWORD_HASH_MEMORY_BUDGET_MIBLarge enough to hold one hash: PASSWORD_HASH_MEMORY_BUDGET_MIB × 1024 must be at least PASSWORD_HASH_MEMORY_KIB

The default cost, 7168 KiB with 5 iterations and a parallelism of 1, is one of the Argon2id configurations OWASP lists.

The budget caps memory, not requests. A hash or verification that would push the total past the budget waits until memory frees up instead of allocating more, so a burst of logins queues rather than growing the process. Verifying a stored hash that costs more than the whole budget takes the entire budget and runs alone.

Keep the budget well under the container’s memory limit. The Helm chart gives the API pod a 512Mi limit by default, and Argon2 is only one part of what the process holds.

Secrets at rest

VariableDefaultDescription
SECRETS_ENCRYPTION_KEYSemptyKeys that encrypt realm signing keys, TOTP seeds and SMTP passwords in the database, written id:base64 and separated by commas. Empty keeps them in clear and logs a warning at each start

--help does not print the value. Secrets at rest covers the format, rotation and what stays unencrypted.

Authorization

VariableDefaultDescription
AUTHZ_ENABLEDfalseMount the AuthZEN evaluation endpoints and their metadata document, plus the authorization management API under /realms/{realm_name}/authz

With the variable off, those routes are not mounted and answer 404. Turning it on does not switch authorization on for anyone: each realm still has to enable tenant authorization for itself. See Authorization.

Logging

VariableDefaultDescription
LOG_FILTERinfoEnvFilter directives, for example info,ferriskey_core=debug
LOG_JSONfalseEmit structured JSON logs instead of human-readable lines

Outbound requests

FerrisKey refuses to call loopback and private addresses when it delivers webhooks or fetches client metadata documents. These two switches lift that rule, and each logs a warning at start while it is on.

VariableDefaultDescription
WEBHOOK_ALLOW_PRIVATE_ENDPOINTSfalseAllow webhook endpoints to resolve to loopback or private addresses. For local development only. Link-local addresses stay refused regardless
CLIENT_METADATA_ALLOW_PRIVATE_ENDPOINTSfalseAllow client metadata documents to be fetched over http from loopback or private addresses. For tests only. Link-local addresses stay refused regardless

Observability

VariableDefaultDescription
ACTIVE_OBSERVABILITYfalseTurn on tracing and metrics export
OTLP_ENDPOINTunsetOTLP collector endpoint (gRPC) for traces and logs. Required when ACTIVE_OBSERVABILITY is on
METRICS_ENDPOINTunsetOTLP collector endpoint (gRPC) for metrics. Required when ACTIVE_OBSERVABILITY is on

Prometheus metrics are also exposed directly on /metrics, with no collector needed.

Some routes need no token

/metrics, /config, the API documentation (/scalar, /swagger-ui, /redoc, /rapidoc and /api-docs/openapi.json) and the health probes (/health/live and /health/ready) are mounted without authentication. They sit under SERVER_ROOT_PATH when you set one. /config returns the version and the ENV value, and /metrics returns HTTP request metrics. If the API is reachable from the internet, decide at your proxy or ingress which of them to expose.

Client addresses and throttles

FerrisKey records the caller’s IP address in SeaWatch security events and Compass flows, and a few throttles count requests per address. By default the address is the one of the TCP peer. Behind an Ingress or any proxy, that is the proxy: every caller shares one address, the audit records are honest but of little use, and the throttles count all your users as one.

Set SERVER_TRUST_FORWARDED_FOR=true to take the address from X-Forwarded-For instead. FerrisKey reads the first, leftmost entry, so the proxy has to replace the header with the real client address rather than append to it. Only enable it when a proxy you control is the only way in. With the flag on, a client that reaches the API directly can write any address it likes, and that address is what gets recorded and throttled.

The throttles are fixed, kept in memory and counted per replica, so running three replicas triples the effective allowance. IPv6 callers are grouped by their /64 prefix.

ThrottleLimitCounted perAnswer
Failed client authentication at the token endpoint10 failures per minuteIP address, realm and client id429 with Retry-After: 60
Dynamic client registration10 requests per minuteIP address429 with Retry-After: 60
Dynamic client registration60 registrations per hourRealm429 with Retry-After: 60
First use of a client metadata document URL that is not yet a client30 per minuteIP addressTreated as an invalid client

Startup checks

The API validates its configuration at start and exits, printing the reason, when one of these holds:

SettingRefused when
ALLOWED_ORIGINSAn entry is not an http or https origin: it is a wildcard, is not a URL, or carries a path, query, fragment or credentials
SECRETS_ENCRYPTION_KEYSAn entry is not id:base64, an id uses characters other than letters, digits, - and _ or appears twice, or a key is not 32 bytes in base64
PASSWORD_HASH_*A value is outside the limits listed under Password hashing
DATABASE_MAX_CONNECTIONS, DATABASE_MIN_CONNECTIONSThe maximum is 0, or the minimum is above the maximum
WEBAPP_URL_FROM_REQUESTIt is on and WEBAPP_ALLOWED_HOSTS is empty or holds an entry that is not host, host:port or *.domain
ACTIVE_OBSERVABILITYIt is on and OTLP_ENDPOINT or METRICS_ENDPOINT is missing
SERVER_TLS_CERT, SERVER_TLS_KEYOnly one of them is set
SERVER_TOKEN_MAX_IN_FLIGHTThe value is 0

A data migration that fails also stops the start. See Upgrading.

Generating the OpenAPI spec

The API binary carries a subcommand that prints its OpenAPI document without touching the database:

ferriskey-api gen-api --output openapi.json

Omit --output to write the spec to stdout.

Deployment examples

Docker Compose

services:
  api:
    environment:
      - DATABASE_HOST=db
      - DATABASE_PORT=5432
      - DATABASE_NAME=ferriskey
      - DATABASE_USER=ferriskey
      - DATABASE_PASSWORD=a-strong-password
      - ADMIN_USERNAME=admin
      - ADMIN_PASSWORD=a-strong-admin-password
      - ADMIN_EMAIL=admin@yourorg.com
      - ALLOWED_ORIGINS=https://iam.yourorg.com
      - WEBAPP_URL=https://iam.yourorg.com
      - LOG_FILTER=info

Bare metal

Set the variables directly, or put a .env file in the directory you start the server from. The server also looks in the parent directories.

DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=ferriskey
DATABASE_USER=ferriskey
DATABASE_PASSWORD=a-strong-password
ADMIN_USERNAME=admin
ADMIN_PASSWORD=a-strong-admin-password
ADMIN_EMAIL=admin@yourorg.com
ALLOWED_ORIGINS=https://iam.yourorg.com
WEBAPP_URL=https://iam.yourorg.com
SERVER_PORT=3333
LOG_FILTER=info

Then run the server:

./ferriskey-api

Moving configuration between realms

Portal themes, portal layouts and email templates export and import as files, and password hashes from another IAM can be imported per user. See Export & Import.

SMTP configuration

Email delivery for magic links, password resets, and email verification is configured per realm, in the database, not through environment variables. Open Emails in the admin console sidebar, switch to the SMTP tab, and fill in the host, port, encryption mode, credentials and sender. The SMTP password is one of the values SECRETS_ENCRYPTION_KEYS encrypts.

No global SMTP

Every realm carries its own SMTP configuration and can point at a different mail provider. The Email & Templates guide has the full field reference and the API endpoints.