Skip to content

Latest commit

 

History

History
503 lines (404 loc) · 77.1 KB

configuration.md

File metadata and controls

503 lines (404 loc) · 77.1 KB

Configuration Reference

This is the single source of truth for how Seamless Auth API is configured. It lists every environment variable, whether it is required, its default, and where it takes effect.

There are two configuration layers:

  • Environment variables boot the process, connect external systems (database, messaging, signing keys), and seed the runtime config on first start.
  • system_config table holds runtime settings (token TTLs, roles, origins, login methods, OAuth providers, rate limits). These are seeded from environment variables on first boot and are the source of truth afterwards.

Once an admin changes an env-mapped value through the admin system-config endpoints, that row becomes authoritative and its environment variable is no longer consulted. Until then, the env var is re-applied on every boot. See Environment vs system_config.

Minimal configuration to boot

For local development, these are the only values you must set (all are present in .env.example with working defaults):

Variable Example Purpose
APP_NAME Seamless Auth Example Human-readable app name (min 3 chars)
APP_ID local-dev Stable application identifier
APP_ORIGINS http://localhost:3000 Callers allowed by CORS
ISSUER http://localhost:5312 JWT iss claim and issuer URL
DEFAULT_ROLES user Roles assigned to every new user
AVAILABLE_ROLES user,admin Roles allowed in the system
ACCESS_TOKEN_TTL 30m Access token lifetime (\d+[smhd])
REFRESH_TOKEN_TTL 1d Refresh token lifetime (\d+[smhd])
RATE_LIMIT 100 Global request rate limit
DELAY_AFTER 50 Requests before slow-down kicks in
RPID localhost WebAuthn relying party ID
ORIGINS http://localhost:5173 WebAuthn allowed origins (comma-separated)
FRONTEND_URL http://localhost:5173 Base URL for emailed magic links
SEAMLESS_SERVICE_TOKEN <32-byte hex> Trusted server-adapter and internal bearer secret (API_SERVICE_TOKEN still accepted)
Database connectivity see below DATABASE_URL/DB_URI or the DB_* set

In production, also REFRESH_TOKEN_LOOKUP_SECRET and DECOY_SUBJECT_SECRET, plus TOTP_SECRET_ENCRYPTION_KEY once TOTP is used and OAUTH_STATE_SECRET once an OAuth provider is configured. The server checks these itself after reading its stored config, rather than in validateEnvs.sh, because which ones apply depends on that config.

Everything else is optional or environment-specific.

The container entrypoint validates these at startup via validateEnvs.sh and exits with a clear message if one is missing.

Environment variables

Legend: Required = process refuses to boot without it (enforced by validateEnvs.sh in the container path). Seeds system_config = value is copied into the runtime config table on first boot.

Application

Variable Required Default Seeds system_config Notes
NODE_ENV No development No production enables stricter checks (JWKS, secrets).
HOST No 0.0.0.0 No Bind address.
PORT No 5312 No Listen port.
TRUST_PROXY No unset No Express trust proxy value, used to read the client address from X-Forwarded-For. Set it to the number of proxies in front of the server (1 behind a single load balancer); loopback and a comma-separated IP/CIDR allowlist also work. Without it the rate limiters bucket every client behind the proxy together. Leave it unset when the server is reachable directly, or a client can forge the header and pick its own bucket.
APP_NAME Yes - app_name Min 3 characters.
APP_ID Yes - No Stable app identifier. Recorded as deployment_id on every auth event and as infraId on every session, so rows collected across a fleet stay attributable.
APP_ORIGINS Yes - No CORS allowlist for callers of this API (comma-separated). Distinct from WebAuthn ORIGINS. A cross-origin request from an origin not listed here is refused with 403 and the route never runs. A request carrying no Origin header, such as one from a server adapter, is unaffected, and so is a same-origin request, which is why the admin console needs no entry.
ISSUER Yes - No JWT iss and issuer URL.
FRONTEND_URL Yes - frontend_url Base URL that emailed magic links point at. Boot-required, but unlike the rest of this table it is enforced by bootstrapSystemConfig, not validateEnvs.sh: an instance with no frontend_url row and no FRONTEND_URL clears env validation and then exits with Missing required system config "frontend_url". Once a value exists, magic-link URLs fall back to the first ORIGINS entry only if the row is later cleared.
DEFAULT_ROLES Yes - default_roles Roles for new users (comma-separated).
AVAILABLE_ROLES Yes - available_roles Roles permitted in the system (comma-separated). Assigning a role that is not listed is rejected. Include admin:read and admin:write to offer scoped admin. See Scoped Admin Roles.

Auth and tokens

Variable Required Default Seeds system_config Notes
ACCESS_TOKEN_TTL Yes - access_token_ttl Format \d+[smhd], e.g. 30m.
REFRESH_TOKEN_TTL Yes - refresh_token_ttl Format \d+[smhd], e.g. 1d. Falls back to 1d when unset.
RATE_LIMIT Yes - rate_limit Global limit, positive integer.
DELAY_AFTER Yes - delay_after Slow-down threshold, non-negative integer.
LOGIN_METHODS No passkey,magic_link login_methods Any of passkey,magic_link,email_otp,phone_otp,oauth. .env.example ships passkey,magic_link,email_otp so a stock instance is CLI/headless-loginable; email_otp needs a configured messaging transport or the external-delivery path.
PASSKEY_LOGIN_FALLBACK_ENABLED No true passkey_login_fallback_enabled When false, an account that holds a passkey continues with passkey only. The client's passkeyAvailable hint on POST /login cannot widen that: a caller reporting no passkey support is still offered passkey only, because otherwise any caller could ask for a weaker method by claiming not to support passkeys. A browser that genuinely cannot run the ceremony cannot sign in, which is what this setting means. Accounts with no passkey are unaffected and keep the configured methods. The rule binds on every continuation endpoint, not only on the list /login returns, so a passkey holder calling an email code, phone code, magic link, TOTP or email verification endpoint directly, or finishing an OAuth sign-in, gets 403 login_method_disabled.
PHISHING_RESISTANT_ONLY No false phishing_resistant_only When true, a session starts only from a passkey. Email and phone codes, magic links, TOTP and OAuth are refused (403 login_method_disabled, and OAuth providers are hidden) whatever LOGIN_METHODS and PASSKEY_LOGIN_FALLBACK_ENABLED say, and GET /system-config/public reports loginMethods: ["passkey"]. The one exception is a new account: the email code that verifies its address starts one session so its first passkey can be enrolled. An account that never enrolls one cannot sign in again on its own and needs admin recovery. Off by default, so existing deployments are unaffected.
LOCKOUT_POLICY No {"enabled":true,"maxFailures":10,"windowSeconds":900,"lockoutSeconds":900} lockout_policy JSON. Set enabled:false only when an upstream policy handles lockout.
SESSION_IDLE_TTL No 8h session_idle_ttl Format \d+[smhd]. How long a session may go unrefreshed. The absolute session lifetime is REFRESH_TOKEN_TTL, measured from sign-in and not extended by refresh; this only binds while it is the shorter of the two.
MAX_CONCURRENT_SESSIONS No unlimited max_concurrent_sessions How many sessions one user may hold at once. Unset, empty, null, none or unlimited all mean no cap, which is the default. When a signed-in user is at the limit, the oldest session is revoked to make room and a session_evicted auth event is recorded; the sign-in itself always succeeds. Lowering the limit converges on each user's next sign-in rather than one session per login. NIST 800-53 AC-10.
AUTHENTICATOR_POLICY No {"attachment":"any","userVerification":"required","attestation":"none","requireKnownAuthenticator":false,"syncedPasskeys":"allow","aaguidAllowList":[],"aaguidDenyList":[]} authenticator_policy JSON. attachment is any, platform or cross-platform; any offers both built-in authenticators and roaming security keys, and naming one narrows the browser picker and rejects a request asking for the other. userVerification is required, preferred or discouraged and drives both what the browser is asked for and what the server enforces. attestation is none or direct; direct asks the authenticator to identify itself, which is what enables validation against the FIDO Metadata Service, and carries a privacy cost so it is off unless needed. requireKnownAuthenticator refuses an authenticator the metadata service does not list, and only applies under direct. It also refuses a credential that self attests or presents no attestation, since neither can be looked up, so it rules out authenticators that ship no attestation certificate. Changing attestation needs a restart, because the metadata service is prepared at startup. syncedPasskeys is allow or block; allow, the default, enrols a credential that can leave the device it was created on, which includes every iCloud Keychain and Google Password Manager passkey, so a stock instance accepts the passkey a normal device offers. block refuses those, which is what a deployment issuing its own authenticators wants. aaguidAllowList and aaguidDenyList restrict which authenticator models may register, and need attestation set to direct to mean anything.
RECOVERY_CODES No {"enabled":true,"requireAtEnrollment":false} recovery_codes JSON. Single-use recovery codes. A user has codes only after generating a set, so enabled matters once someone has. requireAtEnrollment asks for a set during first passkey enrollment. Recovery codes are not a login method, so LOGIN_METHODS cannot turn them off.
ACCOUNT_RECOVERY No {"enabled":false,"delay":"24h"} account_recovery JSON. Self-service recovery through a verified email or phone. Off by default. It completes only after delay (1h to 30d), and every other channel and session is told so the owner can cancel.

/login response timing

Variable Required Default Notes
LOGIN_RESPONSE_FLOOR_MS No 250 Minimum time POST /login takes, in milliseconds. A real identifier reads more tables than an unknown one, and that difference is measurable even when the bodies match, so every answer is held to the same floor. Set it above the slowest real login the deployment sees; 0 turns it off.

Not a system_config key: that schema is shared through @seamless-auth/types, so a key there means a coordinated release across both SDKs for what is an operational tuning knob rather than a policy changed at runtime. See security-posture.md for what it is defending.

Audit retention

Audit events are kept forever unless retention is set. Retention archives before it deletes, so it does nothing at all without an archive directory. The job runs a minute after boot and then daily, and each run also logs the audit chain head as an external anchor. See Audit trail integrity.

Variable Required Default Notes
AUDIT_RETENTION_DAYS No - Whole days to keep audit events. Unset, or anything that is not a positive whole number, keeps everything. Set it from the records retention schedule that applies to the deployment, not from a product default: authentication audit events are records.
AUDIT_ARCHIVE_DIR With retention - Where expired events are written before they are deleted, as auth-events-<firstSeq>-<lastSeq>.ndjson with a .sha256 file beside each. Use durable storage, such as a mounted volume, since a file here is the only copy once its rows are gone.
AUDIT_RETENTION_DATABASE_URL No DATABASE_URL A connection for the retention job alone, so it can run as a role that holds DELETE on auth_events while the application role does not.

Service tokens and secrets

Variable Required Default Notes
SEAMLESS_SERVICE_TOKEN Yes - Trusted server-adapter and internal bearer secret. No other secret falls back to it.
API_SERVICE_TOKEN No - Supported fallback name for SEAMLESS_SERVICE_TOKEN. Used only when SEAMLESS_SERVICE_TOKEN is unset, with an info notice at boot. Setting both to different values stops the server at boot.
REFRESH_TOKEN_LOOKUP_SECRET Production development-only value HMAC key for indexed refresh-token lookup fingerprints. Always required in production.
TOTP_SECRET_ENCRYPTION_KEY Production, once TOTP is used development-only value Encrypts stored TOTP secrets (hashed with SHA-256 to derive the key, so any high-entropy value works). Production refuses to start while any TOTP credential exists without it, and POST /totp/enroll/start answers 409 secret_required until it is set.
OAUTH_STATE_SECRET Production, once OAuth is configured development-only value Signs OAuth state. Production refuses to start with a provider configured and no value, and adding a provider answers 409 secret_required until it is set.
DECOY_SUBJECT_SECRET Production development-only value HMAC key the decoy pre-auth subjects on POST /login derive from. Always required in production. Rotating it re-maps which subject an unknown identifier gets, which is harmless: decoys are stateless and pre-auth tokens live five minutes.
OAUTH_PROVIDER_PRIVATE_HOSTS No - Comma-separated OAuth provider hosts allowed to resolve to private or loopback addresses in production, for an on-premises IdP. Other provider hosts must resolve to public addresses. Link-local is always refused.

None of these falls back to the service token. Development (any NODE_ENV other than production) uses a fixed development-only value for each unset one, with a warning. See docs/production-operations.md for upgrading from a deployment that relied on the fallback.

Database

Provide either a connection string (DATABASE_URL, or DB_URI as an alias) or the discrete DB_* variables. A connection string wins when set and is preferred in containers and hosted environments. The same resolution is shared by the running app and by the startup migrations, so both connect the same way.

Variable Required Default Notes
DATABASE_URL Conditional - Full Postgres connection string. If set, the DB_* host set is not required.
DB_URI Conditional - Alias for DATABASE_URL, used when DATABASE_URL is unset.
DB_HOST Conditional localhost Required when no connection string is set.
DB_PORT Conditional 5432 Required when no connection string is set.
DB_NAME Conditional seamless_auth Required when no connection string is set.
DB_USER Conditional - Required when no connection string is set.
DB_PASSWORD No - Password for DB_USER.
DB_LOGGING Yes false Logs SQL from the app and startup migrations.
RUN_MIGRATIONS No true false skips the container entrypoint's migration step. See Migrations.

Migrations

By default the container entrypoint applies pending migrations on every start, then starts the server. That costs a second cold Node process on each boot, and in a service running several tasks every task races to apply the same change.

A deployment can apply migrations once per deploy instead:

  1. Run the image with the migrate argument as a one-off task before the service rolls (for example an ECS RunTask with the command override ["migrate"]). It validates the environment, applies pending migrations, creating the database if it does not exist, and exits.
  2. Set RUN_MIGRATIONS=false on the service so its tasks skip the step.

Either way the server checks at startup that every migration it ships with has been applied, and refuses to start if one is pending. Skipping the entrypoint step therefore cannot run new code against an old schema; a deploy that forgot step 1 fails to start instead. An older build starting against a newer schema, as a rollback does, is allowed and logged.

TLS to Postgres

TLS is off by default, which suits a local Postgres on loopback. Managed databases should turn it on, and it is mandatory when the cluster enforces it (for example Aurora/RDS with rds.force_ssl = 1).

Variable Required Default Notes
DB_SSL No off true/false, or an sslmode value: require, prefer, allow, verify-ca, verify-full.
DB_SSL_CA No - Server CA bundle, inline PEM or a file path. Supplying it turns certificate verification on.
DB_SSL_REJECT_UNAUTHORIZED No see below Forces certificate verification on or off, overriding the default for the selected mode.

Resolution order:

  1. DB_SSL wins when set.
  2. Otherwise an sslmode query parameter on the connection string is honored.
  3. Otherwise TLS is off.

Certificate verification follows libpq semantics: require (and DB_SSL=true) encrypts without verifying the server certificate, while verify-ca and verify-full verify it. Verification is also enabled whenever DB_SSL_CA is supplied. Amazon's RDS certificates chain to a private CA that Node does not trust by default, so verifying against RDS means pointing DB_SSL_CA at the RDS global bundle.

WebAuthn

Variable Required Default Seeds system_config Notes
RPID Yes - rpid Relying party ID (a domain, no scheme).
ORIGINS Yes - origins WebAuthn allowed origins (comma-separated URLs). Distinct from APP_ORIGINS. When you serve the admin console, the origin it loads from must also appear here, alongside the app origins. See Admin dashboard.

OAuth

Variable Required Default Seeds system_config Notes
OAUTH_PROVIDERS No [] oauth_providers JSON array of provider configs. Provider client secrets stay in env vars referenced by each provider's clientSecretEnv, never in this JSON or system_config. See docs/oauth.md.

First admin

Variable Required Default Notes
OWNER_EMAIL No - Comma separated list of owner emails. An account created for one of these is granted one admin role, and auth events about it carry owner = true.

The role is admin:write when available_roles includes it, otherwise admin, and nothing when neither is available (src/lib/ownerAdmin.ts). It is granted only when the account is created: setting OWNER_EMAIL after the owner already has an account promotes nobody.

Email OTP registration creates the account, with the role, at POST /registration/register, before the address is verified. An unverified account cannot sign in (/login answers it as an unknown identifier), so only someone who receives the code at the owner address can use it. OAuth signup creates the account from a verified provider email. When OWNER_EMAIL is unset the grant is a no-op and the instance has no admin until one is assigned another way.

Admin dashboard (optional)

This API can serve the admin dashboard SPA (seamless-auth-admin-dashboard) at the /console subpath on its own origin (same origin as the API), so a managed instance ships its dashboard without a separate host. Serving is additive: the admin API routes are registered first and keep priority, and the SPA history fallback only handles unmatched /console/* GETs. No CORS changes are needed because the dashboard is same-origin with the API.

/console is chosen deliberately so the SPA namespace never overlaps the admin API, which lives under /admin/* (/admin/users, /admin/organizations, /admin/sessions, /admin/audit-events, /admin/credential-count). A path under /admin would let a hard refresh or deep-link on one of those routes resolve to the API instead of the SPA; /console has no such overlap.

Variable Required Default Notes
SERVE_ADMIN_DASHBOARD No true Serve the SPA at /console. Set to false to disable. When enabled but no build is bundled, serving is skipped.
ADMIN_DASHBOARD_DIR No ./admin-dashboard Directory of the built SPA, relative to the compiled bundle. The Docker image copies the dashboard build here.

Build integration and version bumps. The dashboard repo is public, but its npm package is not published ("private": true in its package.json), so the Docker image fetches it from git at a pinned ref and runs the dashboard's own same-origin build (npm run build:console, which sets VITE_BASE_PATH=/console/ and VITE_SAME_ORIGIN=true so the assets, the router basename, and the origin-derived API base all agree) in a dedicated stage (see the admin-dashboard stage in Dockerfile). Bump the dashboard shipped to tenants by raising the SEAMLESS_ADMIN_DASHBOARD_REF build ARG; the new version then flows to tenants through the normal upstream auth-image release. The ref is the full commit SHA of the dashboard release tag v0.10.1 (e16c1e84e6a105fb7cadc328570a2fbc469f463d), because a tag can be moved and a SHA cannot. The build refuses a ref that is not a full SHA and checks the checked-out commit against it. To bump, resolve the new tag with git ls-remote https://github.com/fells-code/seamless-auth-admin-dashboard refs/tags/<tag> (use the ^{} line for an annotated tag) and update the tag named in the comment above the ARG. v0.2.0 was the first release to ship the same-origin /console build.

Because the same-origin build derives its API base from the page origin and still speaks the server-adapter contract (it calls <origin>/auth/... with credentials: include), the origin that serves /console must also expose the @seamless-auth/express adapter's /auth/* cookie endpoints. This bare API is Bearer-only with no /auth prefix and no cookies, so loading the SPA directly from the API origin renders the UI but its API calls fail.

The supported wiring is @seamless-auth/express (>= 0.8.0), which serves the dashboard and the adapter from one origin: mount the console reverse-proxy alongside the auth routes so /console is proxied to this API while /auth/* is handled by the adapter.

app.use('/auth', createSeamlessAuthServer(opts));
app.use('/console', createSeamlessConsoleProxy({ authServerUrl: opts.authServerUrl }));

The proxy forwards /console requests to this API's /console (so the dashboard version tracks the auth image), while the adapter bridges the dashboard's cookie session to Bearer upstream.

Prerequisites for browser flows started from the console. Serving the console changes the origin that passkey ceremonies carry, and for OAuth it changes the full redirect URI. Two separate allowlists validate these, and both fail the same confusing way: the flow starts normally and only fails partway through, so the configuration looks correct right up to the point it does not work. Both apply to both deployment shapes above (served directly by this API at its own origin, or reverse-proxied through createSeamlessConsoleProxy at the adopter's origin).

  1. The console's origin must be in ORIGINS. Passkey and step-up ceremonies started in the console carry the origin the console was loaded from, and WebAuthn verification checks it against origins in system_config (seeded from ORIGINS). If it is missing, start returns 200 with a challenge and the browser prompts normally, but finish fails, so step-up never completes. Add the console's origin (for example https://api.example.com) to ORIGINS alongside the app origins. RPID does not need to change, since the RP ID ignores the port.

  2. The console's callback must be in each provider's redirectUris. The dashboard builds its OAuth redirect with the router basename applied, so the /console build sends <origin>/console/oauth/callback, a different path (and host) from a typical web app's /oauth/callback. redirectUris is matched exactly, so this URL must be listed explicitly on each provider, and it must also be registered as an authorized redirect URI with the identity provider itself. If it is missing here, POST /oauth/:providerId/start returns 400 before any redirect; if it is missing at the provider, the provider rejects with redirect_uri_mismatch at the consent screen. See docs/oauth.md.

Direct messaging (optional)

Needed only when this API sends OTP/magic-link messages itself. Not needed when a SeamlessAuth server adapter handles delivery in external mode. validateEnvs.sh enforces provider-specific combinations (for example, Twilio requires SID, auth token, and a from-number).

Variable Required Default Notes
MESSAGING_ENABLE_IN_DEV No (dev only) false Exercise direct delivery even when NODE_ENV=development.
MESSAGING_AWS_REGION Conditional us-east-1 Required when email-from is set or SMS provider is aws.
MESSAGING_EMAIL_FROM Conditional - Enables email delivery when set.
MESSAGING_SMS_PROVIDER No - aws or twilio.
MESSAGING_SMS_FROM Conditional - Required for Twilio.
MESSAGING_TWILIO_ACCOUNT_SID Conditional - Required for Twilio.
MESSAGING_TWILIO_AUTH_TOKEN Conditional - Required for Twilio.

Store review accounts (optional)

App Store and Google Play review sign in to a demo account, and a reviewer cannot read the inbox a passwordless code is sent to. A listed address is issued a fixed email code instead of a random one, so the code can go in the review notes. The rest of the flow is unchanged: the code is stored hashed, expires after five minutes, is rate limited, and is still delivered. Phone codes, magic links and passkeys are not affected.

Variable Required Default Notes
REVIEW_ACCOUNT_EMAILS No - Comma separated addresses, matched case-insensitively.
REVIEW_ACCOUNT_CODE Conditional - Six letters. Required when REVIEW_ACCOUNT_EMAILS is set; boot fails otherwise.

The code does not rotate, so use an inbox you own, keep nothing of value in the account, create it before review (an unknown address gets the ordinary decoy answer), and clear both variables once review is over.

Every email code event for a review address that is issued the fixed code (otp_success and otp_failed for the send, verify_otp_success and verify_otp_failed for the check) carries metadata.reviewAccount: true, so its use is visible in the audit trail. The code itself is never recorded. GET /admin/review-accounts reports whether review accounts are on, which addresses are listed, and how often the fixed code was used recently; see Review Accounts.

Production signing and JWKS

Required when NODE_ENV=production. In development, signing keys are generated locally.

Variable Required Default Notes
SEAMLESS_JWKS_ACTIVE_KID Prod only - Key ID of the active signing key.
SEAMLESS_JWKS_KEY_<KID>_PRIVATE Prod only - PKCS8 private key PEM for the active KID.
SEAMLESS_JWKS_PUBLIC_KEYS Prod only - JSON { "keys": [{ "kid", "pem", "createdAt" }] }. Every published key, not just the active one: the endpoint serves them all and token verification resolves by kid, which is what makes a rotation overlap rather than cut over.

See docs/production-operations.md for key rotation.

Development signing keys

Outside production the server creates an RSA key pair at startup, before it starts listening, so /.well-known/jwks.json publishes a key from the first request. If no key can be read, the endpoint answers { "keys": [] } and logs why, rather than a 500.

Variable Required Default Notes
SEAMLESS_DEV_KEYS_DIR No ./keys/dev Directory holding private.pem (and public.pem). Relative to the working directory, so /app/keys/dev in the image. Ignored when NODE_ENV=production.

The kid is dev- followed by the first 16 characters of the key's RFC 7638 JWK thumbprint (base64url). A regenerated key therefore gets a new kid, and adapters that cache the JWKS refetch it on their own instead of failing verification against a stale key. Keep the directory on persistent storage (the bundled docker-compose.yml mounts a dev-keys volume at /app/keys) so a recreated container keeps signing with the same key. Only private.pem has to survive; the public key is derived from it.

Testing and conformance (never production)

These loosen the server deliberately, so each one is refused under NODE_ENV=production and logs that it was ignored. They exist for automated suites and FIDO2 conformance runs, never for a customer deployment.

Variable Required Default Notes
ALLOW_UNCREDENTIALED_DELIVERY_SECRETS No false Lets a caller request external delivery without a valid x-seamless-service-token, so OTPs and magic-link URLs come back in the response body and the stack runs with no mail or SMS transport.
DISABLE_AUTH_RATE_LIMITS No false Skips every auth limiter (global, OTP, magic link, registration, OAuth, JWKS). A conformance run drives hundreds of ceremonies from one IP and is otherwise throttled.
FIDO_CONFORMANCE_MODE No false Mounts the FIDO2 conformance interface at /conformance. Unset, the routes are never registered, so they reach neither Express nor the OpenAPI document and the paths answer the ordinary 404. The surface takes no authentication and issues no sessions.
FIDO_CONFORMANCE_MDS_URLS No - Comma separated MDS3 endpoints the run stands up. Read only in conformance mode.
FIDO_CONFORMANCE_MDS_ROOT_CERT_FILE No - Path to the PEM the run signs its metadata blobs with. Read only in conformance mode. Without it the server trusts only the production FIDO root and every metadata test fails on a certificate error.
FIDO_CONFORMANCE_METADATA_DIR No - Directory of metadata statement JSON files, read recursively. Read only in conformance mode. Statements are accepted bare or wrapped in an MDS entry, so the tools' metadata archive can be unzipped in unedited.

Development mode outside localhost

Anything other than NODE_ENV=production is development mode: per-instance signing keys on disk, the /docs explorer, fallback development secrets, and the variables above. The server refuses to start in development mode when ISSUER or any APP_ORIGINS entry is reachable from outside, and names each one. Local means localhost, *.localhost, *.test, a single-label name such as a compose service, a loopback or private address.

That check reads configuration, not the address the server listens on, so it cannot tell when a machine left on these localhost defaults is reachable from the network. The bundled docker-compose.yml and docker-compose.dev.yml therefore publish the API on 127.0.0.1 only, because Docker's port publishing bypasses host firewalls such as ufw. docker-compose.yml does not publish Postgres at all; docker-compose.dev.yml publishes it on 127.0.0.1 for running the suite with TEST_DB=postgres. Neither sets a default OWNER_EMAIL, so no fixed address is granted admin. To reach the stack from another machine, put a TLS proxy in front of it and move to a production configuration (docs/production-operations.md) rather than widening the bind address.

Variable Required Default Notes
SEAMLESS_ALLOW_INSECURE_DEV No false Start in development mode on a non-local deployment anyway, for a shared staging box. Logs a warning and reports insecureDevelopmentMode on /health/status.

See docs/fido-conformance.md for the full run procedure, including the AUTHENTICATOR_POLICY posture a conformance run expects.

system_config keys

These runtime values live in the system_config table. On first boot, bootstrapSystemConfig.ts seeds any missing key from its mapped environment variable (systemConfig.envMap.ts) or from a built-in default (systemConfig.defaults.ts). Validation is enforced by systemConfig.schema.ts.

Key Type Seeded from env Default
app_name string (min 3) APP_NAME -
default_roles string[] DEFAULT_ROLES -
available_roles string[] AVAILABLE_ROLES -
login_methods enum[] LOGIN_METHODS ["passkey","magic_link"]
passkey_login_fallback_enabled boolean PASSKEY_LOGIN_FALLBACK_ENABLED true
phishing_resistant_only boolean PHISHING_RESISTANT_ONLY false
oauth_providers provider[] OAUTH_PROVIDERS []
lockout_policy object LOCKOUT_POLICY {enabled,maxFailures:10,windowSeconds:900,lockoutSeconds:900}
authenticator_policy object AUTHENTICATOR_POLICY {attachment:"any",userVerification:"required",attestation:"none",requireKnownAuthenticator:false,syncedPasskeys:"allow",aaguidAllowList:[],aaguidDenyList:[]}
recovery_codes object RECOVERY_CODES {enabled:true,requireAtEnrollment:false}
account_recovery object ACCOUNT_RECOVERY {enabled:false,delay:"24h"}
session_idle_ttl string (\d+[smhd]) SESSION_IDLE_TTL 8h
max_concurrent_sessions number or null MAX_CONCURRENT_SESSIONS null (no limit)
access_token_ttl string (\d+[smhd]) ACCESS_TOKEN_TTL -
refresh_token_ttl string (\d+[smhd]) REFRESH_TOKEN_TTL -
rate_limit integer > 0 RATE_LIMIT -
delay_after integer >= 0 DELAY_AFTER -
flow_rate_limits object FLOW_RATE_LIMITS {windowSeconds:900,otp:{perIp:10,perIdentity:5},magicLink:{perIp:20,perIdentity:5},oauth:{perIp:30,perProvider:10}}
rpid string RPID -
origins url[] ORIGINS -
frontend_url url FRONTEND_URL -
magic_link_redirect_uris string[] MAGIC_LINK_REDIRECT_URIS []

Flow rate limits

rate_limit bounds every request per IP per minute. The message-carrying and provider flows carry a second, tighter set of limits, flow_rate_limits, applied per 15-minute window (the windowSeconds default) and split two ways per flow:

  • perIdentity bounds how often one sign-in attempt can message one address or phone (otp, magicLink), keyed on the identifier plus the ephemeral token's attempt (jti, or the requester's IP without one), and how often one provider can be started from one address (oauth.perProvider). All attempts for one identifier together are capped at four times perIdentity, so a stranger starting attempts for someone else's address cannot use up the owner's allowance (security posture). This is the abuse a sender cares about, and it rarely needs changing.
  • perIp bounds how many distinct flows one network location can drive. This is the enumeration and spam guard, and it is the one a mobile audience runs into: carriers put thousands of subscribers behind one IPv4 address, so otp.perIp: 10 refuses a phone audience at modest scale while never troubling a web one. A deployment that serves a native app should raise the perIp values and leave perIdentity where it is.

The defaults are the constants these limiters carried before the key existed, so an instance that sets nothing behaves as it did. A partial value fills in the flows it leaves out. A changed limit applies on the next request; a changed windowSeconds starts fresh counters.

{ "otp": { "perIp": 500 }, "magicLink": { "perIp": 500 } }

Environment vs system_config

Each env-mapped system_config row is seeded from its environment variable on first boot. After that, precedence depends on whether the row has been changed through the admin API:

  • Until an admin changes it (updatedBy IS NULL), the mapped environment variable is re-applied on every boot. This keeps env-driven config authoritative and stops a seeded default from permanently shadowing a later .env change. When a boot overwrites a stored value that differs, it is logged at warn.
  • Once an admin changes it through the admin system-config endpoints, the row records who made the change (updatedBy) and becomes authoritative. The mapped environment variable is no longer consulted for that key, so the admin change survives restarts and later .env edits do not affect it.

Both the whole-config PATCH /system-config/admin and the per-provider /system-config/oauth-providers endpoints set updatedBy, whether the caller authenticates with a service token or with an admin access token. So a change made in the admin console sticks. To make a seeded value change through env again after an admin has taken it over, clear that row's updatedBy (or delete the row and let it re-seed).

A row edited directly in the database is a third case, and the one that tends to confuse. A direct UPDATE leaves updatedBy NULL, so the row still counts as env-driven and the next boot reverts it to the environment variable. The value looks correct in the database right up until the process restarts. Change it through the admin endpoints, or change the environment variable, rather than editing the row.

Reads are cached in-process for five minutes. Both write paths already invalidate that cache, so a change through the admin endpoints takes effect immediately; a direct database edit does not, and is not visible until the entry expires. See getSystemConfig.ts.

See also