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_configtable 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.
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.
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.
| 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. |
| 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. |
| 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 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. |
| 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_ENVother thanproduction) 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.
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. |
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:
- Run the image with the
migrateargument as a one-off task before the service rolls (for example an ECSRunTaskwith the command override["migrate"]). It validates the environment, applies pending migrations, creating the database if it does not exist, and exits. - Set
RUN_MIGRATIONS=falseon 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 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:
DB_SSLwins when set.- Otherwise an
sslmodequery parameter on the connection string is honored. - 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.
| 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. |
| 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. |
| 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.
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).
-
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 againstoriginsinsystem_config(seeded fromORIGINS). If it is missing,startreturns 200 with a challenge and the browser prompts normally, butfinishfails, so step-up never completes. Add the console's origin (for examplehttps://api.example.com) toORIGINSalongside the app origins.RPIDdoes not need to change, since the RP ID ignores the port. -
The console's callback must be in each provider's
redirectUris. The dashboard builds its OAuth redirect with the router basename applied, so the/consolebuild sends<origin>/console/oauth/callback, a different path (and host) from a typical web app's/oauth/callback.redirectUrisis 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/startreturns 400 before any redirect; if it is missing at the provider, the provider rejects withredirect_uri_mismatchat the consent screen. See docs/oauth.md.
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. |
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.
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.
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.
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. |
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.
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 |
[] |
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:
perIdentitybounds 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 timesperIdentity, 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.perIpbounds 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, sootp.perIp: 10refuses a phone audience at modest scale while never troubling a web one. A deployment that serves a native app should raise theperIpvalues and leaveperIdentitywhere 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 } }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.envchange. When a boot overwrites a stored value that differs, it is logged atwarn. - 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.envedits 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.
.env.example- annotated template- docs/production-operations.md - secrets inventory, key rotation
- docs/oauth.md - OAuth provider configuration
- docs/architecture.md - runtime shape and token model