Peke ki te ihirangi

Environment variables

Kāore anō tēnei ihirangi kia wātea i tō reo.

This reference reflects Onetime Secret v0.26.19 (e98a88c9).

Onetime Secret - Full Environment Variable Reference This file documents every supported environment variable. For quick-start configuration, see .env.example instead. Variables marked: * [derived] are generated by `rake ots:init` via HKDF from SECRET. * [independent] are randomly generated by `rake ots:init` and must be backed up separately. * [federation] must be set manually and shared identically across all regions. A trailing `# Since vX.Y.Z` records the release a variable first shipped in; `# Since unreleased` means it ships in the next one. A variable with no marker predates v0.24.0. The marker is a comment about the variable, not part of its value — drop it when copying a line, since `docker run --env-file` and `docker compose --env-file` take the whole rest of the line as the value rather than stripping a trailing comment.

Secrets & Cryptography

Example lineDefaultSince
SECRET=not declarednot annotated
Description of SECRET

Root secret. All derived secrets use HKDF (RFC 5869) with this as input keying material. The only value needed for disaster recovery. Treat it like the database itself: back it up in a secret manager and never regenerate it casually — see docs/runbooks/secret-rotation.md for what depends on it and how to rotate it safely.

#SECRET_VERIFIER_MODE=warnwarnv0.26.0
Description of SECRET_VERIFIER_MODE

Boot-time SECRET verifier policy (site.secret_verifier_mode). The app stores an HKDF-derived verifier in the datastore and checks it on boot so a changed SECRET is detected instead of silently making existing secrets undecryptable. warn (default) | enforce | off.

#SECRET_PREVIOUS=not declaredv0.26.0
Description of SECRET_PREVIOUS

Previous SECRET value(s), decrypt-only, for key rotation (comma-separated, oldest first — append the outgoing SECRET on each rotation). Old ciphertexts keep decrypting while new writes use the current SECRET. Keep each entry for at least the longest secret TTL plus receipt TTL, then drop it. See docs/runbooks/secret-rotation.md.

#SESSION_SECRET=not declaredv0.24.0
Description of SESSION_SECRET

[derived] Rack session signing key

#SESSION_ABSOLUTE_TIMEOUT=25920002592000 (30 days). Set to 0 to disable the bound (not recommended).v0.26.14
Description of SESSION_ABSOLUTE_TIMEOUT

Absolute session lifetime in seconds since sign-in, however active the session is (site.session.absolute_timeout). site.session.expire_after is the idle clock (24h rolling); this is the bound activity cannot slide. Applies in both auth modes: to the session blob (from authenticated_at) and, in full mode, to the active-session row (created_at), where it is also Rodauth's session_lifetime_deadline. Remembered sessions end at their own 14-day deadline first. Accepts a non-negative integer only; anything else falls back to the default. Default: 2592000 (30 days). Set to 0 to disable the bound (not recommended).

#IDENTIFIER_SECRET=not declaredv0.24.0
Description of IDENTIFIER_SECRET

[derived] Secret link verification key (Familia::VerifiableIdentifier). Used to generate secret/receipt keys. Derived from SECRET via HKDF.

#AUTH_SECRET=not declaredv0.24.0
Description of AUTH_SECRET

[independent] HMAC key for Rodauth auth operations (TOTP, login tokens). Cannot be recovered from SECRET. Back this up.

#ARGON2_SECRET=not declaredv0.24.0
Description of ARGON2_SECRET

[independent] Argon2 pepper for password hashing (full auth mode). Folded into every password hash via Rodauth's argon2_secret. Cannot be recovered from SECRET. WARNING: Changing this invalidates ALL existing password hashes. Back this up. See: apps/web/auth/config/features/argon2.rb

#FEDERATION_SECRET=not declaredv0.24.0
Description of FEDERATION_SECRET

[federation] Cross-region subscription federation. Must be identical across all regions in a multi-region deploy. Not auto-generated — set manually and share across instances.

#ACCOUNT_ID_SECRET=not declaredv0.26.0
Description of ACCOUNT_ID_SECRET

[independent] Obfuscates numeric account IDs in email-verification links and remember-me cookies. Also keys the pseudonymous Sentry diagnostics references (Onetime::Utils::DiagnosticsRef): the actor reference is derived from the customer's external identifier and the organization reference from the organization objid, both HMAC'd under this secret and truncated. Sentry receives only the opaque reference, never the identifier it was derived from. Those references support issue correlation and affected-account counts within this installation; they are not for product usage analytics or behavioral profiling, and a keyed pseudonym is still potentially personal data. Because both pre-images are minted per installation, separately provisioned installations do not correlate — do not copy this secret between installations. Rotating it re-keys every reference, so correlation with already-reported events breaks; under Sentry's retention window that discontinuity ages out on its own. With no secret set, no references are emitted at all.

#AUTH_OLD_SECRET=not declaredv0.24.0
Description of AUTH_OLD_SECRET

Previous AUTH_SECRET value, set only while rotating AUTH_SECRET (Rodauth hmac_old_secret). Tokens HMAC'd under the outgoing secret keep validating during the rotation window; unset when done. Full auth mode (AUTHENTICATION_MODE=full) only. No default.

#RODAUTH_HMAC_SECRET=not declaredv0.26.0
Description of RODAUTH_HMAC_SECRET

[deprecated] Not consumed by this app — appears only in the vendored Rodauth reference doc. The Rodauth HMAC secret is read from AUTH_SECRET instead (hmac_secret_env_key 'AUTH_SECRET' in apps/web/auth/config/base.rb). Set AUTH_SECRET; this var is ignored.

#VERIFIABLE_ID_HMAC_SECRET=not declaredv0.24.0
Description of VERIFIABLE_ID_HMAC_SECRET

HMAC key the familia gem reads to sign secret/receipt identifiers (Familia::VerifiableIdentifier). When unset, boot fills it from IDENTIFIER_SECRET, else HKDF-derives it from SECRET (purpose :identifier). A set-but-blank value is kept and rejected by familia. Set only to override; normally manage IDENTIFIER_SECRET instead.

Connections

Example lineDefaultSince
REDIS_URL='redis://maindb:5212/0?timeout=5'not declarednot annotated
VALKEY_URL=not declarednot annotated
#VALKEY_REQUIRE_TLS=falsenot declaredunreleased
Description of VALKEY_REQUIRE_TLS

Refuse to boot unless the datastore URI uses TLS (redis.require_tls): rediss:// or valkeys://, with no `ssl` query parameter, whatever the host. Either variable set to the exact string 'true' enables it. Off by default so existing deployments keep booting; while off, a plaintext URI is reported once at boot: a WARNING for a dotted hostname or public IP, an INFO line for a single-label name (a compose service such as `maindb`) or a private, CGNAT or link-local IP, nothing for loopback or a unix socket. Hostnames are never resolved. Turn it on once the datastore serves TLS.

#REDIS_REQUIRE_TLS=falsenot declaredunreleased
AUTH_DATABASE_URL=postgresql://onetime_user:CHANGEME@authdb/onetime_authnot declaredv0.24.0
AUTH_DATABASE_URL_MIGRATIONS=postgresql://onetime_migrator:CHANGEME@authdb/onetime_authnot declaredv0.24.0
VALKEY_PASSWORD=not declaredv0.26.0
Description of VALKEY_PASSWORD

Password for the bundled Valkey/Redis datastore. REQUIRED by both docker/compose/docker-compose.simple.yml and docker-compose.full.yml, which set `valkey-server --requirepass` and refuse to start unless this is set. The compose file threads the SAME value into the connection URL, so also embed it in REDIS_URL/VALKEY_URL for non-compose deployments (redis://:PASSWORD@host:6379/0). Use a URL-safe value (hex/alphanumeric — no @ : / # so it needs no URL-encoding); generate one with: echo "VALKEY_PASSWORD=$(openssl rand -hex 32)" >> .env

RABBITMQ_USER=not declaredv0.26.0
Description of RABBITMQ_USER

RabbitMQ (job system). Broker credentials are REQUIRED by the bundled docker/compose/docker-compose.full.yml — BOTH must be set or the stack fails fast at `docker compose up`, before any container starts, with a clear "RABBITMQ_USER must be set" / "RABBITMQ_PASS must be set" message. This is deliberate and applies to the FULL STACK REGARDLESS of JOBS_ENABLED: the broker, worker-email, and scheduler always run (they sit idle when jobs are off — see docker/README.md "Background Jobs"), so their AMQP identity must exist even in the default (jobs-off) configuration. The full stack is a production deployment; this mirrors the SECRET fail-fast guard. These two values seed the broker's default user AND are woven into the app/worker/scheduler AMQP URLs by the compose file, so you do NOT set RABBITMQ_URL yourself for the full stack (see the note below). Never ship the historic guest/guest pair: RabbitMQ's 'guest' account is a well-known default and (on newer RabbitMQ) is refused over non-loopback.

RABBITMQ_PASS=not declaredv0.26.0
RABBITMQ_URL=amqp://CHANGEME:CHANGEME@mqhost:5672/devnot declaredv0.24.0
Description of RABBITMQ_URL

Full AMQP URL read directly by the app when it publishes jobs (JOBS_ENABLED=true), via jobs.rabbitmq_url. Must carry the SAME credentials as RABBITMQ_USER / RABBITMQ_PASS above. Format: amqp://USER:PASS@host:5672/vhost NOTE: docker/compose/docker-compose.full.yml sets RABBITMQ_URL on the app, worker-email, AND scheduler services itself — deriving it from RABBITMQ_USER / RABBITMQ_PASS and pointing at the in-network broker (amqp://…@rabbitmq:5672, default vhost '/'). That compose-set value overrides this one, so leave this placeholder alone for the full stack. Set it only for non-compose or external-broker deployments.

#FAMILIA_POOL_SIZE=25not declaredv0.24.0
Description of FAMILIA_POOL_SIZE

Size of the shared Valkey/Redis connection pool used for all Familia model operations (count of connections). Default: 25.

#FAMILIA_POOL_TIMEOUT=5not declaredv0.24.0
Description of FAMILIA_POOL_TIMEOUT

Seconds to wait when checking a connection out of the Valkey/Redis connection pool before raising a timeout error. Default: 5.

#REDIS_DBS_CUSTOM_DOMAIN=0not declarednot annotated
Description of REDIS_DBS_CUSTOM_DOMAIN

Fallback alias for VALKEY_DBS_CUSTOM_DOMAIN (redis.dbs.custom_domain); the VALKEY_ variant wins when both are set. Logical database number 0-15. Default: 0.

#REDIS_DBS_CUSTOMER=0not declarednot annotated
Description of REDIS_DBS_CUSTOMER

Fallback alias for VALKEY_DBS_CUSTOMER (redis.dbs.customer); the VALKEY_ variant wins when both are set. Logical database number 0-15. Default: 0.

#REDIS_DBS_FEEDBACK=0not declarednot annotated
Description of REDIS_DBS_FEEDBACK

Fallback alias for VALKEY_DBS_FEEDBACK (redis.dbs.feedback); the VALKEY_ variant wins when both are set. Logical database number 0-15. Default: 0.

#REDIS_DBS_METADATA=0not declarednot annotated
Description of REDIS_DBS_METADATA

Fallback alias for VALKEY_DBS_METADATA (redis.dbs.metadata); the VALKEY_ variant wins when both are set. Logical database number 0-15. Default: 0.

#REDIS_DBS_SECRET=0not declarednot annotated
Description of REDIS_DBS_SECRET

Fallback alias for VALKEY_DBS_SECRET (redis.dbs.secret); the VALKEY_ variant wins when both are set. Logical database number 0-15. Default: 0.

#VALKEY_DBS_CUSTOM_DOMAIN=0not declaredv0.24.0
Description of VALKEY_DBS_CUSTOM_DOMAIN

Logical database number for custom-domain records (redis.dbs.custom_domain). 0-15, default 0. Takes precedence over REDIS_DBS_CUSTOM_DOMAIN. Note: this mapping is currently informational (shown in the boot banner); all models connect to the database in the connection URL.

#VALKEY_DBS_CUSTOMER=0not declaredv0.24.0
Description of VALKEY_DBS_CUSTOMER

Logical database number for customer records (redis.dbs.customer). 0-15, default 0. Takes precedence over REDIS_DBS_CUSTOMER. Note: this mapping is currently informational (shown in the boot banner); all models connect to the database in the connection URL.

#VALKEY_DBS_FEEDBACK=0not declaredv0.24.0
Description of VALKEY_DBS_FEEDBACK

Logical database number for feedback records (redis.dbs.feedback). 0-15, default 0. Takes precedence over REDIS_DBS_FEEDBACK. Note: this mapping is currently informational (shown in the boot banner); all models connect to the database in the connection URL.

#VALKEY_DBS_METADATA=0not declaredv0.24.0
Description of VALKEY_DBS_METADATA

Logical database number for metadata (receipt) records (redis.dbs.metadata). 0-15, default 0. Takes precedence over REDIS_DBS_METADATA. Note: this mapping is currently informational (shown in the boot banner); all models connect to the database in the connection URL.

#VALKEY_DBS_SECRET=0not declaredv0.24.0
Description of VALKEY_DBS_SECRET

Logical database number for secret records (redis.dbs.secret). 0-15, default 0. Takes precedence over REDIS_DBS_SECRET. Note: this mapping is currently informational (shown in the boot banner); all models connect to the database in the connection URL.

Site Identity & Core

Example lineDefaultSince
HOST=localhost:3000not declarednot annotated
Description of HOST

Public hostname. Include port if non-standard.

SSL=truenot declarednot annotated
not declaredv0.26.11
Description of SESSION_COOKIE_SECURE

Explicit override for the session cookie's Secure flag; wins over any SSL-derived value. In production SSL=false alone no longer disables Secure (the app fails closed); set this to false ONLY for a deliberately plain-HTTP deployment (the e2e lanes use it). Unset: derived from SSL / environment.

RACK_ENV=productionnot declarednot annotated
Description of RACK_ENV

Runtime environment

NODE_ENV=productionnot declarednot annotated
STDOUT_SYNC=falsenot declarednot annotated

Secret TTL Configuration

Example lineDefaultSince
#TTL_OPTIONS=not declarednot annotated
Description of TTL_OPTIONS

Available TTL choices for users (space-separated seconds). Example: '300 3600 86400 604800 2592000' 5m 1h 1d 7d 30d

#DEFAULT_TTL=604800not declarednot annotated
Description of DEFAULT_TTL

Default TTL when not specified (seconds). Default: 604800 (7 days)

#TTL_MAX_ANONYMOUS=604800604800 (7 days). Max: 31536000 (365 days). Clamped between 1 second and 365 days; invalid or non-positive values use the default. The effective ceiling is the lower of this and the TTL_OPTIONS maximum. Self-hosted deployments may raise it above 7 days; on deployments with billing enabled the free-tier plan limit applies as an additional ceiling, so anonymous callers never outrank an authenticated free-tier user.v0.26.3
Description of TTL_MAX_ANONYMOUS

Maximum TTL for secrets created without an account (seconds). Default: 604800 (7 days). Max: 31536000 (365 days). Clamped between 1 second and 365 days; invalid or non-positive values use the default. The effective ceiling is the lower of this and the TTL_OPTIONS maximum. Self-hosted deployments may raise it above 7 days; on deployments with billing enabled the free-tier plan limit applies as an additional ceiling, so anonymous callers never outrank an authenticated free-tier user. On a billing-enabled deployment this variable also sets the free-tier secret_lifetime fallback used when plan state is unavailable (default 1209600, 14 days, matching free_v1 in etc/billing.yaml), so it moves both values. Replaces PLAN_TTL_ANONYMOUS, which is still read as a deprecated alias for the anonymous ceiling when TTL_MAX_ANONYMOUS is unset, but no longer moves the free-tier fallback. Rename it. @see https://github.com/onetimesecret/onetimesecret/issues/2390

Secret Options

Example lineDefaultSince
#SECRET_CREATE_RATE_LIMIT_ENABLED=truenot declaredv0.26.11
Description of SECRET_CREATE_RATE_LIMIT_ENABLED

Rate limit on ANONYMOUS secret creation (site.secret_options. create_rate_limit), counted per masked client IP (/24 IPv4, /48 IPv6) across every creation entry point (V1/V2/V3 conceal and generate). Authenticated callers are never charged. Enabled by default; set ENABLED=false to opt out. The cap is deliberately loose — the bucket is a whole /24 (office or campus NAT), and behind an unconfigured reverse proxy it is the entire deployment — so raise MAX_PER_IP for dense-NAT populations rather than tightening it. See lib/onetime/security/conceal_secret_rate_limiter.rb.

#SECRET_CREATE_RATE_LIMIT_MAX_PER_IP=500not declaredv0.26.11
Description of SECRET_CREATE_RATE_LIMIT_MAX_PER_IP

Anonymous creations permitted per window from one masked IP. Default: 500.

#SECRET_CREATE_RATE_LIMIT_WINDOW=3600not declaredv0.26.11
Description of SECRET_CREATE_RATE_LIMIT_WINDOW

Counting window in seconds. Default: 3600 (1 hour).

#SECRET_CREATE_RATE_LIMIT_LOCKOUT=3600not declaredv0.26.11
Description of SECRET_CREATE_RATE_LIMIT_LOCKOUT

Lockout duration in seconds once the cap is hit. Default: 3600 (1 hour).

#PASSPHRASE_ENFORCE_COMPLEXITY=falsenot declarednot annotated
Description of PASSPHRASE_ENFORCE_COMPLEXITY

Require complexity in secret passphrases (site.secret_options.passphrase.enforce_complexity). When true, the V2 API rejects passphrases missing an uppercase letter, lowercase letter, number, or symbol. Ignored by the V1 API (preserves v0.23.4 behavior). Default: false; set to 'true' to enable.

#PASSPHRASE_MAX_LENGTH=128not declarednot annotated
Description of PASSPHRASE_MAX_LENGTH

Maximum passphrase length in characters (site.secret_options.passphrase.maximum_length). Secret creation rejects longer passphrases on both V1 and V2 APIs. Default: 128.

#PASSPHRASE_MIN_LENGTH=4not declarednot annotated
Description of PASSPHRASE_MIN_LENGTH

Minimum passphrase length in characters (site.secret_options.passphrase.minimum_length). Enforced on both V1 and V2 APIs when a passphrase is provided. Default: 4.

#PASSPHRASE_REQUIRED=falsefalse; set to 'true' to enable.not annotated
Description of PASSPHRASE_REQUIRED

Require a passphrase on every secret (site.secret_options.passphrase.required). When true, creating a secret without a passphrase fails on both V1 and V2 APIs. Default: false; set to 'true' to enable.

#PASSWORD_GEN_EXCLUDE_AMBIGUOUS=truenot declarednot annotated
Description of PASSWORD_GEN_EXCLUDE_AMBIGUOUS

Exclude visually ambiguous characters (0, O, o, 1, l, I, i) from generated passwords (site.secret_options.password_generation .character_sets.exclude_ambiguous). Generate requests may override character_sets per call. Default: true; set to 'false' to allow ambiguous characters.

#PASSWORD_GEN_LENGTH=12not declarednot annotated
Description of PASSWORD_GEN_LENGTH

Default length in characters for generated passwords (site.secret_options.password_generation.default_length). Used when a generate request does not specify its own length. Default: 12.

#PASSWORD_GEN_LOWERCASE=truenot declarednot annotated
Description of PASSWORD_GEN_LOWERCASE

Include lowercase letters (a-z) in generated passwords (site.secret_options.password_generation.character_sets.lowercase). When multiple sets are enabled, at least one character from each is guaranteed. Default: true; set to 'false' to exclude.

#PASSWORD_GEN_MAX_LENGTH=128not declaredv0.26.0
Description of PASSWORD_GEN_MAX_LENGTH

Server-enforced ceiling on the requested length of a generated password (site.secret_options.password_generation.maximum_length). Oversized `length` values are rejected before allocation, closing a memory-exhaustion DoS on the anonymous generate endpoints. Matches the frontend Zod max so the client and server limits stay in lockstep. Default: 128.

#PASSWORD_GEN_NUMBERS=truenot declarednot annotated
Description of PASSWORD_GEN_NUMBERS

Include numbers (0-9) in generated passwords (site.secret_options.password_generation.character_sets.numbers). When multiple sets are enabled, at least one character from each is guaranteed. Default: true; set to 'false' to exclude.

#PASSWORD_GEN_SYMBOLS=truenot declarednot annotated
Description of PASSWORD_GEN_SYMBOLS

Include symbols (!@#$%^&* etc.) in generated passwords (site.secret_options.password_generation.character_sets.symbols). When multiple sets are enabled, at least one character from each is guaranteed. Default: true; set to 'false' to exclude.

#PASSWORD_GEN_UPPERCASE=truenot declarednot annotated
Description of PASSWORD_GEN_UPPERCASE

Include uppercase letters (A-Z) in generated passwords (site.secret_options.password_generation.character_sets.uppercase). When multiple sets are enabled, at least one character from each is guaranteed. Default: true; set to 'false' to exclude.

#SECRET_MAX_LENGTH=10000not declaredv0.26.0
Description of SECRET_MAX_LENGTH

Maximum secret content size in characters (site.secret_options.content.maximum_length). Server-enforced ceiling on the secret body for V2 and incoming secrets; also the single source of truth for the client-side textarea limit. The V1 API uses a fixed 10000-char limit instead. Default: 10000.

#GENERATED_VALUE_DISPLAY_TTL=6060.v0.24.0
Description of GENERATED_VALUE_DISPLAY_TTL

Window in seconds after creation during which a generated password's value may be revealed on the creator's receipt page (site.secret_options.generated_value_display_ttl). Applies only to generated secrets; the reveal is claimed atomically so it happens at most once. Set to 0 to disable receipt-page display. Default: 60.

Email

Example lineDefaultSince
EMAILER_MODE=smtpnot declarednot annotated
SMTP_HOST=not declarednot annotated
SMTP_PORT=587not declarednot annotated
SMTP_USERNAME=not declarednot annotated
SMTP_PASSWORD=not declarednot annotated
SMTP_AUTH=loginnot declarednot annotated
SMTP_TLS=truenot declarednot annotated
#SMTP_SSL=falsenot declaredv0.26.15
Description of SMTP_SSL

Implicit TLS (SMTPS): the connection is encrypted from the start, typically on port 465. When enabled it takes precedence over SMTP_TLS and STARTTLS is not attempted. Default: false; set to 'true' to enable.

FROM_EMAIL=secure@onetimesecret.comnot declarednot annotated
FROM_NAME=secure@onetimesecret.comnot declarednot annotated
EMAILER_REGION=not declarednot annotated
FEEDBACK_TO_EMAIL=not declaredv0.25.2
VERIFIER_EMAIL=secure@onetimesecret.comnot declarednot annotated
VERIFIER_DOMAIN=onetimesecret.comnot declarednot annotated
not declaredv0.25.6
#AWS_ACCESS_KEY_ID=not declaredv0.24.0
Description of AWS_ACCESS_KEY_ID

AWS access key for SES. Fallback credential for SES transactional delivery when emailer.user (SMTP_USERNAME) is unset, and for SES sender-domain provisioning when CUSTOM_MAIL_SES_ACCESS_KEY_ID is unset. No default.

#AWS_REGION=us-east-1not declaredv0.24.0
Description of AWS_REGION

AWS region for SES API clients: transactional delivery and the suppression-list feedback sync. Fallback when the config region is unset — emailer.region (EMAILER_REGION) for delivery, email_providers.ses.region (CUSTOM_MAIL_SES_REGION) for the feedback client. Default: us-east-1.

#AWS_SECRET_ACCESS_KEY=not declaredv0.24.0
Description of AWS_SECRET_ACCESS_KEY

AWS secret key paired with AWS_ACCESS_KEY_ID. Fallback for SES transactional delivery when emailer.pass (SMTP_PASSWORD) is unset, and for SES sender-domain provisioning when CUSTOM_MAIL_SES_SECRET_ACCESS_KEY is unset. No default.

#EMAIL_DELIVERY_MODE=not declaredv0.24.0
Description of EMAIL_DELIVERY_MODE

[deprecated] No effect. Formerly read by the legacy auth mailer (removed); auth emails are delivered by the unified Onetime::Mail system. To log emails instead of sending, use EMAILER_MODE=logger.

#EMAIL_SUBJECT_PREFIX=not declaredv0.24.0
Description of EMAIL_SUBJECT_PREFIX

[deprecated] No effect. Formerly read by the legacy auth mailer (removed). The live auth config hardcodes Rodauth's email_subject_prefix to '' because templates carry their own subject prefixes. No replacement.

#FROM=not declarednot annotated
Description of FROM

[deprecated] Use FROM_EMAIL instead. Legacy fallback for the sender address (emailer.from); read only when FROM_EMAIL is unset. When both are unset the placeholder CHANGEME@example.com is used.

#REPLYTO_EMAIL=not declaredv0.24.0
Description of REPLYTO_EMAIL

Reply-To address for outbound email (emailer.reply_to). Falls back to FROM_EMAIL when unset. Note: the current delivery path resolves reply-to from template data or a domain's custom sender config, so this config value has no runtime reader today.

#SENDGRID_API_KEY=not declaredv0.24.0
Description of SENDGRID_API_KEY

SendGrid API key for transactional email delivery, used when the provider resolves to sendgrid (EMAILER_MODE=sendgrid, or auto-detected from an emailer.sendgrid_api_key config value). Resolution order: emailer.sendgrid_api_key, then emailer.pass (SMTP_PASSWORD), then this variable. No default.

#SMTP_DOMAIN=not declaredv0.24.0
Description of SMTP_DOMAIN

HELO domain for SMTP delivery (emailer.domain). Passed to the Mail gem's :domain setting when non-empty; identifies this server to the receiving SMTP server. Optional; unset by default (the mail library then uses its own HELO default).

Authentication

Example lineDefaultSince
AUTHENTICATION_MODE=fullnot declaredv0.24.0
Description of AUTHENTICATION_MODE

'full' enables Rodauth-based auth with PostgreSQL. Other modes may use simpler auth flows.

AUTH_ENABLED=truenot declarednot annotated
AUTH_SIGNUP=truenot declarednot annotated
AUTH_SIGNIN=truenot declarednot annotated
AUTH_AUTOVERIFY=falsefalsenot annotated
Description of AUTH_AUTOVERIFY

false (default): new accounts must verify their email address before signing in — requires a working SMTP/email provider config. true: accounts are usable immediately after signup; the right choice for private/team instances or installs without an email provider.

#AUTH_REQUIRED=falsenot declarednot annotated
Description of AUTH_REQUIRED

When true, the homepage secret form requires a signed-in user (site navigation and branding remain visible). Default: false.

AUTH_EMAIL_AUTH_ENABLED=truenot declaredv0.24.0
Description of AUTH_EMAIL_AUTH_ENABLED

Full-mode features

AUTH_LOCKOUT_ENABLED=truenot declaredv0.24.0
AUTH_MFA_ENABLED=falsenot declaredv0.24.0
AUTH_WEBAUTHN_ENABLED=falsenot declaredv0.24.0
AUTH_PASSWORD_REQUIREMENTS_ENABLED=truenot declaredv0.24.0
AUTH_ACTIVE_SESSIONS_ENABLED=truenot declaredv0.24.0
AUTH_VERIFY_ACCOUNT_ENABLED=truenot declaredv0.24.0
#AUTH_PASSWORD_ONLY=falsenot declaredv0.25.0
Description of AUTH_PASSWORD_ONLY

Restrict sign-in to a single authentication method. This is an access control, not a display preference: every other method's routes 404 on the restricted host, not just hide. Set at most ONE of these to 'true'. The result maps to full.restrict_to in auth config (values: password, email_auth, webauthn, sso). Setting more than one is a FATAL BOOT ERROR naming every flag set — there is no "restricted to two methods". The named method must actually be available (feature enabled and credentials present); if it is not, that is also a FATAL BOOT ERROR. Post-boot or per-host unavailability degrades to "sign-in unavailable" — never back to showing all methods. If none are set, all enabled methods are shown (default).

#AUTH_EMAIL_AUTH_ONLY=falsenot declaredv0.25.0
#AUTH_WEBAUTHN_ONLY=falsenot declaredv0.25.0
#AUTH_SSO_ONLY=falsenot declaredv0.24.5
#ALLOWED_SIGNUP_DOMAIN=not declarednot annotated
Description of ALLOWED_SIGNUP_DOMAIN

Restrict account creation to specific email domains, comma-separated (site.authentication.allowed_signup_domains), e.g. "company.com, partner.org". Applies to password AND SSO signups; a custom domain's own SignupConfig takes precedence when present. Empty/unset (default): signups allowed from any domain.

#RESET_REQUEST_RATE_LIMIT_ENABLED=truenot declaredv0.26.3
Description of RESET_REQUEST_RATE_LIMIT_ENABLED

Rate limiting for reset-password requests (#3872; site.authentication.reset_request_rate_limit). Throttles POST /auth/reset-password-request per client IP and per submitted login BEFORE any account lookup, bounding the sampling throughput of the accepted timing residual from #3857. Applies in BOTH auth modes: full mode enforces it from the Rodauth before_reset_password_request_route hook, simple mode (the default) from the shared reset-request logic. Enabled by default (protective); set to the exact string 'false' to opt out — other falsey-looking values ('0', 'no', 'off') leave it ENABLED, the same convention as the other *_ENABLED != 'false' knobs in etc/defaults/config.defaults.yaml. See lib/onetime/security/reset_request_rate_limiter.rb.

#RESET_REQUEST_RATE_LIMIT_MAX_PER_IP=10not declaredv0.26.3
Description of RESET_REQUEST_RATE_LIMIT_MAX_PER_IP

Requests permitted per window from a single client IP before the per-IP tier locks out (site.authentication.reset_request_rate_limit.max_per_ip). Default: 10.

#RESET_REQUEST_RATE_LIMIT_MAX_PER_EMAIL=3030.v0.26.3
Description of RESET_REQUEST_RATE_LIMIT_MAX_PER_EMAIL

Higher backstop cap per submitted login, catching IP-rotating callers (site.authentication.reset_request_rate_limit.max_per_email). Default: 30.

#RESET_REQUEST_RATE_LIMIT_WINDOW=36003600 (1 hour).v0.26.3
Description of RESET_REQUEST_RATE_LIMIT_WINDOW

Counting window in seconds for both rate-limit tiers (site.authentication.reset_request_rate_limit.window). Default: 3600 (1 hour).

#RESET_REQUEST_RATE_LIMIT_LOCKOUT=36003600 (1 hour).v0.26.3
Description of RESET_REQUEST_RATE_LIMIT_LOCKOUT

Lockout duration in seconds once a tier's cap is hit (site.authentication.reset_request_rate_limit.lockout). Default: 3600 (1 hour).

#EMAIL_LOGIN_REQUEST_RATE_LIMIT_ENABLED=truenot declaredunreleased
Description of EMAIL_LOGIN_REQUEST_RATE_LIMIT_ENABLED

Rate limiting for magic-link requests (site.authentication.email_login_request_rate_limit). Throttles POST /auth/email-login-request per client IP and per submitted login BEFORE any account lookup, so one source cannot mail sign-in links to address after address or sample the route for account existence. Full mode with AUTH_EMAIL_AUTH_ENABLED=true only; the route does not exist otherwise. Same two tiers as the reset-request limiter above, under separate keys. Enabled by default (protective); set to the exact string 'false' to opt out — other falsey-looking values leave it ENABLED. See lib/onetime/security/reset_request_rate_limiter.rb.

#EMAIL_LOGIN_REQUEST_RATE_LIMIT_MAX_PER_IP=3030 — looser than the reset-request cap because a magic link is a sign-in method and a whole masked /24 shares the bucket. The same TRUSTED_PROXY_ENABLED caveat as RESET_REQUEST_RATE_LIMIT_MAX_PER_IP applies. To clear a stuck lockout: bin/ots ratelimit keys email_login_request_ip <masked-ip> | grep -v '^#' | valkey-cli or POST /api/colonel/ratelimit/reset with kind=email_login_request_ip.unreleased
Description of EMAIL_LOGIN_REQUEST_RATE_LIMIT_MAX_PER_IP

Requests permitted per window from a single client IP before the per-IP tier locks out (site.authentication.email_login_request_rate_limit.max_per_ip). Default: 30 — looser than the reset-request cap because a magic link is a sign-in method and a whole masked /24 shares the bucket. The same TRUSTED_PROXY_ENABLED caveat as RESET_REQUEST_RATE_LIMIT_MAX_PER_IP applies. To clear a stuck lockout: bin/ots ratelimit keys email_login_request_ip <masked-ip> | grep -v '^#' | valkey-cli or POST /api/colonel/ratelimit/reset with kind=email_login_request_ip.

#EMAIL_LOGIN_REQUEST_RATE_LIMIT_MAX_PER_EMAIL=9090. Keep it at least 2 x EMAIL_LOGIN_REQUEST_RATE_LIMIT_MAX_PER_IP, and EMAIL_LOGIN_REQUEST_RATE_LIMIT_LOCKOUT at least EMAIL_LOGIN_REQUEST_RATE_LIMIT_WINDOW: a single IP timing its requests around its own window boundary lands up to 2 x max_per_ip - 2 requests in one login's window, and with a shorter lockout it is free again inside that window, so one source could lock a victim out of magic-link sign-in from every IP. The defaults satisfy both. Rodauth's resend window already caps the mail an account receives.unreleased
Description of EMAIL_LOGIN_REQUEST_RATE_LIMIT_MAX_PER_EMAIL

Backstop cap per submitted login, catching IP-rotating callers (site.authentication.email_login_request_rate_limit.max_per_email). Default: 90. Keep it at least 2 x EMAIL_LOGIN_REQUEST_RATE_LIMIT_MAX_PER_IP, and EMAIL_LOGIN_REQUEST_RATE_LIMIT_LOCKOUT at least EMAIL_LOGIN_REQUEST_RATE_LIMIT_WINDOW: a single IP timing its requests around its own window boundary lands up to 2 x max_per_ip - 2 requests in one login's window, and with a shorter lockout it is free again inside that window, so one source could lock a victim out of magic-link sign-in from every IP. The defaults satisfy both. Rodauth's resend window already caps the mail an account receives.

#EMAIL_LOGIN_REQUEST_RATE_LIMIT_WINDOW=36003600 (1 hour).unreleased
Description of EMAIL_LOGIN_REQUEST_RATE_LIMIT_WINDOW

Counting window in seconds for both tiers (site.authentication.email_login_request_rate_limit.window). Default: 3600 (1 hour).

#EMAIL_LOGIN_REQUEST_RATE_LIMIT_LOCKOUT=36003600 (1 hour). Keep it >= EMAIL_LOGIN_REQUEST_RATE_LIMIT_WINDOW (see EMAIL_LOGIN_REQUEST_RATE_LIMIT_MAX_PER_EMAIL).unreleased
Description of EMAIL_LOGIN_REQUEST_RATE_LIMIT_LOCKOUT

Lockout duration in seconds once a tier's cap is hit (site.authentication.email_login_request_rate_limit.lockout). Default: 3600 (1 hour). Keep it >= EMAIL_LOGIN_REQUEST_RATE_LIMIT_WINDOW (see EMAIL_LOGIN_REQUEST_RATE_LIMIT_MAX_PER_EMAIL).

#CREATE_ACCOUNT_RATE_LIMIT_ENABLED=truenot declaredv0.26.3
Description of CREATE_ACCOUNT_RATE_LIMIT_ENABLED

Rate limiting for unauthenticated account creation (site.authentication.create_account_rate_limit). POST /auth/create-account previously had no limiter in EITHER auth mode, leaving unthrottled account creation: one customer record (which carries no TTL) plus one welcome email per distinct address, with subaddressing folding many addresses onto one mailbox. This setting applies to both modes — full mode enforces it from the Rodauth before_create_account_route hook, simple mode from the shared CreateAccount logic class — so the knob means the same thing whichever mode a deployment runs. Enabled by default (protective); set to the exact string 'false' to opt out — other falsey-looking values ('0', 'no', 'off') leave it ENABLED, the same convention as the other *_ENABLED != 'false' knobs. See lib/onetime/security/create_account_rate_limiter.rb.

#CREATE_ACCOUNT_RATE_LIMIT_MAX_PER_IP=1010.v0.26.3
Description of CREATE_ACCOUNT_RATE_LIMIT_MAX_PER_IP

Signups permitted per window from a single client IP before the limiter locks out (site.authentication.create_account_rate_limit.max_per_ip). Default: 10.

#CREATE_ACCOUNT_RATE_LIMIT_WINDOW=36003600 (1 hour).v0.26.3
Description of CREATE_ACCOUNT_RATE_LIMIT_WINDOW

Counting window in seconds (site.authentication.create_account_rate_limit.window). Default: 3600 (1 hour).

#CREATE_ACCOUNT_RATE_LIMIT_LOCKOUT=36003600 (1 hour).v0.26.3
Description of CREATE_ACCOUNT_RATE_LIMIT_LOCKOUT

Lockout duration in seconds once the cap is hit (site.authentication.create_account_rate_limit.lockout). Default: 3600 (1 hour).

#AUTH_REMEMBER_ME_ENABLED=truetrue.v0.24.0
Description of AUTH_REMEMBER_ME_ENABLED

Remember-me: the sign-in form's "Remember me" checkbox (auth: full.features.remember_me) makes THAT session last a fixed 14 days from sign-in instead of the rolling 24 hours; activity does not extend it. Both auth modes. Set to 'false' to ignore the checkbox; sessions already remembered return to the default lifetime (their 14-day deadline still ends them). SESSION_ABSOLUTE_TIMEOUT still caps every session. Default: true.

#AUTH_SERVICE_URL=not declaredv0.24.0
Description of AUTH_SERVICE_URL

Reserved: auth service base URL for internal requests (auth: full.service_url). Currently inert — the config line is commented out in etc/defaults/auth.defaults.yaml (example default http://127.0.0.1:3000/auth) and the external-auth token validator that would consume a service_url is an unimplemented placeholder.

#AUTH_WEBAUTHN_AUTOFILL=falsenot declaredv0.26.5
Description of AUTH_WEBAUTHN_AUTOFILL

Enables Rodauth's webauthn_autofill feature: passkey autofill (browser conditional UI) on the login form (auth: full.features.webauthn_autofill). Default: false. Only the literal string 'true' enables — any other value (including '1', 'yes', or mere presence) leaves it off. Requires AUTH_WEBAUTHN_ENABLED=true and AUTHENTICATION_MODE=full.

#AUTH_WEBAUTHN_VERIFY_ACCOUNT=falsefalse. Only the literal string 'true' enables — any other value (including '1', 'yes', or mere presence) leaves it off. Requires AUTH_WEBAUTHN_ENABLED=true and AUTHENTICATION_MODE=full; leave AUTH_VERIFY_ACCOUNT_ENABLED on.v0.26.5
Description of AUTH_WEBAUTHN_VERIFY_ACCOUNT

Enables Rodauth's webauthn_verify_account feature: account email verification sets up a WebAuthn credential instead of a password (passwordless signup) (auth: full.features.webauthn_verify_account). Default: false. Only the literal string 'true' enables — any other value (including '1', 'yes', or mere presence) leaves it off. Requires AUTH_WEBAUTHN_ENABLED=true and AUTHENTICATION_MODE=full; leave AUTH_VERIFY_ACCOUNT_ENABLED on.

Organizations

Example lineDefaultSince
ENABLE_ORGS=falsenot declaredv0.25.0
Description of ENABLE_ORGS

Toggle on to show the Organizations context switcher in the UI, next to the equivalent Domain context switcher. This allows users to update their default organization settings and add additional organizations to their account. When billing is enabled, the subscription plans are per-org.

ORGS_SSO_ENABLED=falsenot declaredv0.25.0
Description of ORGS_SSO_ENABLED

When enabled, allows organizations with the manage_sso entitlement to configure SSO for each of their custom domains.

ORGS_CUSTOM_MAIL_ENABLED=falsenot declaredv0.25.0
Description of ORGS_CUSTOM_MAIL_ENABLED

When enabled, allows organizations with the custom_mail_sender entitlement to configure custom sender identity (from name, from address) for their domains. Uses installation-level email provider credentials.

ORGS_INCOMING_SECRETS_ENABLED=falsenot declaredv0.25.0
Description of ORGS_INCOMING_SECRETS_ENABLED

When enabled, allows organizations with the incoming_secrets entitlement to configure incoming secret receiving for their custom domains. Secrets sent to the domain are routed to the organization's inbox.

ORGS_HOMEPAGE_NETWORK_RULES_UI_ENABLED=falsenot declaredunreleased
Description of ORGS_HOMEPAGE_NETWORK_RULES_UI_ENABLED

When enabled, allows organizations with the homepage_network_rules entitlement to limit their custom domain homepages by client network (allowed/denied CIDRs). Despite the name, this gates the API as well as the UI: while off, PUT /api/domains/:extid/homepage-config refuses any request that changes allowed_cidrs or denied_cidrs, and DELETE refuses to drop a config that holds them. A list sent back unchanged is accepted. Turning it off stops edits only: stored rules stay enforced.

ORGS_BASE_DOMAIN_SIGNUP_ENABLED=falsenot declaredunreleased
Description of ORGS_BASE_DOMAIN_SIGNUP_ENABLED

When enabled, allows organizations with the base_domain_signup entitlement to admit their custom domain's own base domain to its sign-up allowlist automatically (secrets.acme.com admits @acme.com). Checked at sign-up too, so turning it off stops a saved setting from admitting anyone.

ORGS_AUDIT_LOGS_ENABLED=truenot declaredv0.26.4
Description of ORGS_AUDIT_LOGS_ENABLED

When enabled (the default), organizations with the audit_logs entitlement can view their secret activity trail (UI + list API). Unlike the flags above, this defaults ON because the trail works out of the box — set to false to exclude the feature. Event collection is not affected.

SECRET_ACTIVITY_COLLECT=truenot declaredv0.26.4
Description of SECRET_ACTIVITY_COLLECT

Whether organization secret-activity events are recorded at all (GDPR data minimization). Enabled by default; set to false to pause collection. Pausing stops new events from being recorded — existing events stay readable (pair with ORGS_AUDIT_LOGS_ENABLED=false to also hide them).

SECRET_ACTIVITY_MAX_EVENTS=10000not declaredv0.26.4
Description of SECRET_ACTIVITY_MAX_EVENTS

Newest secret-activity events retained per organization (floor: 100). LOWERING this value DELETES (not hides) the oldest events past the new cap in each org's trail, applied lazily on that org's next recorded event. Setting SECRET_ACTIVITY_COLLECT=false does not trigger the trim.

Custom Mail Sender DNS Settings

These settings control DNS record generation for DKIM, SPF, and related email authentication when setting up custom mail sender domains. The provider is selected by CUSTOM_MAIL_PROVIDER (above); these settings configure that provider's DNS record values.

Example lineDefaultSince
#CUSTOM_MAIL_PROVIDER=not declaredv0.25.8
Description of CUSTOM_MAIL_PROVIDER

Provider for custom mail sender domain provisioning (DNS/DKIM). Decouples domain provisioning from the sending transport above. Valid values: ses, sendgrid, lettermint, smtp2go, smtp If unset, falls back to EMAILER_MODE.

#CUSTOM_MAIL_SES_REGION=us-east-1us-east-1v0.25.8
Description of CUSTOM_MAIL_SES_REGION

--- AWS SES --- Select SES as the sender-domain provisioning provider with CUSTOM_MAIL_PROVIDER=ses (above). See docs/architecture/custom-mail-sender-ses.md for the full domain-level setup, credentials, and IAM permissions. AWS region for SES sender-domain provisioning (the SESv2 API client used to create/get/delete email identities). This is INDEPENDENT of EMAILER_REGION, which configures the install-level transactional mailer — so you can run, e.g., SMTP for transactional email and SES for sender provisioning. Data-residency regions: ca-central-1 (Canada), ap-southeast-2 (Sydney), eu-west-1 (Ireland). Confirm SES is available in your target region. Default: us-east-1

#CUSTOM_MAIL_SES_ACCESS_KEY_ID=AKIA...not declaredv0.25.9
Description of CUSTOM_MAIL_SES_ACCESS_KEY_ID

AWS credentials for the SES provisioning API, kept independent of the SMTP transactional mailer. If you run authenticated SMTP for delivery (EMAILER_MODE=smtp with SMTP_USERNAME/SMTP_PASSWORD), set these (or the standard AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY, which they fall back to) so the SMTP login is NOT used as the AWS key for SES. Leave unset only when SES is also the delivery backend (EMAILER_MODE=ses) and the emailer already carries the AWS keys.

#CUSTOM_MAIL_SES_SECRET_ACCESS_KEY=...not declaredv0.25.9
LETTERMINT_API_TOKEN=not declaredv0.24.0
Description of LETTERMINT_API_TOKEN

--- Lettermint --- Lettermint has TWO separate APIs with different auth: 1. Sending API - uses x-lettermint-token header (project token) 2. Team API - uses Authorization: Bearer header (team token) Project token for Lettermint Sending API (email delivery). Required when EMAILER_MODE=lettermint

LETTERMINT_TEAM_TOKEN=not declaredv0.25.0
Description of LETTERMINT_TEAM_TOKEN

Team token for Lettermint Team API (domain provisioning). Required for custom mail sender domain management (ORGS_CUSTOM_MAIL_ENABLED=true) https://dash.lettermint.co/team/api-tokens

#LETTERMINT_BASE_URL=https://api.lettermint.co/v1https://api.lettermint.co/v1v0.24.0
Description of LETTERMINT_BASE_URL

API base URL (for enterprise/on-premise deployments). Default: https://api.lettermint.co/v1

SMTP2GO_API_KEY=not declaredv0.26.5
Description of SMTP2GO_API_KEY

--- SMTP2GO --- SMTP2GO (Christchurch, NZ) backs the NZ region. A single API key covers API-based sending and sender-domain verification, authenticated via the X-Smtp2go-Api-Key header. API key from Sending > API Keys in the SMTP2GO dashboard. Format: `api-` followed by 32 characters. Required when EMAILER_MODE=smtp2go or CUSTOM_MAIL_PROVIDER=smtp2go

#SMTP2GO_BASE_URL=https://api.smtp2go.com/v3https://api.smtp2go.com/v3v0.26.5
Description of SMTP2GO_BASE_URL

API base URL override; rarely needed. Default: https://api.smtp2go.com/v3

#CUSTOM_MAIL_SMTP2GO_RETURNPATH_SUBDOMAIN=bouncenot declaredv0.26.5
Description of CUSTOM_MAIL_SMTP2GO_RETURNPATH_SUBDOMAIN

Return-path subdomain prefix for SMTP2GO sender-domain DNS records (email_providers.smtp2go.returnpath_subdomain). Becomes a CNAME record on the customer's sender domain (bounce.yourdomain.com) so the envelope sender aligns for SPF. Default: bounce

#CUSTOM_MAIL_SMTP2GO_TRACKING_SUBDOMAIN=tracknot declaredv0.26.5
Description of CUSTOM_MAIL_SMTP2GO_TRACKING_SUBDOMAIN

Tracking subdomain prefix for SMTP2GO sender-domain DNS records (email_providers.smtp2go.tracking_subdomain). Becomes a CNAME record on the customer's sender domain (track.yourdomain.com) for open/click tracking. Default: track

#CUSTOM_MAIL_SMTP2GO_FASTACCEPT=falsefalsev0.26.5
Description of CUSTOM_MAIL_SMTP2GO_FASTACCEPT

Accept SMTP2GO messages asynchronously for faster API responses. When enabled, synchronous rejection details are unavailable; use webhooks for delivery results. Leave disabled for sender-configuration test emails and only enable if you aren't running the background workers and you find that requests the trigger an email send are taking too long to load. Default: false

#CUSTOM_MAIL_LETTERMINT_SPF_CNAME_PREFIX=lm-bounceslm-bouncesv0.25.8
Description of CUSTOM_MAIL_LETTERMINT_SPF_CNAME_PREFIX

SPF CNAME subdomain prefix for Lettermint sender-domain DNS records (email_providers.lettermint.spf_cname_prefix). 'lm-bounces' creates lm-bounces.yourdomain.com pointing at the SPF CNAME target below. Default: lm-bounces

#CUSTOM_MAIL_LETTERMINT_SPF_CNAME_TARGET=bounces.lmta.netnot declaredv0.25.8
Description of CUSTOM_MAIL_LETTERMINT_SPF_CNAME_TARGET

SPF CNAME target domain for Lettermint sender-domain DNS records (email_providers.lettermint.spf_cname_target). Lettermint maintains the SPF record at this target. Default: bounces.lmta.net

#CUSTOM_MAIL_SENDGRID_SUBDOMAIN=emnot declaredv0.25.8
Description of CUSTOM_MAIL_SENDGRID_SUBDOMAIN

Branding subdomain prefix for SendGrid sender-domain DNS records (email_providers.sendgrid.subdomain); appears in the generated CNAME record names. Letters, digits, and hyphens only. Default: em

SSO / OmniAuth

Example lineDefaultSince
AUTH_SSO_ENABLED=falsenot declaredv0.24.0
Description of AUTH_SSO_ENABLED

Master toggle for SSO. Must be true for any provider below.

#SSO_DISPLAY_NAME=not declaredv0.24.0
Description of SSO_DISPLAY_NAME

[deprecated] Use per-provider DISPLAY_NAME vars instead.

OIDC_ISSUER=not declaredv0.24.0
Description of OIDC_ISSUER

--- Generic OIDC provider --- Required: OIDC_ISSUER, OIDC_CLIENT_ID Optional: OIDC_CLIENT_SECRET (omit for PKCE-only flows)

OIDC_CLIENT_ID=not declaredv0.24.0
OIDC_CLIENT_SECRET=not declaredv0.24.0
#OIDC_ROUTE_NAME=oidcnot declaredv0.24.0
#OIDC_DISPLAY_NAME=SSOnot declaredv0.24.3
Description of OIDC_DISPLAY_NAME

Sign-in button label for this provider. Default: SSO

#OIDC_CLIENT_AUTH_METHOD=not declaredunreleased
Description of OIDC_CLIENT_AUTH_METHOD

How the client secret is sent to the token endpoint: client_secret_basic (Authorization: Basic header; the default when unset) or client_secret_post (request body). Leave unset unless the IdP requires one method. An unknown value is logged at boot and treated as unset. Custom-domain OIDC has its own per-domain setting and never inherits this value.

#OIDC_TRUST_EMAIL_FOR_LINKING=falsenot declaredv0.26.2
Description of OIDC_TRUST_EMAIL_FOR_LINKING

Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.

ENTRA_TENANT_ID=not declaredv0.24.3
Description of ENTRA_TENANT_ID

--- Microsoft Entra ID --- Required: ENTRA_TENANT_ID, ENTRA_CLIENT_ID, ENTRA_CLIENT_SECRET

ENTRA_CLIENT_ID=not declaredv0.24.3
ENTRA_CLIENT_SECRET=not declaredv0.24.3
#ENTRA_ROUTE_NAME=entranot declaredv0.24.3
#ENTRA_DISPLAY_NAME=Microsoftnot declaredv0.24.3
#ENTRA_TRUST_EMAIL_FOR_LINKING=falsenot declaredv0.26.2
Description of ENTRA_TRUST_EMAIL_FOR_LINKING

Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.

GITHUB_CLIENT_ID=not declaredv0.24.3
Description of GITHUB_CLIENT_ID

--- GitHub --- Required: GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET

GITHUB_CLIENT_SECRET=not declaredv0.24.3
#GITHUB_ROUTE_NAME=githubnot declaredv0.24.3
#GITHUB_DISPLAY_NAME=GitHubnot declaredv0.24.3
#GITHUB_TRUST_EMAIL_FOR_LINKING=falsenot declaredv0.26.2
Description of GITHUB_TRUST_EMAIL_FOR_LINKING

Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.

GITLAB_CLIENT_ID=not declaredunreleased
Description of GITLAB_CLIENT_ID

--- GitLab --- Required: GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET gitlab.com only. For a self-managed GitLab, use the generic OIDC provider above with OIDC_ISSUER set to the instance URL.

GITLAB_CLIENT_SECRET=not declaredunreleased
#GITLAB_ROUTE_NAME=gitlabnot declaredunreleased
#GITLAB_DISPLAY_NAME=GitLabnot declaredunreleased
#GITLAB_TRUST_EMAIL_FOR_LINKING=falsenot declaredunreleased
Description of GITLAB_TRUST_EMAIL_FOR_LINKING

Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.

GOOGLE_CLIENT_ID=not declaredv0.24.3
Description of GOOGLE_CLIENT_ID

--- Google --- Required: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET

GOOGLE_CLIENT_SECRET=not declaredv0.24.3
#GOOGLE_ROUTE_NAME=googlenot declaredv0.24.3
#GOOGLE_DISPLAY_NAME=Googlenot declaredv0.24.3
#GOOGLE_TRUST_EMAIL_FOR_LINKING=falsenot declaredv0.26.2
Description of GOOGLE_TRUST_EMAIL_FOR_LINKING

Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only.

APPLE_CLIENT_ID=not declaredv0.26.13
Description of APPLE_CLIENT_ID

--- Apple (Sign in with Apple) --- Required: APPLE_CLIENT_ID, APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY APPLE_CLIENT_ID is the Services ID (e.g. com.example.web), not the App ID. There is deliberately NO APPLE_CLIENT_SECRET: the strategy mints a fresh ES256 client-secret JWT per request from the private key below.

APPLE_TEAM_ID=not declaredv0.26.13
APPLE_KEY_ID=not declaredv0.26.13
APPLE_PRIVATE_KEY=not declaredv0.26.13
Description of APPLE_PRIVATE_KEY

The CONTENTS of the .p8 EC key, not a path to it: the whole file, PEM header and footer lines included. OpenSSL must be able to parse it, so the newlines have to survive your env plumbing. The single-line form with each newline written as a literal \n is accepted — the provider definition un-escapes it before handing it to the strategy. (No sample value is shown here: a realistic one trips the detect-private-key pre-commit hook.)

#APPLE_ROUTE_NAME=applenot declaredv0.26.13
#APPLE_DISPLAY_NAME=Applenot declaredv0.26.13
#APPLE_TRUST_EMAIL_FOR_LINKING=falsenot declaredv0.26.13
Description of APPLE_TRUST_EMAIL_FOR_LINKING

Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only. Apple's email may be a private relay address (@privaterelay.appleid.com), which is not evidence of control over the underlying mailbox. Keep this false. (The email itself arrives on every authorization, from the id_token; only the user's NAME is limited to the first authorization.)

#SAML_ENABLED=falsenot declaredv0.26.14
Description of SAML_ENABLED

--- SAML 2.0 --- Install-wide switch that turns SAML on. Default false. Unless true, the app processes no SAML at all, on both surfaces: no saml route is registered (platform SAML_* settings and every tenant's saved SAML config included, so omniauth-saml/ruby-saml are not loaded), the SAML callback answers 404, the sign-in button is not shown, and the per-domain SSO API refuses to save or edit a SAML configuration (tenants can still disable or delete theirs). Saved configurations are kept, untouched, and resume when it is set back to true. This is an incident switch for a ruby-saml advisory: it stops SAML without taking OIDC and Entra tenants down (ORGS_SSO_ENABLED=false does). WARNING: custom domains restricted to SSO sign-in and using SAML have NO sign-in method while this is off. Accepts the shared boolean vocabulary (true/1/yes/on, false/0/no/off); any other value fails boot. UPGRADING: an install that already has SAML_* set for platform SAML, or tenants with saved SAML configs, must set this to true before upgrading or SAML sign-in stops. See docs/authentication/per-install-sso.md.

SAML_IDP_SSO_SERVICE_URL=not declaredv0.26.14
Description of SAML_IDP_SSO_SERVICE_URL

Required (with SAML_ENABLED=true): SAML_IDP_SSO_SERVICE_URL, SAML_IDP_ENTITY_ID, SAML_IDP_CERT There is no client id/secret: trust is the IdP's pinned signing certificate. A missing OR unusable value skips the provider with an error in the boot log (it never fails boot) and the login button is not shown. The IdP's SSO endpoint (HTTP-Redirect binding). https only. The CSP form-action origin and the cross-site POST callback allowance are derived from THIS url's origin, not from the EntityID.

SAML_IDP_ENTITY_ID=not declaredv0.26.14
Description of SAML_IDP_ENTITY_ID

The IdP's EntityID, exactly as the IdP sends it in <Issuer> — compared byte for byte (case, trailing slash and all), and stored as the issuer half of every SAML identity. Changing it later orphans existing SAML sign-ins.

SAML_IDP_CERT=not declaredv0.26.14
Description of SAML_IDP_CERT

The CONTENTS of the IdP's X.509 signing certificate in PEM form, header and footer lines included — exactly one certificate, not expired. The single-line form with each newline written as a literal \n is accepted. Certificate FINGERPRINTS are not supported, deliberately: fingerprint-only trust accepts whatever certificate the response embeds. When the IdP rotates its certificate this value must be updated (one certificate at a time).

#SAML_SP_ENTITY_ID=not declaredv0.26.14
Description of SAML_SP_ENTITY_ID

Our SP EntityID (the Audience the IdP must send). Default: https://<site.host>/auth/sso/<route name>/metadata — which is also where the SP metadata XML is served. The Assertion Consumer Service (ACS) URL to register at the IdP is https://<site.host>/auth/sso/<route name>/callback (HTTP-POST binding).

#SAML_UID_ATTRIBUTE=not declaredv0.26.14
Description of SAML_UID_ATTRIBUTE

Use this SAML attribute as the stable user id instead of the NameID. Required when the IdP can only send a TRANSIENT NameID (refused otherwise — a transient id would create a new account on every sign-in). The IdP should send a persistent NameID (requested by default).

#SAML_NAME_ID_FORMAT=urn:oasis:names:tc:SAML:2.0:nameid-format:persistentnot declaredv0.26.14
Description of SAML_NAME_ID_FORMAT

NameID format requested from the IdP and accepted in the response. Default persistent. Also accepted: the SAML 1.1 emailAddress and unspecified formats, the 2.0 transient format (needs SAML_UID_ATTRIBUTE; the provider is skipped at boot without it), or 'omit' to send no NameIDPolicy at all. Existing identities are keyed on the NameID and will not match after a format change unless SAML_UID_ATTRIBUTE supplies the key. Tenant records carry their own name_id_format. See docs/authentication/saml-policy.md.

#SAML_ALLOW_NULL_ORIGIN=falsenot declaredv0.26.14
Description of SAML_ALLOW_NULL_ORIGIN

Operator-only: admit a literal 'Origin: null' header on POSTs to a configured SAML callback route, for IdP flows that submit an opaque origin. Only the exact string true enables it; unset, false and any other spelling deny. No tenant setting can enable this. See docs/authentication/saml-policy.md.

#SAML_CALLBACK_SOURCE_LIMIT=64not declaredv0.26.14
Description of SAML_CALLBACK_SOURCE_LIMIT

Staging admission for the SAML callback transport, per fixed 120-second bucket: how many staged assertions one privacy-masked client source may take, and the global cap shared by every host (the deployment-wide storage bound). Positive integers up to 1000000; the source limit is clamped to the global limit; an invalid value logs a warning and uses the default. Raising them increases storage exposure. See docs/authentication/saml-callback-transport.md.

#SAML_CALLBACK_GLOBAL_LIMIT=256not declaredv0.26.14
#SAML_ROUTE_NAME=samlnot declaredv0.26.14
#SAML_DISPLAY_NAME=SAML SSOnot declaredv0.26.14
#SAML_TRUST_EMAIL_FOR_LINKING=falsenot declaredv0.26.14
Description of SAML_TRUST_EMAIL_FOR_LINKING

Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only. SAML carries no email_verified claim: the email attribute is whatever the IdP asserts.

#SSO_FORM_ACTION_ORIGINS=not declaredv0.26.2
Description of SSO_FORM_ACTION_ORIGINS

Extra origins for the CSP form-action directive (space-separated). IdP origins are auto-derived on two levels: each active platform provider's origin is added at boot, and a custom domain's tenant SSO issuer origin is added per-request from its per-domain SSO config (#4173) — so Chromium does not block the SSO form-POST redirect on either surface. Configure sovereign Microsoft Entra as generic OIDC with its sovereign issuer; that origin is derived automatically. Set this only when the auto-derived origin is wrong or incomplete: an OIDC issuer whose authorization_endpoint lives on a different origin than the issuer, or a per-request tenant derivation gap. The tenant widening depends on the resolved display domain: if the domain record is unreachable at request time it is skipped silently (browser-console-only symptom), and this override is the manual fallback for such gap cases. See #3848, #4173.

#SSO_PROVIDER_ORDER=not declaredv0.26.5
Description of SSO_PROVIDER_ORDER

Login-button ordering for SSO providers: a comma/space-separated list of route names (e.g. "google, github, oidc"). Listed providers appear first in the given order; unlisted ones keep their registry order after them. Unset = registry order (lib/onetime/sso_provider/registry.rb).

#SSO_ALLOW_PLATFORM_FALLBACK=falsenot declaredv0.25.0
Description of SSO_ALLOW_PLATFORM_FALLBACK

Multi-tenant SSO fallback policy (auth: full.sso.allow_platform_fallback_for_tenants). When true, custom domains without their own per-domain SSO config may piggyback on the platform's ENV-based SSO credentials — convenient for single-tenant, a security concern in multi-tenant. Default: false ('true' enables).

#SSO_TRUST_EMAIL_FOR_LINKING=falsenot declaredv0.26.2
Description of SSO_TRUST_EMAIL_FOR_LINKING

Global/deprecated fallback for the per-provider *_TRUST_EMAIL_FOR_LINKING vars above (single-OIDC case; a per-provider var, when set, takes precedence). Default false. Opt-in email-based SSO linking; single-tenant trusted-IdP only. Opting a single provider OUT of a global true requires an EXPLICIT =false: with SSO_TRUST_EMAIL_FOR_LINKING=true, a provider whose *_TRUST_EMAIL_FOR_LINKING is simply omitted INHERITS the global true. To disable one provider, set it explicitly, e.g. GITHUB_TRUST_EMAIL_FOR_LINKING=false.

#GITHUB_KEY=not declaredv0.26.0
Description of GITHUB_KEY

[deprecated] Not consumed by this app — appears only in the vendored Rodauth reference doc. Use GITHUB_CLIENT_ID: the GitHub SSO provider's OAuth client id (with GITHUB_CLIENT_SECRET).

#GITHUB_SECRET=not declaredv0.26.0
Description of GITHUB_SECRET

[deprecated] Not consumed by this app — appears only in the vendored Rodauth reference doc. Use GITHUB_CLIENT_SECRET: the GitHub SSO provider's OAuth client secret (with GITHUB_CLIENT_ID).

#GOOGLE_KEY=not declaredv0.26.0
Description of GOOGLE_KEY

[deprecated] Not consumed by this app — appears only in the vendored Rodauth reference doc. Use GOOGLE_CLIENT_ID: the Google SSO provider's OAuth client id (with GOOGLE_CLIENT_SECRET).

#GOOGLE_SECRET=not declaredv0.26.0
Description of GOOGLE_SECRET

[deprecated] Not consumed by this app — appears only in the vendored Rodauth reference doc. Use GOOGLE_CLIENT_SECRET: the Google SSO provider's OAuth client secret (with GOOGLE_CLIENT_ID).

Stripe Billing

Example lineDefaultSince
STRIPE_API_KEY=not declaredv0.24.0
PUBLIC_STRIPE_API_KEY=not declaredv0.24.1
STRIPE_WEBHOOK_SIGNING_SECRET=not declaredv0.24.0
#STRIPE_CHECKOUT_HOST=not declaredv0.26.1
Description of STRIPE_CHECKOUT_HOST

Bare host of a Stripe custom Checkout domain (live-mode only), e.g. pay.onetimesecret.com. When set, the frontend allowlists checkout URLs served from this host in addition to checkout.stripe.com. Leave unset unless a Stripe custom domain is configured. Wins over billing.yaml `checkout_host`. Validated at boot when billing is enabled — the check runs in Billing::Initializers::StripeSetup (BillingConfig#validate_checkout_host!), which is skipped when billing is disabled. A malformed value (scheme/path/userinfo/out-of-range port) fails startup, not a later checkout.

#STRIPE_AUTOMATIC_TAX=not declaredv0.26.3
Description of STRIPE_AUTOMATIC_TAX

Enable Stripe automatic tax on checkout sessions. 'true'/'1' enable, 'false'/'0' disable, unset or blank means off; any other value fails startup (Onetime::ConfigError) — an unrecognized token used to silently disable tax collection. Deployment-level policy applied by BillingConfig#automatic_tax? to all checkout sessions — not a per-link choice. Wins over billing.yaml `automatic_tax`. Requires Stripe Tax to be configured in the Dashboard (tax registrations + product tax codes) first.

#STRIPE_PAYMENT_METHOD_CONFIGURATION=not declaredv0.26.5
Description of STRIPE_PAYMENT_METHOD_CONFIGURATION

Stripe payment method configuration ID (pmc_...) to pin on checkout sessions. When set, checkout uses this specific configuration instead of the Dashboard default. Wins over billing.yaml `payment_method_configuration`. Leave unset to use the Dashboard default. A blank-but-set value is treated as explicitly unset and does NOT fall back to the billing.yaml key. A malformed value (anything not starting with pmc_) fails startup (Onetime::ConfigError) when billing is enabled, instead of failing at a customer's first checkout. Find IDs at Settings → Payments → Payment methods.

#BILLING_CURRENCY=cadnot declaredv0.24.0
Description of BILLING_CURRENCY

Billing catalog currency as a lowercase ISO 4217 code (billing.yaml `currency` key, via ERB in etc/billing.yaml copied from etc/examples/billing.example.yaml). Wins over CURRENCY when both are set; BillingConfig falls back to 'cad' when neither is set.

#BILLING_ENABLED=not declaredv0.24.0
Description of BILLING_ENABLED

Master switch for Stripe billing (billing.yaml `enabled`). When set, 'true'/'1' enable and 'false'/'0' disable, overriding the config file; blank counts as explicitly off; any other value fails startup (Onetime::ConfigError — BILLING_ENABLED=1 used to silently mean off). When unset, billing.yaml decides — the example config enables billing whenever the file exists; with no billing.yaml, billing is off.

#CURRENCY=cadnot declaredv0.25.3
Description of CURRENCY

Alias for BILLING_CURRENCY, checked second in the same ERB chain in etc/billing.yaml (from etc/examples/billing.example.yaml). Prefer BILLING_CURRENCY, which wins when both are set. Lowercase ISO 4217 code; effective default cad.

Custom Domains

Example lineDefaultSince
DOMAINS_ENABLED=falsenot declarednot annotated
DEFAULT_DOMAIN=not declarednot annotated
not declaredv0.26.5
Description of LINK_DOMAINS

Comma-separated hosts offered in the link-domain picker. For installs whose canonical host is an internal platform address that must keep serving the app but must never be offered to customers. Every entry also joins the canonical host set, so those hosts serve normally. unset -> the picker offers the canonical domain (default) set -> exactly these hosts; canonical only if listed set-but-empty -> boot error (this is why the line below is commented out rather than left bare like DEFAULT_DOMAIN above) A set list naming only hosts that cannot be parsed (e.g. an internal hostname with no public suffix) is also a boot error, and the message echoes the entries so the typo is easy to spot. Mixing a good host with a bad one boots; the bad one is dropped and logged.

DOMAINS_VALIDATION_STRATEGY=passthroughpassthroughv0.24.0
Description of DOMAINS_VALIDATION_STRATEGY

Validation strategy: passthrough (default), approximated, caddy_on_demand approximated and caddy_on_demand both require the customer to publish the domain's TXT challenge record before the domain counts as verified. Under caddy_on_demand the application performs that DNS lookup itself, so it needs a working system resolver (/etc/resolv.conf). Caddy obtaining a certificate is not treated as proof of ownership. Resolving and SSL status under caddy_on_demand come from the application's own A/AAAA lookup and a TLS handshake to the domain on port 443 (no request is sent; private addresses are never dialled), so the host also needs outbound TCP 443.

APPROXIMATED_API_KEY=not declarednot annotated
Description of APPROXIMATED_API_KEY

Approximated provider settings (when strategy=approximated)

APPROXIMATED_PROXY_IP=not declaredv0.24.0
APPROXIMATED_PROXY_HOST=not declaredv0.24.0
APPROXIMATED_PROXY_NAME=not declaredv0.24.0
APPROXIMATED_VHOST_TARGET=not declaredv0.24.0
#ACME_ENDPOINT_ENABLED=falsenot declaredv0.24.0
Description of ACME_ENDPOINT_ENABLED

Internal ACME 'ask' endpoint for Caddy on-demand TLS, used by the caddy_on_demand validation strategy (features.domains.acme.enabled). When true, the ACME app auto-mounts inside the main process at /api/internal/acme (localhost-only). When false, run it standalone: rackup apps/internal/acme/config.ru. Default: false.

#ACME_LISTEN_ADDRESS=127.0.0.1not declaredv0.24.0
Description of ACME_LISTEN_ADDRESS

Bind address for the standalone ACME endpoint process (features.domains.acme.listen_address). Only applies when running rackup apps/internal/acme/config.ru (ACME_ENDPOINT_ENABLED=false); should stay on localhost. HOST env / rackup -o override it.

#ACME_PORT=12020not declaredv0.24.0
Description of ACME_PORT

Port for the standalone ACME endpoint process (features.domains.acme.port). Caddy's on_demand_tls ask directive points here (e.g. http://127.0.0.1:12020/api/internal/acme/ask). Standalone mode only; PORT env / rackup -p override it.

#DOMAINS_REQUIRE_VERIFIED=falsefalse.v0.25.2
Description of DOMAINS_REQUIRE_VERIFIED

When true, secret creation naming an unverified custom share_domain is rejected with a form error (features.domains.require_verified), under every validation strategy. When false, creation still requires the domain to be served (see DOMAINS_SERVE_UNVERIFIED). Canonical domains are unaffected. The check fires whenever a custom share_domain resolves to a domain record — it is not gated by DOMAINS_ENABLED. Default: false.

#DOMAINS_SERVE_UNVERIFIED=falsenot declaredunreleased
Description of DOMAINS_SERVE_UNVERIFIED

When true, every registered custom domain that has an organization is served whatever its ownership state (features.domains.serve_unverified), and a warning is logged at boot. When false, under a validation strategy that checks ownership (approximated, caddy_on_demand), a custom domain is served only once its ownership is verified by its TXT record or an operator override; until then its host answers 404 and the domain is not offered for links. Under passthrough, registration by the owning organization is enough. Run `bin/ots domains verify --all` to record the ownership of domains whose TXT record is in place. Default: false.

#SSO_DIAGNOSTICS_RATE_LIMIT_ENABLED=truenot declaredunreleased
Description of SSO_DIAGNOSTICS_RATE_LIMIT_ENABLED

Rate limiting for "Check saved configuration" runs on a custom domain's SSO record (features.organizations.sso_diagnostics_rate_limit; POST /api/domains/:extid/sso/diagnostics). Every run contacts the identity provider, and for Entra ID sends the stored client secret in a token request, so run volume is capped per acting customer (keyed on the customer's public id; limiter kind `sso_diagnostics`). Enabled by default (protective); set to the exact string 'false' to opt out — other falsey-looking values ('0', 'no', 'off') leave it ENABLED, the same convention as the other *_ENABLED != 'false' knobs. See lib/onetime/security/sso_diagnostics_rate_limiter.rb.

#SSO_DIAGNOSTICS_RATE_LIMIT_MAX_PER_CUSTOMER=1010.unreleased
Description of SSO_DIAGNOSTICS_RATE_LIMIT_MAX_PER_CUSTOMER

Runs permitted per window for one customer, across all their domains (features.organizations.sso_diagnostics_rate_limit.max_per_customer). Default: 10.

#SSO_DIAGNOSTICS_RATE_LIMIT_WINDOW=36003600 (1 hour).unreleased
Description of SSO_DIAGNOSTICS_RATE_LIMIT_WINDOW

Counting window in seconds (features.organizations.sso_diagnostics_rate_limit.window). Default: 3600 (1 hour).

#SSO_DIAGNOSTICS_RATE_LIMIT_LOCKOUT=36003600 (1 hour).unreleased
Description of SSO_DIAGNOSTICS_RATE_LIMIT_LOCKOUT

Lockout duration in seconds once the cap is hit (features.organizations.sso_diagnostics_rate_limit.lockout). Default: 3600 (1 hour).

#SSO_METADATA_FETCH_RATE_LIMIT_ENABLED=truenot declaredunreleased
Description of SSO_METADATA_FETCH_RATE_LIMIT_ENABLED

Rate limiting for "Fetch from metadata" on a custom domain's SAML settings (features.organizations.sso_metadata_fetch_rate_limit; POST /api/domains/:extid/sso/metadata). Every fetch is one outbound HTTPS request to the SAML IdP metadata URL the caller supplies, so fetch volume is capped per acting customer (keyed on the customer's public id; limiter kind `sso_metadata_fetch`). Enabled by default (protective); set to the exact string 'false' to opt out — other falsey-looking values ('0', 'no', 'off') leave it ENABLED, the same convention as the other *_ENABLED != 'false' knobs. See lib/onetime/security/sso_metadata_fetch_rate_limiter.rb.

#SSO_METADATA_FETCH_RATE_LIMIT_MAX_PER_CUSTOMER=3030.unreleased
Description of SSO_METADATA_FETCH_RATE_LIMIT_MAX_PER_CUSTOMER

Fetches permitted per window for one customer, across all their domains (features.organizations.sso_metadata_fetch_rate_limit.max_per_customer). Default: 30.

#SSO_METADATA_FETCH_RATE_LIMIT_WINDOW=36003600 (1 hour).unreleased
Description of SSO_METADATA_FETCH_RATE_LIMIT_WINDOW

Counting window in seconds (features.organizations.sso_metadata_fetch_rate_limit.window). Default: 3600 (1 hour).

#SSO_METADATA_FETCH_RATE_LIMIT_LOCKOUT=36003600 (1 hour).unreleased
Description of SSO_METADATA_FETCH_RATE_LIMIT_LOCKOUT

Lockout duration in seconds once the cap is hit (features.organizations.sso_metadata_fetch_rate_limit.lockout). Default: 3600 (1 hour).

#SENDER_TEST_EMAIL_RATE_LIMIT_ENABLED=truenot declaredunreleased
Description of SENDER_TEST_EMAIL_RATE_LIMIT_ENABLED

Rate limiting for "Send test" on a custom domain's email sender (features.organizations.sender_test_email_rate_limit; POST /api/domains/:extid/email-config/test). Every send is a real email through the delivery provider and one security audit trail row, so the volume is capped per acting customer (keyed on the customer's public id; limiter kind `sender_test_email`). Enabled by default (protective); set to the exact string 'false' to opt out — other falsey-looking values ('0', 'no', 'off') leave it ENABLED, the same convention as the other *_ENABLED != 'false' knobs. See lib/onetime/security/sender_test_email_rate_limiter.rb.

#SENDER_TEST_EMAIL_RATE_LIMIT_MAX_PER_CUSTOMER=1010.unreleased
Description of SENDER_TEST_EMAIL_RATE_LIMIT_MAX_PER_CUSTOMER

Test sends permitted per window for one customer, across all their domains (features.organizations.sender_test_email_rate_limit.max_per_customer). Default: 10.

#SENDER_TEST_EMAIL_RATE_LIMIT_WINDOW=36003600 (1 hour).unreleased
Description of SENDER_TEST_EMAIL_RATE_LIMIT_WINDOW

Counting window in seconds (features.organizations.sender_test_email_rate_limit.window). Default: 3600 (1 hour).

#SENDER_TEST_EMAIL_RATE_LIMIT_LOCKOUT=36003600 (1 hour).unreleased
Description of SENDER_TEST_EMAIL_RATE_LIMIT_LOCKOUT

Lockout duration in seconds once the cap is hit (features.organizations.sender_test_email_rate_limit.lockout). Default: 3600 (1 hour).

Feature Flags

Example lineDefaultSince
REGIONS_ENABLED=falsenot declarednot annotated
JURISDICTION=not declarednot annotated
Description of JURISDICTION

This instance's jurisdiction ID (e.g. EU).

JURISDICTIONS=not declaredv0.25.5
Description of JURISDICTIONS

Available jurisdictions as ID:domain pairs (e.g., EU:eu.example.com,CA:ca.example.com)

I18N_ENABLED=truenot declarednot annotated
I18N_DEFAULT_LOCALE=ennot declarednot annotated
INCOMING_ENABLED=falsenot declarednot annotated
Description of INCOMING_ENABLED

Enable the incoming secrets feature on the CANONICAL domain (anonymous users send encrypted secrets to pre-configured recipients via /incoming). This flag does NOT gate custom domains — those are governed by the org's incoming_secrets entitlement + ORGS_INCOMING_SECRETS_ENABLED. To emergency-disable incoming on custom domains, revoke the entitlement or set ORGS_INCOMING_SECRETS_ENABLED=false.

INCOMING_RECIPIENT_1=not declarednot annotated
#MARKDOWN_ENABLED=falsenot declaredunreleased
Description of MARKDOWN_ENABLED

Markdown secrets (features.markdown.enabled). When true, senders can mark a secret as Markdown and the recipient sees it rendered and sanitized. When false (the default), the create forms show no Markdown control, the API refuses display_type=markdown, and secrets already stored as Markdown are shown as plain text.

not declarednot annotated
PRICING_URL=/pricingnot declaredv0.24.1
TERMS_URL=/termsnot declarednot annotated
Description of TERMS_URL

Legal & policy URLs (site.legal, #4278). TERMS_URL / PRIVACY_URL also drive the signup consent links and the branded reveal footer; all six render in the footer "Legals" column when FOOTER_LINKS=true. A URL that is unset or blank makes the link absent everywhere — no placeholder, no dead link. Absolute URLs (https://...) open in a new tab; relative paths (e.g. /terms) resolve in-app.

PRIVACY_URL=/privacynot declarednot annotated
DPA_URL=not declaredv0.26.8
not declaredv0.26.8
AUP_URL=not declaredv0.26.8
SECURITY_URL=not declaredv0.26.8
STATUS_URL=not declarednot annotated
ABOUT_URL=/aboutnot declarednot annotated
CONTACT_URL=/feedbacknot declarednot annotated
#DOCS_URL=https://docs.onetimesecret.com/https://docs.onetimesecret.com/v0.25.0
Description of DOCS_URL

Footer "Docs" link (public pages). Also sets docs_host in the bootstrap payload — the documentation host exposed to the frontend, which replaced the retired site.support.host (#1461). Default when unset: https://docs.onetimesecret.com/

#SUPPORT_HOST=not declarednot annotated
Description of SUPPORT_HOST

Documentation/support host. Sets site.support.host in the bootstrap payload. Note: this does NOT repoint the workspace footer links below — those resolve to WORKSPACE_API_DOCS_URL / WORKSPACE_BRANDING_GUIDE_URL (or their built-in defaults). Default: unset.

not declaredv0.25.0
Description of WORKSPACE_LINKS

Workspace footer links (authenticated users) Master toggle — workspace footer links are OFF unless set to 'true'.

WORKSPACE_API_DOCS_URL=not declaredv0.24.6
Description of WORKSPACE_API_DOCS_URL

Override the API Docs and Branding Guide link URLs. Leave blank to use the built-in defaults (https://api.onetimesecret.com/ and https://docs.onetimesecret.com/).

WORKSPACE_BRANDING_GUIDE_URL=not declaredv0.24.6
WORKSPACE_FEEDBACK_URL=/feedbacknot declaredv0.24.6
#WORKSPACE_FEEDBACK_ENABLED=truenot declaredv0.25.5
Description of WORKSPACE_FEEDBACK_ENABLED

Show the workspace feedback link. Set to 'false' to hide. Default: true.

not declaredv0.25.5
Description of FOOTER_VERSION_ENABLED

Show version number in all footers (default: true) Set to 'false' to hide version across all footer components

#I18N_DATE_FORMAT=localenot declaredv0.24.6
Description of I18N_DATE_FORMAT

Date display format (internationalization.date_format). Accepts a preset keyword or a raw date-fns pattern. Setting this alone controls both date-only and date+time display. Presets: locale (browser-native, default) | iso8601 | us | eu | eu-dot | uk | long.

#I18N_DATETIME_FORMAT=localenot declaredv0.24.6
Description of I18N_DATETIME_FORMAT

Date+time display format (internationalization.datetime_format). Same preset keywords as I18N_DATE_FORMAT, or a raw date-fns pattern. Only set when date+time contexts need a different format: left at the default 'locale', the frontend falls back to the datetime variant of I18N_DATE_FORMAT (so one setting covers both).

API Access

Example lineDefaultSince
#API_ENABLED=truetrue; set to 'false' to mark the API disabled in the UI.not annotated
Description of API_ENABLED

Whether the JSON API is advertised as enabled (site.interface.api.enabled). Only surfaced to the web frontend via the bootstrap config (the account API settings page shows a disabled notice); it does NOT block /api/* requests server-side. Default: true; set to 'false' to mark the API disabled in the UI.

#API_GUEST_BURN=truenot declarednot annotated
Description of API_GUEST_BURN

Allow anonymous callers to burn (destroy unread) a secret via the guest API (site.interface.api.guest_routes.burn). Checked only when API_GUEST_ROUTES_ENABLED is true; authenticated requests are never gated. Default: true; set to 'false' to disable.

#API_GUEST_CONCEAL=truenot declarednot annotated
Description of API_GUEST_CONCEAL

Allow anonymous callers to create a secret from their own value via the guest API (site.interface.api.guest_routes.conceal). Checked only when API_GUEST_ROUTES_ENABLED is true; authenticated requests are never gated. Default: true; 'false' to disable.

#API_GUEST_GENERATE=truenot declarednot annotated
Description of API_GUEST_GENERATE

Allow anonymous callers to create a random generated secret via the guest API (site.interface.api.guest_routes.generate). Checked only when API_GUEST_ROUTES_ENABLED is true; authenticated requests are never gated. Default: true; set to 'false' to disable.

#API_GUEST_RECEIPT=truenot declarednot annotated
Description of API_GUEST_RECEIPT

Allow anonymous callers to view secret receipts (creator-side status/metadata, single or batch) via the guest API (site.interface.api.guest_routes.receipt). Checked only when API_GUEST_ROUTES_ENABLED is true; authenticated requests are never gated. Default: true; set to 'false' to disable.

#API_GUEST_REVEAL=truenot declarednot annotated
Description of API_GUEST_REVEAL

Allow anonymous callers to reveal (decrypt and consume) a secret via the guest API (site.interface.api.guest_routes.reveal). Checked only when API_GUEST_ROUTES_ENABLED is true; authenticated requests are never gated. Default: true; 'false' to disable.

#API_GUEST_ROUTES_ENABLED=truenot declarednot annotated
Description of API_GUEST_ROUTES_ENABLED

Global toggle for anonymous (guest) API access (site.interface.api.guest_routes.enabled). When false, all guest secret operations are rejected with GUEST_ROUTES_DISABLED regardless of the per-operation flags below. Authenticated requests are unaffected. Default: true; 'false' to disable.

#API_GUEST_SHOW=truenot declarednot annotated
Description of API_GUEST_SHOW

Allow anonymous callers to retrieve a secret via the guest API show endpoint — metadata, plus the decrypted value when called with continue=true (site.interface.api.guest_routes.show). Checked only when API_GUEST_ROUTES_ENABLED is true; authenticated requests are never gated. Default: true; 'false' to disable.

Incoming Secrets

Example lineDefaultSince
#INCOMING_DEFAULT_PASSPHRASE=Emptynot annotated
Description of INCOMING_DEFAULT_PASSPHRASE

Optional passphrase applied to every secret submitted through the incoming form (features.incoming.default_passphrase). Recipients must enter it to reveal the secret. Global — applies to custom-domain submissions too (not per-domain yet). Empty (default) = no passphrase.

#INCOMING_DEFAULT_TTL=604800not declarednot annotated
Description of INCOMING_DEFAULT_TTL

Lifetime in seconds for secrets created via the incoming form on the canonical domain (features.incoming.default_ttl). Coerced to integer seconds at boot; custom domains use their per-domain IncomingConfig instead. Default: 604800 (7 days).

#INCOMING_MEMO_MAX_LENGTH=50not declarednot annotated
Description of INCOMING_MEMO_MAX_LENGTH

Maximum character length for the memo/subject field on the incoming form, canonical domain (features.incoming.memo_max_length). Longer memos are truncated server-side. Custom domains use their per-domain IncomingConfig. Default: 50 characters.

#INCOMING_RECIPIENT_2=not declarednot annotated
Description of INCOMING_RECIPIENT_2

Second pre-configured recipient for canonical-domain incoming secrets (features.incoming.recipients). Format: email[,display name]; name defaults to the email's local part. Emails are hashed with site.secret at boot and never exposed in API responses. Requires INCOMING_ENABLED=true. See INCOMING_RECIPIENT_1.

#INCOMING_RECIPIENT_3=not declarednot annotated
Description of INCOMING_RECIPIENT_3

Third pre-configured recipient for canonical-domain incoming secrets (features.incoming.recipients). Format: email[,display name]. Requires INCOMING_ENABLED=true. See INCOMING_RECIPIENT_1.

#INCOMING_RECIPIENT_4=not declarednot annotated
Description of INCOMING_RECIPIENT_4

Fourth pre-configured recipient for canonical-domain incoming secrets (features.incoming.recipients). Format: email[,display name]. Slot 4 is the last env-wired slot; additional recipients must be listed in etc/config.yaml directly. Requires INCOMING_ENABLED=true. See INCOMING_RECIPIENT_1.

#INCOMING_RATE_LIMIT_ENABLED=truenot declaredv0.26.0
Description of INCOMING_RATE_LIMIT_ENABLED

Rate limiting for anonymous incoming-secret submissions (AZ9; features.incoming.rate_limit). Throttles per client IP and per client-supplied recipient hash BEFORE any secret is created or email is sent. Enabled by default (protective); set to 'false' to opt out. See lib/onetime/security/incoming_rate_limiter.rb. Default: enabled.

#INCOMING_RATE_LIMIT_MAX_PER_IP=1010.v0.26.0
Description of INCOMING_RATE_LIMIT_MAX_PER_IP

Submissions permitted per window from a single client IP before the per-IP tier locks out (features.incoming.rate_limit.max_per_ip). Default: 10.

#INCOMING_RATE_LIMIT_MAX_PER_RECIPIENT=30not declaredv0.26.0
Description of INCOMING_RATE_LIMIT_MAX_PER_RECIPIENT

Higher backstop cap per recipient hash, catching IP-rotating spam (features.incoming.rate_limit.max_per_recipient). Default: 30.

#INCOMING_RATE_LIMIT_WINDOW=3600not declaredv0.26.0
Description of INCOMING_RATE_LIMIT_WINDOW

Counting window in seconds for both rate-limit tiers (features.incoming.rate_limit.window). Default: 3600 (1 hour).

#INCOMING_RATE_LIMIT_LOCKOUT=3600not declaredv0.26.0
Description of INCOMING_RATE_LIMIT_LOCKOUT

Lockout duration in seconds once a tier's cap is hit (features.incoming.rate_limit.lockout). Default: 3600 (1 hour).

Reverse Proxy / Client IP

Site-wide client IP resolution (config site.network.trusted_proxy). Affects ban checks, session tracking, audit logs, rate limiting, and the Colonel "Your Current IP" display. WARNING: Only enable when the app is behind a trusted reverse proxy, direct client access is blocked by firewall, and the proxy strips/overwrites client-provided forwarding headers.

Example lineDefaultSince
TRUSTED_PROXY_ENABLED=falsefalsev0.25.5
Description of TRUSTED_PROXY_ENABLED

Turns proxy-aware IP resolution on. The mode, CIDR, and depth settings below do nothing while this is false. When false (default), forwarded headers are ignored and the client IP is REMOTE_ADDR (the address that opened the connection). That is right only when clients connect to the app directly: put a proxy in front and REMOTE_ADDR is the proxy, so every request looks like it came from one address. When true, the client IP is read out of the forwarded headers using the mode below. Behind a proxy you trust, set this to true. Behind one you don't trust to overwrite X-Forwarded-For, set true and set the mode below to depth, which picks the client by its position in the chain.

TRUSTED_PROXY_MODE=filternot declaredv0.25.5
Description of TRUSTED_PROXY_MODE

How to resolve client IP behind proxies. Default: filter. - filter: CIDR-walk. Walk the forwarded chain LEFT-TO-RIGHT and return the first entry that is not a trusted proxy — i.e. the LEFTMOST non-proxy entry becomes the client IP. Trusted = RFC1918/loopback/link-local (10.x, 127.x, 169.254.x, 172.16-31.x, 192.168.x, ::1, fc00::/7, fe80::/10) plus every range in TRUSTED_PROXY_CIDRS. Works for most k8s/cloud deployments where proxies have internal IPs. SECURITY: because the leftmost entry wins, the edge proxy MUST OVERWRITE X-Forwarded-For with the real peer address, otherwise a client-supplied entry is returned as the client IP and IP rate limits/bans are spoofable: nginx: proxy_set_header X-Forwarded-For $remote_addr; Caddy: header_up X-Forwarded-For {client_ip} ({client_ip} honours Caddy's own trusted_proxies, so it resolves the real visitor when Caddy is itself behind a declared CDN; with none declared it is the direct peer, same as {remote_host}. See etc/examples/Caddyfile-example.) Use depth mode when you cannot make the edge overwrite. Combining the overwrite above with depth mode is correct ONLY for TRUSTED_PROXY_DEPTH=1 (the overwrite leaves exactly one attested entry, which depth 1 selects); a depth of 2 or more needs each counted hop to append one entry, and the overwrite collapses that chain, so resolution falls back to the peer. Note: filter mode reads the X-Forwarded-For family only (X-Forwarded-For, X-Real-IP, X-Client-IP) and REJECTS any other TRUSTED_PROXY_HEADER at boot. - depth: position-based; skip exactly TRUSTED_PROXY_DEPTH rightmost hops. Counts positions from the RIGHT of the chain, so extra leftmost entries (forged or from farther upstream) never shift the selection. Requires a FIXED hop count with each counted hop appending exactly one entry: a chain shorter than the depth falls back to the peer, and a depth larger than the real hop count selects a client-supplied entry. Use for proxies with public IPs that filter mode would misread as the client, or when you need deterministic hop selection regardless of IP class. Matched case-insensitively and canonicalized (as TRUSTED_PROXY_HEADER is). Anything outside the closed set falls back to filter — the safer mode — and WARNs at boot naming the value; the startup `trusted_proxy:` posture line always reports the mode actually in force, not the value configured here.

TRUSTED_PROXY_HEADER=X-Forwarded-ForX-Forwarded-For. The accepted set is closed — exactly one of: - X-Forwarded-For the de facto standard, written by nginx, Caddy, HAProxy, ALB/ELB, Cloudflare, Fastly, and most k8s ingresses - Forwarded RFC 7239 (for=/by=/proto=), emitted by HAProxy and Apache when configured for it; still uncommon in practice - Both read Forwarded first, fall back to X-Forwarded-For Case-insensitive, so `forwarded` is accepted and canonicalized. Any other value fails the boot with an ArgumentError rather than silently resolving from the wrong header.v0.25.5
Description of TRUSTED_PROXY_HEADER

depth mode only: which header to read the forwarding chain from. Default: X-Forwarded-For. The accepted set is closed — exactly one of: - X-Forwarded-For the de facto standard, written by nginx, Caddy, HAProxy, ALB/ELB, Cloudflare, Fastly, and most k8s ingresses - Forwarded RFC 7239 (for=/by=/proto=), emitted by HAProxy and Apache when configured for it; still uncommon in practice - Both read Forwarded first, fall back to X-Forwarded-For Case-insensitive, so `forwarded` is accepted and canonicalized. Any other value fails the boot with an ArgumentError rather than silently resolving from the wrong header. Vendor client-IP headers are NOT selectable here — CF-Connecting-IP, True-Client-IP and friends are never read for client-IP resolution. They also carry a single address rather than a chain, so there are no hops to count. (Vendor GEO headers such as CF-IPCountry are a separate concern with its own resolution path; this setting does not affect them.) If your edge only sets one of those, have it write the chain into X-Forwarded-For: Cloudflare: enable "Add visitor IP headers" (sets X-Forwarded-For), or transform-rule X-Forwarded-For = cf.connecting_ip Filter mode reads the X-Forwarded-For family only (X-Forwarded-For, X-Real-IP, X-Client-IP) and never RFC 7239 Forwarded: with TRUSTED_PROXY_MODE=filter, any value here other than X-Forwarded-For FAILS THE BOOT with a message naming both settings. Leave it at the default (or unset) unless the mode is depth.

#TRUSTED_PROXY_CIDRS=not declaredv0.25.5
Description of TRUSTED_PROXY_CIDRS

filter mode only: additional CIDR ranges to trust as proxies beyond the RFC1918 defaults (e.g. a CDN/proxy with public IPs). Comma-separated. Example: 203.0.113.0/24,2001:db8::/32 SECURITY: listing a range here is a full infrastructure-trust grant, not just client-IP trust. Requests arriving from these ranges also have their X-Forwarded-Host honored for custom-domain detection. It is the only forwarded host header the application reads, and only when it holds one value. The proxy MUST therefore overwrite it itself rather than pass it through or append to it — a pass-through proxy lets a client pick the tenant domain the app renders, and a comma-separated value is not read at all (the request resolves on Host, with a WARN). This is the same requirement stated above for X-Forwarded-For, extended to the host header. Apx-Incoming-Host, X-Original-Host and RFC 7239 Forwarded's host= parameter are never a host source: an edge that carries the public host only there must preserve Host or send X-Forwarded-Host instead, and should remove them. See docs/operations/proxy-authority-header.md.

TRUSTED_PROXY_DEPTH=1not declaredv0.25.5
Description of TRUSTED_PROXY_DEPTH

depth mode only: number of proxy hops to skip from the right. Default: 1. 1 = standard single reverse proxy (nginx/Caddy) 2 = CDN -> reverse proxy -> app For direct/no-proxy deployments set TRUSTED_PROXY_ENABLED=false above rather than depth: 0.

#GEO_HEADER=not declaredv0.26.5
Description of GEO_HEADER

Optional app-level geo header, checked before the built-in vendor headers. FILTER MODE ONLY (setting it under depth mode trips Otto's boot-time depth/geo_header conflict, so it is ignored there). Blank = built-ins only.

#GEO_DB_PATH=not declaredv0.26.5
Description of GEO_DB_PATH

Optional path to a local MaxMind country database (.mmdb) for deployments without a geo-tagging CDN (direct-connect, or depth mode). Looked up on the ALREADY-MASKED IP. Works in all modes. Requires the optional 'maxmind-db' gem; a bad path / missing gem fails at boot. Blank = disabled.

#SECRET_ACTIVITY_GEO_COUNTRY_ENABLED=falsenot declaredv0.26.5
Description of SECRET_ACTIVITY_GEO_COUNTRY_ENABLED

Country column on the org Secret Activity trail (config features.secret_activity.geo_country_enabled). DEFAULT-OFF / opt-in — the inverse of the other secret-activity flags — pending counsel review of org-tier geo exposure. Set to true to enable once reviewed.

ASSUME_HTTPS=falsenot declaredv0.26.2
Description of ASSUME_HTTPS

Assume HTTPS at the origin (config site.network.assume_https). Opt-in and INDEPENDENT of TRUSTED_PROXY_*. Upgrade-only: when true, requests that do not already look like HTTPS are marked as HTTPS before any downstream consumer reads the scheme (Secure session cookie, HttpOrigin, CSRF, HSTS, scheme redirects). Only enable when a real TLS-terminating proxy fronts the origin AND it does not forward X-Forwarded-Proto: https (e.g. Cloudflare Tunnel). Standard nginx/Caddy/ALB setups forward the scheme and do not need this. Never enable on a directly-reachable origin. Keep SSL=true (site.ssl) when this is on: pairing ASSUME_HTTPS=true with site.ssl:false makes generated share/email/redirect links emit http:// while clients use https. Default: false.

#PUBLIC_HOST_REWRITE=falsenot declaredv0.26.15
Description of PUBLIC_HOST_REWRITE

Public host rewrite (config site.network.public_host_rewrite). Opt-in. For installs behind a proxy that rewrites Host to the origin target and sends the public hostname in X-Forwarded-Host: when true, a request classified as a canonical host, a subdomain of one, or a registered custom domain has its Host set to that hostname before the mounted apps run. Requests that do not classify are left as they are. No effect behind a proxy that preserves Host. See docs/operations/proxy-authority-header.md. Default: false.

#ADMIN_ALLOWED_HOSTS=Unsetv0.26.5
Description of ADMIN_ALLOWED_HOSTS

Admin (Colonel) host allowlist (config site.admin.allowed_hosts). Comma-separated hostnames that serve /colonel and /api/colonel; any other host gets the same 404. Unset (default) = the canonical hosts (DEFAULT_DOMAIN / HOST) plus their www. variants, so tenant custom domains and LINK_DOMAINS stop serving the admin console. Set but BLANK ("" or only whitespace/commas) = the same canonical fallback, plus a boot WARN (#4127): a written allowlist that names nothing is announced rather than silently reading as unset -- on a localhost/bare-IP install it yields no host gate at all (see below). Explicit entries match literally. "*" ANYWHERE in the list disables the host gate, logged at WARN, and is the one-variable rollback. Punycode (xn--) form required for internationalized domains. A value that can never match (an IP literal, localhost, *.example.com, a non-ASCII name) leaves the gate ACTIVE and EMPTY: /colonel and /api/colonel 404 on every hostname, with a boot WARN naming each rejected entry. The boot itself is NOT aborted -- the rest of the app is unaffected. Only the unset/fallback case self-disables, with a boot WARN, on installs with no routable hostname (the stock HOST=localhost:3000, or a bare-IP install) -- there the fallback has nothing to anchor on, so unsetting turns the gate OFF rather than restricting to a canonical host; it is not a hardening step on such installs. Behind a proxy that forwards the public hostname in a header instead of rewriting Host, TRUSTED_PROXY_* must be configured with the proxy's own ranges in TRUSTED_PROXY_CIDRS or the admin gate refuses the forwarded host and both surfaces 404. Filter mode with no explicit CIDRs trusts every private-network peer as a proxy, which restores exactly the forwarded-host spoofing the provenance rule exists to block. THIS AND ADMIN_ALLOWED_CIDRS ARE ALSO WHAT PROMOTE the 15 destructive colonel routes (purge, role change, revoke, delete, DLQ purge) from an ADVISORY network requirement to an ENFORCED one (#4332). Set BOTH or neither: setting only one gives you neither promotion and logs a boot WARN saying so. A wildcard here does not count as set for this purpose. Example: admin.example.com

#ADMIN_ALLOWED_CIDRS=not declaredv0.26.0
Description of ADMIN_ALLOWED_CIDRS

Admin (Colonel) network isolation (config site.admin.allowed_cidrs). Optional CIDR allowlist for the Colonel admin surfaces (/colonel shell and /api/colonel API). Comma-separated. When set, a request whose trusted-proxy-resolved client IP is OUTSIDE the allowlist gets a 404 (indistinguishable-from-absent, not a 403) on both surfaces — defense-in-depth on top of the two app-layer auth layers, which still enforce beneath it. Unset/empty (default): NO-OP — both surfaces reachable, gated only by the auth layers. Correct for self-hosted single-container installs (which cannot require a VPN); those can instead front the surfaces with a reverse proxy. Set to PRIVATE ranges only on cloud (e.g. a Tailscale/VPN CGNAT range). Behind a proxy you must also configure TRUSTED_PROXY_* above (with the proxy's own ranges in TRUSTED_PROXY_CIDRS) or every request resolves to the proxy hop; a raw X-Forwarded-For header cannot bypass this. Client IPs arrive privacy-masked (last IPv4 octet zeroed = /24, IPv6 to /48), but matching runs at full precision through the verdict-only true-IP matcher the privacy layer installs, so /32 and /128 entries work — see docs/operations/admin-network-isolation.md, "CIDR precision and privacy masking". Paired with ADMIN_ALLOWED_HOSTS above, this is what promotes the 15 destructive colonel routes from an advisory network requirement to an enforced one (#4332). Set BOTH or neither. Example: 100.64.0.0/10,10.0.0.0/8

#RODAUTH_ADMIN_URL=not declaredv0.26.12
Description of RODAUTH_ADMIN_URL

Base URL of the standalone Rodauth Admin instance (config site.admin.rodauth_admin_url). Optional and credential-free: the colonel console only renders outbound links to it (customer detail -> the matching Rodauth account by external id; the sessions console -> the full-mode session authority). Nothing is ever requested from it. Unset or blank renders those links as plain text. Only meaningful in full auth mode. Example: http://127.0.0.1:9292

#COLONEL_ELEVATION_ENABLED=truenot declaredv0.26.11
Description of COLONEL_ELEVATION_ENABLED

Step-up (sudo) re-authentication for destructive colonel actions (config site.admin.elevation, issue #4327). ON BY DEFAULT. A colonel session alone is no longer sufficient for a tier-1 verb (purge account, change role, revoke sessions, delete org/domain/secret, DLQ purge): the operator must have re-proven a credential through POST /api/colonel/elevation within the window. Confirmation (#4326) still applies on top. Set to false to restore pre-#4327 behaviour.

#COLONEL_ELEVATION_WINDOW=600not declaredv0.26.11
Description of COLONEL_ELEVATION_WINDOW

Window length in seconds (default 600 = 10 minutes).

#COLONEL_ELEVATION_REAUTH_GRACE=0not declaredv0.26.11
Description of COLONEL_ELEVATION_REAUTH_GRACE

Seconds after sign-in during which a PASSWORD-LESS (SSO-only) colonel may elevate with factor=recent_auth and no credential. DEFAULT 0 = OFF. Available ONLY to accounts that cannot satisfy the password factor: giving it to password holders would make step-up a no-op for the first N seconds after every sign-in. In FULL auth mode every account counts as password-holding (the Rodauth hash table is not reachable from the logic layer), so recent_auth is unavailable there and an SSO-only full-mode fleet sets COLONEL_ELEVATION_ENABLED=false instead. MFA as a step-up factor is not implemented. AN SSO-ONLY FLEET MUST CONFIGURE THIS OR COLONEL_ELEVATION_ENABLED OR ITS COLONELS CANNOT PERFORM TIER-1 ACTIONS AT ALL — and which one depends on the auth mode: in SIMPLE mode set this grace > 0 (or ENABLED=false); in FULL mode recent_auth is never offered, so only ENABLED=false works.

#COLONEL_RATE_LIMIT_ENABLED=truenot declaredv0.26.11
Description of COLONEL_RATE_LIMIT_ENABLED

Rate limits for the colonel API surface (config site.admin.rate_limit). Keyed on the acting colonel's public external id (extid) — the same value the audit trail records as `actor` — never on a session id. The parent flag short-circuits every bucket.

#COLONEL_ELEVATION_RATE_LIMIT_ENABLED=truenot declaredv0.26.11
Description of COLONEL_ELEVATION_RATE_LIMIT_ENABLED

Step-up attempts. The Rodauth password check behind POST /api/colonel/elevation is an internal request and does NOT increment Rodauth's own lockout counter, so this is the only backstop against guessing there. A locked-out operator clears it with POST /api/colonel/ratelimit/reset (kind=colonel_elevation) or the commands `bin/ots ratelimit keys colonel_elevation <extid>` prints.

#COLONEL_ELEVATION_MAX_ATTEMPTS=5not declaredv0.26.11
#COLONEL_ELEVATION_RATE_WINDOW=900not declaredv0.26.11
#COLONEL_ELEVATION_LOCKOUT=900not declaredv0.26.11
#COLONEL_MUTATION_RATE_LIMIT_ENABLED=truenot declaredv0.26.11
Description of COLONEL_MUTATION_RATE_LIMIT_ENABLED

Every MUTATING colonel verb (issue #4329), charged once per request from the colonel logic base constructor. 120 per 5 minutes is far above any human operator and above a bulk console session, so it only trips on scripted abuse. A lockout here also blocks POST /ratelimit/reset (itself a mutation), so clear this one with the commands `bin/ots ratelimit keys colonel_mutation <extid>` prints.

#COLONEL_MUTATION_MAX_ATTEMPTS=120not declaredv0.26.11
#COLONEL_MUTATION_RATE_WINDOW=300not declaredv0.26.11
#COLONEL_MUTATION_LOCKOUT=300not declaredv0.26.11
#COLONEL_DESTRUCTIVE_RATE_LIMIT_ENABLED=truenot declaredv0.26.11
Description of COLONEL_DESTRUCTIVE_RATE_LIMIT_ENABLED

TIER 1 (destructive) verbs only: purge account, change role, revoke sessions, delete org/domain/secret, DLQ purge. Charged LAST — after step-up, confirmation and the per-verb interlocks all pass — so a rejected attempt costs nothing and an attacker holding the cookie cannot burn the real operator's budget with cheap 403s. 10 real actions per 5 minutes with a 15-minute lockout: enough for an incident-response burst, and low enough that a scripted purge stalls long before it can flush the count-capped audit trail. Bulk work belongs on the CLI. Clear a lockout with POST /ratelimit/reset (kind=colonel_destructive), which needs no elevation.

#COLONEL_DESTRUCTIVE_MAX_ATTEMPTS=10not declaredv0.26.11
#COLONEL_DESTRUCTIVE_RATE_WINDOW=300not declaredv0.26.11
#COLONEL_DESTRUCTIVE_LOCKOUT=900not declaredv0.26.11
#COLONEL_HANDLE_RESOLVE_RATE_LIMIT_ENABLED=truenot declaredv0.26.11
Description of COLONEL_HANDLE_RESOLVE_RATE_LIMIT_ENABLED

The two session reads that resolve an opaque session handle (#4330) and may fall back to a bounded 10 000-key scan plus as many HMACs. Every OTHER colonel read is deliberately unlimited — the console fetches them on every screen.

#COLONEL_HANDLE_RESOLVE_MAX_ATTEMPTS=60not declaredv0.26.11
#COLONEL_HANDLE_RESOLVE_RATE_WINDOW=300not declaredv0.26.11
#COLONEL_HANDLE_RESOLVE_LOCKOUT=300not declaredv0.26.11
#ADMIN_SESSION_LIFETIME_ENABLED=truenot declaredv0.26.11
Description of ADMIN_SESSION_LIFETIME_ENABLED

Session bounds for the ADMIN API SURFACE (/api/colonel) only (issue #4331). These do NOT shorten the onetime.session cookie and do NOT gate the /colonel SPA shell: one session serves the admin console, the tenant app and the auth app, so expiring the object would log a colonel out of the customer app too — and on a self-hosted install the colonel is often the only customer. A stale admin tab loads the shell, its first API call 401s, and the console renders an expired banner with a sign-in link. ADMIN_SESSION_IDLE_TIMEOUT reads the best-effort SessionMetadata sidecar and is SKIPPED (never failed) when no record exists. It only bites because the admin console makes no periodic requests, and the sidecar is a site-wide activity clock, so tenant traffic keeps the admin window open too. ADMIN_SESSION_ABSOLUTE_TIMEOUT reads session['authenticated_at'] — nothing a stolen cookie can do advances it, so it is the bound that always binds. Set either to 0 to disable that bound; ADMIN_SESSION_LIFETIME_ENABLED=false disables both and restores the pre-#4331 24h rolling posture.

#ADMIN_SESSION_IDLE_TIMEOUT=3600not declaredv0.26.11
#ADMIN_SESSION_ABSOLUTE_TIMEOUT=43200not declaredv0.26.11

Middleware

Example lineDefaultSince
#MIDDLEWARE_AUTHENTICITY_TOKEN=truetrue. Set to 'false' to disable.v0.24.0
Description of MIDDLEWARE_AUTHENTICITY_TOKEN

CSRF token protection (site.middleware.authenticity_token). Validates the 'shrimp' authenticity token on state-changing requests via Rack::Protection::AuthenticityToken. Bypassed for /api/*, /auth/sso/*, /auth/email-login, and /billing/webhook. Default: true. Set to 'false' to disable.

not declaredv0.24.0
Description of MIDDLEWARE_COOKIE_TOSSING

Cookie-tossing protection (site.middleware.cookie_tossing). Refuses (403) a request whose Cookie header carries the session cookie (site.session.key) more than once, so a cookie planted by a sibling or parent host cannot compete with the real one (Onetime::Middleware::CookieTossing). Default: true since v0.26.14. Set to 'false' to disable.

#MIDDLEWARE_FRAME_OPTIONS=truetrue. Set to 'false' to disable.v0.24.0
Description of MIDDLEWARE_FRAME_OPTIONS

Clickjacking protection (site.middleware.frame_options). Enables Rack::Protection::FrameOptions, which sets the X-Frame-Options header to restrict iframe embedding. Default: true. Set to 'false' to disable.

#MIDDLEWARE_HTTP_ORIGIN=falsenot declaredv0.24.0
Description of MIDDLEWARE_HTTP_ORIGIN

Origin-based CSRF protection (site.middleware.http_origin). Enables Rack::Protection::HttpOrigin, which denies state-changing requests whose Origin header does not match the site's own origin. Requests without an Origin header are allowed through. An Origin matching the request's resolved display domain (custom domains behind a Host-rewriting proxy) is always accepted. Default: false. Set to 'true' to enable. WARNING: the "site's own origin" is derived from the Host header (or X-Forwarded-Host) as seen by the application, not from the host resolved by Rack::DetectHost. Enabling this requires the proxy tier to pass the public hostname through in Host or X-Forwarded-Host. If your proxy rewrites Host to the canonical origin and forwards the real host only in a header the application does not read (Apx-Incoming-Host, X-Original-Host, Forwarded), every state-changing request from a custom domain is rejected with a 403 — most visibly SSO sign-in, which has no authenticity-token fallback. Symptom in logs: "attack prevented by Rack::Protection::HttpOrigin".

#MIDDLEWARE_IP_SPOOFING=falsenot declaredv0.24.0
Description of MIDDLEWARE_IP_SPOOFING

IP spoofing detection (site.middleware.ip_spoofing). Enables Rack::Protection::IPSpoofing, which rejects requests whose forwarded-IP headers disagree with each other. Default: false. Set to 'true' to enable.

#MIDDLEWARE_PATH_TRAVERSAL=truenot declaredv0.24.0
Description of MIDDLEWARE_PATH_TRAVERSAL

Path traversal protection (site.middleware.path_traversal). Enables Rack::Protection::PathTraversal, which unescapes and normalizes request paths, resolving "../" segments before routing (paths are rewritten, not rejected). Default: true. Set to 'false' to disable.

#MIDDLEWARE_STATIC_FILES=truenot declaredv0.24.0
Description of MIDDLEWARE_STATIC_FILES

Static asset serving (site.middleware.static_files). Serves /dist, /img, /v3 and root-level icon files from public/web via Rack::Static so the Vue frontend works without a reverse proxy. Default: true. Set to 'false' when nginx or similar serves static files instead.

#MIDDLEWARE_STRICT_TRANSPORT=truenot declaredv0.24.0
Description of MIDDLEWARE_STRICT_TRANSPORT

HSTS header (site.middleware.strict_transport). Enables Rack::Protection::StrictTransport, which sets the Strict-Transport-Security header to force HTTPS. Default: true. Set to 'false' to disable (e.g. plain-HTTP deployments).

#MIDDLEWARE_UTF8_SANITIZER=truetrue. Set to 'false' to disable.v0.24.0
Description of MIDDLEWARE_UTF8_SANITIZER

UTF-8 request sanitization (site.middleware.utf8_sanitizer). Enables Rack::UTF8Sanitizer, which replaces malformed UTF-8 in request URIs, headers, and form/JSON bodies with U+FFFD and strips null bytes (requests are sanitized in place, not rejected). Default: true. Set to 'false' to disable.

#MIDDLEWARE_XSS_HEADER=falsenot declaredv0.24.0
Description of MIDDLEWARE_XSS_HEADER

Legacy XSS filter header (site.middleware.xss_header). Enables Rack::Protection::XSSHeader, which sets X-XSS-Protection on HTML responses (modern browsers rely on CSP instead) and also X-Content-Type-Options: nosniff on all responses. Default: false. Set to 'true' to enable.

#MIDDLEWARE_AUTH_DEFLATER=truetrue. Set to 'false' to disable.v0.26.5
Description of MIDDLEWARE_AUTH_DEFLATER

Gzip response compression for /auth via Rack::Deflater. Default: true. Set to 'false' to disable.

#MIDDLEWARE_AUTH_CONTENT_SECURITY_POLICY=truetrue. Set to 'false' to disable.v0.26.5
Description of MIDDLEWARE_AUTH_CONTENT_SECURITY_POLICY

Content-Security-Policy header on /auth responses. Default: true. Set to 'false' to disable.

#MIDDLEWARE_AUTH_FRAME_OPTIONS=truetrue. Set to 'false' to disable.v0.26.5
Description of MIDDLEWARE_AUTH_FRAME_OPTIONS

X-Frame-Options clickjacking protection for /auth. Default: true. Set to 'false' to disable.

#MIDDLEWARE_AUTH_HTTP_ORIGIN=truetrue. Set to 'false' to disable.v0.26.5
Description of MIDDLEWARE_AUTH_HTTP_ORIGIN

Origin-based CSRF protection for /auth. Uses the shared display-domain allow_if, so custom-domain /auth requests behind a Host-rewriting proxy are accepted. Default: true. Set to 'false' to disable.

#MIDDLEWARE_AUTH_IP_SPOOFING=truenot declaredv0.26.5
Description of MIDDLEWARE_AUTH_IP_SPOOFING

IP spoofing detection for /auth (disagreeing forwarded-IP headers are rejected). Default: true. Set to 'false' to disable.

#MIDDLEWARE_AUTH_PATH_TRAVERSAL=truetrue. Set to 'false' to disable.v0.26.5
Description of MIDDLEWARE_AUTH_PATH_TRAVERSAL

Path traversal normalization for /auth request paths. Default: true. Set to 'false' to disable.

#MIDDLEWARE_AUTH_SESSION_HIJACKING=truenot declaredv0.26.5
Description of MIDDLEWARE_AUTH_SESSION_HIJACKING

Session hijacking protection for /auth (ties the session to stable client attributes). Default: true. Set to 'false' to disable.

#CSP_ENABLED=truenot declarednot annotated
Description of CSP_ENABLED

Content-Security-Policy emission (site.security.csp.enabled). When enabled, HTML responses get a nonce-based CSP header (strict in production, relaxed in development for hot reloading) unless a route already set one; the nonce is exposed via env['onetime.nonce']. Default: true. Set to 'false' to disable.

#HEALTH_TRUSTED_CIDR=not declaredv0.25.5
Description of HEALTH_TRUSTED_CIDR

Extra CIDR ranges allowed to reach the health endpoints (/health, /health/*, /auth/health). Comma-separated, e.g. "100.64.0.0/10,10.96.0.0/12". Localhost and private ranges (RFC 1918, IPv6 unique-local) are always trusted; other clients get a 403. Invalid entries are skipped with a warning. No default.

UI / Homepage

Example lineDefaultSince
UI_HOMEPAGE_MODE=blanknot annotated
Description of UI_HOMEPAGE_MODE

Controls homepage experience based on IP/headers. Mode: 'internal', 'external', or blank (default)

UI_HOMEPAGE_MATCHING_CIDRS=not declarednot annotated
UI_HOMEPAGE_MODE_HEADER=not declarednot annotated
not declaredv0.25.6
Description of HOMEPAGE_PUBLIC_LINKS_RECIPIENT_INTRO

URL the "What is this link?" affordance on the disabled-homepage view points at. Surfaced to recipients who arrive via a shared link when the secret form itself is gated by auth. Leave blank to hide the link.

DEFAULT_DISABLED_HOMEPAGE_VARIANT=not declaredv0.25.10
Description of DEFAULT_DISABLED_HOMEPAGE_VARIANT

Which landing page the homepage shows when secret creation is gated by auth (auth.required, or UI_HOMEPAGE_MODE=external). Deployment-wide default for the CANONICAL site; per-domain config and the ?variant= URL override still take precedence. One of: closed - quiet "members only" two-tagline page, no sign-in button (default) minimal - small mark + headline + sign-in CTA (one-click SSO when a single provider is the only login method, otherwise links to /signin) v1 - full hero: mark, eyebrow, headline, CTA, trust strip, optional promo Leave blank to use the default (closed).

DEFAULT_CUSTOM_DOMAIN_DISABLED_HOMEPAGE_VARIANT=not declaredv0.26.2
Description of DEFAULT_CUSTOM_DOMAIN_DISABLED_HOMEPAGE_VARIANT

Same as DEFAULT_DISABLED_HOMEPAGE_VARIANT, but the deployment-wide default for CUSTOM DOMAINS whose homepage is private. Kept separate so the canonical and custom-domain defaults stay decoupled: a custom domain does NOT inherit DEFAULT_DISABLED_HOMEPAGE_VARIANT. Per-domain config and the ?variant= URL override still take precedence. Same values (closed / minimal / v1); leave blank to use the default (closed).

HELP_ENABLED=truenot declaredv0.25.5
Description of HELP_ENABLED

Show help modals on secret reveal/receipt pages. Set to 'false' to hide.

#LOGO_URL=not declarednot annotated
Description of LOGO_URL

Deprecated (#3612): use BRAND_LOGO_URL (brand.logo_url) instead. A value set here is still honored as a fallback, with a deprecation warning at boot. Vue component sentinels (e.g. the old DefaultLogo.vue default) are ignored.

#LOGO_ALT=not declarednot annotated
Description of LOGO_ALT

Deprecated (#3612): use BRAND_LOGO_ALT (brand.logo_alt) instead. A value set here is still honored as a fallback, with a deprecation warning at boot.

not declarednot annotated
Description of LOGO_LINK

Click target for the masthead logo (site.interface.ui.header.logo.href). Accepts a relative path or absolute URL. Default when unset: / (home).

#LOGO_SHOW_NAME=not declaredv0.25.0
Description of LOGO_SHOW_NAME

Show the product name (BRAND_PRODUCT_NAME) as a text mark next to the logo icon in the masthead (site.interface.ui.header.logo.show_name). Unset means auto: shown, unless a custom brand logo is configured (custom logos usually embed their own wordmark). Set 'true'/'false' to force either way; the product name stays active in page titles and MFA labels regardless. On requests classified as custom, this setting cannot show the install product name. This prevents the install identity from appearing on a tenant's white-labeled domain (#4241). Migration note (#3612): unset previously meant always-on. If you relied on the site name showing next to a custom LOGO_URL/BRAND_LOGO_URL, set LOGO_SHOW_NAME=true to keep the wordmark.

#LOGO_PROMINENT=falsenot declaredv0.25.3
Description of LOGO_PROMINENT

Render custom logos at a larger size in authenticated views (site.interface.ui.header.logo.prominent). When true, authenticated users see an 80px logo instead of the compact 40px. Useful for rasterized brand assets that need more visual presence alongside the org/domain context switchers. Unauthenticated views always use the prominent treatment (160px) for custom logos regardless of this setting.

#UI_ENABLED=truenot declarednot annotated
Description of UI_ENABLED

Homepage UI toggle. Set to 'false' to show a disabled/API-only landing page. Does not gate non-homepage routes or API endpoints. Default: true.

#HEADER_ENABLED=truenot declarednot annotated
Description of HEADER_ENABLED

Masthead header customization (logo lockup + nav). Set to 'false' to disable. Default: true.

#HEADER_NAV_ENABLED=truenot declarednot annotated
Description of HEADER_NAV_ENABLED

Header navigation links. Set to 'false' to hide the masthead nav while keeping the logo. Default: true.

#UI_CAPABILITIES_SHOW=truetrue each.v0.25.0
Description of UI_CAPABILITIES_SHOW

Per-surface UI capability flags (surface composition, UI-only — NOT endpoint or security gating). Set any to 'false' to hide that affordance. Default: true each.

#UI_CAPABILITIES_RECIPIENT=truenot declaredv0.25.0
#UI_CAPABILITIES_RECEIPT=truenot declaredv0.25.0
#UI_CAPABILITIES_BURN=truenot declaredv0.25.0

Branding / Private-Label

Installation-level brand identity (config `brand:` block). All default to unset so shipped/self-hosted instances stay brand-agnostic — no OTS branding leaks into emails, MFA labels, or the masthead. Per-domain Redis branding overrides these at runtime.

Example lineDefaultSince
#BRAND_PRIMARY_COLOR='#3B82F6'#3B82F6 (neutral blue).v0.26.0
Description of BRAND_PRIMARY_COLOR

Primary brand color (hex). Used in emails and UI accents. Default when unset: #3B82F6 (neutral blue).

#BRAND_PRODUCT_NAME="Secure Links" (neutral).v0.26.0
Description of BRAND_PRODUCT_NAME

Product name in emails, page titles, logo alt text, and (unless BRAND_TOTP_ISSUER is set) MFA authenticator labels. Default when unset: "Secure Links" (neutral).

#BRAND_PRODUCT_DOMAIN=unset.v0.26.0
Description of BRAND_PRODUCT_DOMAIN

Canonical product domain stored in brand settings for per-domain overrides. Not currently consumed by any template or component. Default: unset.

#BRAND_SUPPORT_EMAIL=not declaredv0.26.0
Description of BRAND_SUPPORT_EMAIL

Support address shown in the email footer and available to the frontend via bootstrap. Default: unset (no contact line rendered).

#BRAND_CORNER_STYLE=roundedroundedv0.26.0
Description of BRAND_CORNER_STYLE

Corner style for buttons/cards. One of: rounded (default), square, pill.

#BRAND_FONT_FAMILY=sanssansv0.26.0
Description of BRAND_FONT_FAMILY

Font family. One of: sans (default), serif, mono.

#BRAND_BUTTON_TEXT_LIGHT=truenot declaredv0.26.0
Description of BRAND_BUTTON_TEXT_LIGHT

Use light text on brand-colored buttons. Set to 'false' for dark text (e.g. light brand colors). Default: true.

#BRAND_LOGO_URL=not declaredv0.26.0
Description of BRAND_LOGO_URL

Brand logo URL for the masthead and outbound emails. Never shown on tenant custom domains — those render their own uploaded logo or the neutral mark. No default — unset shows the neutral mark in the masthead and omits the logo from email templates. Emails only emit absolute http(s) URLs; a relative path still works for the masthead but degrades emails to a text-only header. Values ending in .vue (the frontend's component-sentinel convention) are ignored. Set to a full absolute URL (https:// recommended).

#BRAND_LOGO_DARK_URL=not declaredv0.26.3
Description of BRAND_LOGO_DARK_URL

Dark-theme variant of BRAND_LOGO_URL, swapped in by the web UI's masthead when the site theme toggle is dark. Inert unless BRAND_LOGO_URL is also set (it pairs with the light logo, never replaces it). Web UI only — outbound emails always use BRAND_LOGO_URL. Same rules as BRAND_LOGO_URL: never shown on tenant custom domains, .vue values ignored.

#BRAND_LOGO_ALT=not declaredv0.26.0
Description of BRAND_LOGO_ALT

Alt text for the brand logo (accessibility / hover title). Default when unset: an i18n string derived from the product name.

#BRAND_FAVICON_URL=not declaredv0.26.0
Description of BRAND_FAVICON_URL

Favicon URL for the installation. Overrides the default /favicon.ico for all domains that don't have their own per-domain icon. Set to a full https:// URL pointing to a .ico, .png, or .svg image.

#BRAND_APPLE_TOUCH_ICON_URL=not declaredv0.26.0
Description of BRAND_APPLE_TOUCH_ICON_URL

Apple touch icon URL (iOS home-screen / bookmark icon, 180x180 PNG). Overrides the neutral /apple-touch-icon.png default. Full https:// URL.

#BRAND_OG_IMAGE_URL=not declaredv0.26.0
Description of BRAND_OG_IMAGE_URL

Open Graph / Twitter social-card image URL (recommended 1200x630). Must be an absolute URL so social scrapers can fetch it. Resolution: 1. this variable, if set to a URL; 2. else /social-preview.png, but ONLY if the resolved brand pack carries the file — the tracked `default` and example packs do, and a partial pack falls through to `default` for files it omits; 3. else nothing — no og:image/twitter:image tags are emitted at all (never an empty tag, never one pointing at a 404), and twitter:card degrades from `summary_large_image` to `summary`, which renders correctly imageless. Set to `none` (or `off` / `false`) to suppress the card entirely. That is the opt-out to use when you want NO social preview: deleting social-preview.png from your own pack is not sufficient on its own, because a partial pack falls through to the default pack's card. Blank means "unset", not "none". Custom domains NEVER inherit the install's card, regardless of this setting — there is no per-domain social image, so emitting one would put your brand on a customer's shared link.

#BRAND_PACK=not declaredv0.26.0
Description of BRAND_PACK

Pack NAME, resolved across two search roots (first existing wins): 1. etc/branding/<name> — operator space (runtime mounts / confext) 2. public/branding/<name> — vendor space (tracked default + generated packs) e.g. BRAND_PACK=maruhi serves etc/branding/maruhi or public/branding/maruhi. Must be a bare name — path separators are rejected. Named packs are generator output (pnpm run gen:favicons:maruhi), never committed. Unset resolves to the tracked neutral `default` pack; an unknown name falls back to it (and warns).

#BRAND_ASSETS_DIR=not declaredv0.26.0
Description of BRAND_ASSETS_DIR

Explicit filesystem PATH to a brand pack directory, for runtime mounts (e.g. /mnt/brand). Wins over BRAND_PACK when both are set. A missing path falls back to the default pack (and warns). No default.

#BRAND_TOTP_ISSUER=BRAND_PRODUCT_NAME, then OTS.v0.26.0
Description of BRAND_TOTP_ISSUER

Issuer label shown in TOTP/MFA authenticator apps. Used by both the frontend QR code and the server-side Rodauth OTP configuration. Default when unset: BRAND_PRODUCT_NAME, then OTS.

#BRAND_SIGNATURE_NAME=not declaredv0.26.0
Description of BRAND_SIGNATURE_NAME

Sign-off name on the closing line of transactional emails (welcome, password reset, magic link, email-change, org invite, etc.). Kept separate from BRAND_PRODUCT_NAME so you can sign mail with a person or team without renaming the product. Default when unset: "Support Team" (neutral i18n default).

Logging & Diagnostics

Example lineDefaultSince
DEFAULT_LOG_LEVEL=infonot declaredv0.24.0
Description of DEFAULT_LOG_LEVEL

Global default log level (applies to all logger categories). Options: trace, debug, info, warn, error, fatal

ONETIME_DEBUG=falsenot declarednot annotated
#DEBUG_DATABASE=falsenot declaredv0.25.6
Description of DEBUG_DATABASE

Log every Valkey/Redis command issued through Familia (DatabaseLogger middleware). Verbose; intended for local debugging only and hard-blocked in production regardless of this value. This is the canonical name; the former DATABASE_DEBUG / DEBUG_VALKEY / DEBUG_REDIS aliases have been removed.

LOG_HTTP_REQUESTS=truenot declarednot annotated
Description of LOG_HTTP_REQUESTS

HTTP request logging (RequestLogger middleware). Disable when a reverse proxy already logs requests.

LOG_HTTP_REQUESTS_LEVEL=infonot declaredv0.24.0
Description of LOG_HTTP_REQUESTS_LEVEL

Log level for HTTP request lines specifically. Options: trace, debug, info, warn, error, fatal

LOG_HTTP_CAPTURE=minimalnot declaredv0.24.0
Description of LOG_HTTP_CAPTURE

What data to include in each request log entry. Options: minimal (method/path/status/duration), standard (+ request_id, ip), debug (+ params, headers, session_id)

LOG_HTTP_ALLOWED_ERROR_FIELDS=not declaredv0.26.0
Description of LOG_HTTP_ALLOWED_ERROR_FIELDS

Opt-in allowlist of request param/header names allowed to appear in logs or error reports (Sentry) beyond method/path/status/duration/request_id/ip. Comma-separated, e.g. "ttl,recipient_locale,User-Agent". Empty by default: with nothing listed, LOG_HTTP_CAPTURE=debug shows no param/header values and an unhandled exception logs identically to any other request — the query string and request body are never logged. Never list names like password, secret, token, passphrase, api_key, Authorization, or Cookie.

DIAGNOSTICS_ENABLED=falsenot declarednot annotated
#DIAGNOSTICS_ENABLED_IN_TEST=falsenot declaredv0.26.13
Description of DIAGNOSTICS_ENABLED_IN_TEST

Under RACK_ENV=test, DIAGNOSTICS_ENABLED alone is ignored so a locally booted test server cannot report to the Sentry project a dev shell points at. Set this too when a test boot really should report. No effect in any other environment.

SENTRY_DSN_BACKEND=not declarednot annotated
SENTRY_DSN_FRONTEND=not declarednot annotated
SENTRY_DSN_WORKERS=not declaredv0.25.0
SENTRY_VUE_TRACK_COMPONENTS=truenot declarednot annotated
SENTRY_SAMPLE_RATE=not declarednot annotated
SENTRY_MAX_BREADCRUMBS=not declarednot annotated
SENTRY_LOG_ERRORS=truenot declarednot annotated
SENTRY_AUTH_TOKEN=not declaredv0.24.5
Description of SENTRY_AUTH_TOKEN

Source map upload (CI only — used by @sentry/vite-plugin)

SENTRY_ORG=not declaredv0.24.5
SENTRY_PROJECT=not declaredv0.24.5
SENTRY_URL=not declaredv0.24.5
SENTRY_RELEASE=not declaredv0.24.5
#FAMILIA_SAMPLE_RATE=not declaredv0.24.0
Description of FAMILIA_SAMPLE_RATE

Sampling rate for Valkey/Redis command logging by Familia's DatabaseLogger (0.0-1.0). Currently has no effect: the reader gates on a config key ('environment') that is never set, so this var is never consulted and every command is logged whenever command logging is on (DEBUG_DATABASE=1; always blocked in production).

#LOG_AUDIT_SYSLOG=falsenot declaredv0.26.11
Description of LOG_AUDIT_SYSLOG

Ship the colonel audit sink to syslog as well as stdout (logging.yaml audit.syslog.enabled). Every ColonelAuditEvent is emitted on its own `ColonelAudit` log category before it is written to Valkey; by default that rides the console appender. Set to true to ALSO route that one category — and nothing else — to a syslog appender, for operators who want the audit stream shipped separately (SIEM, write-once host, its own retention). Only the exact string "true" enables it. Default: false (off).

#LOG_AUDIT_SYSLOG_FACILITY=local0not declaredv0.26.11
Description of LOG_AUDIT_SYSLOG_FACILITY

Syslog facility NAME for the audit appender (logging.yaml audit.syslog.facility). local0..local7, user, daemon, authpriv, … — resolved to the matching ::Syslog::LOG_* constant; an unrecognised name falls back to `user`. Operators usually give the audit stream its own facility so rsyslog/journald can route it to a dedicated file or forwarder. Only read when LOG_AUDIT_SYSLOG=true. Default: local0.

#LOG_AUDIT_SYSLOG_LEVEL=infoinfo.v0.26.11
Description of LOG_AUDIT_SYSLOG_LEVEL

Appender-level floor for the audit syslog appender (logging.yaml audit.syslog.level). Options: trace, debug, info, warn, error, fatal. Leave at info: the sink emits at info, so anything higher silently drops the whole stream. Does NOT change the sink itself, whose level is pinned in code (ColonelAuditEvent::SINK_LEVEL). Only read when LOG_AUDIT_SYSLOG=true. Default: info.

#LOG_AUDIT_SYSLOG_URL=syslog://localhostsyslog://localhost.v0.26.11
Description of LOG_AUDIT_SYSLOG_URL

Destination for the audit syslog appender, protocol://host[:port] (logging.yaml audit.syslog.url). 'syslog://localhost' writes to the LOCAL syslog daemon through Ruby's bundled `syslog` library and needs nothing extra. 'tcp://loghost:514' / 'udp://loghost:514' ship to a REMOTE syslog server, which needs the third-party `syslog_protocol` gem (plus `net_tcp_client` for tcp://) added to the Gemfile — neither is bundled, and without them the appender is skipped with one boot warning while the audit stream still reaches stdout. Only read when LOG_AUDIT_SYSLOG=true. Default: syslog://localhost.

#LOG_FORMATTER=colornot declaredv0.24.0
Description of LOG_FORMATTER

Log output formatter (logging.yaml formatter). color = human-readable with ANSI colors, timestamps, PID/thread (default); json = structured JSON for production log shipping; default = plain text without colors. CLI commands always use color regardless of this setting.

#LOG_HTTP_SLOW_REQUEST_MS=10001000.v0.24.0
Description of LOG_HTTP_SLOW_REQUEST_MS

Slow-request threshold in milliseconds (logging.yaml http.slow_request_ms). Requests taking longer than this are logged at warn instead of info by the RequestLogger middleware. Only applies while HTTP request logging is on (LOG_HTTP_REQUESTS=true). Default: 1000.

#LOG_LEVEL=not declaredv0.24.0
Description of LOG_LEVEL

Runtime override for the global default log level. Takes precedence over the logging config's default_level (DEFAULT_LOG_LEVEL; config default: warn). Applies to loggers without an explicit per-category level in logging.yaml; ONETIME_DEBUG=true still forces debug. Options: trace, debug, info, warn, error, fatal. No default.

#SENTRY_DSN=not declarednot annotated
Description of SENTRY_DSN

Shared Sentry DSN (diagnostics.sentry.defaults.dsn). Fallback for every peer when the specific SENTRY_DSN_BACKEND / SENTRY_DSN_FRONTEND / SENTRY_DSN_WORKERS is not set. Requires DIAGNOSTICS_ENABLED=true; diagnostics stay off when no DSN is present at all. No default.

#SENTRY_ORG_ID=not declaredv0.25.0
Description of SENTRY_ORG_ID

Sentry organization ID (diagnostics.sentry.defaults.org_id). When set, sentry-ruby enables strict trace continuation: inbound traces are continued only if their sentry-org_id baggage matches, keeping other Sentry orgs' traces out. Self-hosted Sentry must set this explicitly; leave empty to continue all inbound traces. No default.

Job System

Example lineDefaultSince
JOBS_ENABLED=truenot declaredv0.24.0
SNEAKERS_PID_PATH=tmp/pids/sneakers.pidnot declaredv0.24.0
SCHEDULER_PID_PATH=tmp/pids/scheduler.pidnot declaredv0.24.0
WORKER_HEARTBEAT_INTERVAL=600not declaredv0.24.0
#BILLING_WORKER_PREFETCH=5not declaredv0.24.0
Description of BILLING_WORKER_PREFETCH

AMQP prefetch for the billing worker: max unacknowledged messages fetched per consumer from the billing.event.process queue (jobs.workers.billing.prefetch). Count; default 5. Read by `bin/ots worker`, which loads billing workers only when billing is enabled.

#BILLING_WORKER_THREADS=2not declaredv0.24.0
Description of BILLING_WORKER_THREADS

Consumer thread count for the billing worker processing Stripe webhook events from the billing.event.process queue (jobs.workers.billing.threads). Count; default 2. Read by `bin/ots worker`, which loads billing workers only when billing is enabled.

#DNS_CHECK_WORKER_PREFETCH=5not declaredv0.25.0
Description of DNS_CHECK_WORKER_PREFETCH

AMQP prefetch for the DNS record check worker (domain.dns.check queue): how many unacked messages RabbitMQ delivers ahead to each consumer. Read from the environment when the `ots worker` process loads the worker class (job system; see JOBS_ENABLED). Count; default 5.

#DNS_CHECK_WORKER_THREADS=2not declaredv0.25.0
Description of DNS_CHECK_WORKER_THREADS

Concurrent consumer threads for the DNS record check worker (domain.dns.check queue), which fact-finds custom-domain mail DNS records. Read by the `ots worker` process at class load (job system; see JOBS_ENABLED). Count; default 2.

#DOMAIN_VALIDATION_WORKER_PREFETCH=5not declaredv0.25.0
Description of DOMAIN_VALIDATION_WORKER_PREFETCH

AMQP prefetch for the domain validation worker (domain.validation.check queue): unacked messages RabbitMQ delivers ahead per consumer. Read by the `ots worker` process at class load (job system; see JOBS_ENABLED). Count; default 5.

#DOMAIN_VALIDATION_WORKER_THREADS=2not declaredv0.25.0
Description of DOMAIN_VALIDATION_WORKER_THREADS

Concurrent consumer threads for the domain validation worker (domain.validation.check queue), which sets verification status. Conservative rollout default; the work is DNS/IO-bound, so the code notes 8-16 threads would be safe. Read by the `ots worker` process (job system; see JOBS_ENABLED). Count; default 2.

#FAVICON_FETCH_WORKER_PREFETCH=5not declaredv0.26.0
Description of FAVICON_FETCH_WORKER_PREFETCH

AMQP prefetch for the favicon fetch worker (domain.favicon.fetch queue): unacked messages RabbitMQ delivers ahead per consumer. Read by the `ots worker` process at class load (job system; see JOBS_ENABLED). Count; default 5.

#FAVICON_FETCH_WORKER_THREADS=2not declaredv0.26.0
Description of FAVICON_FETCH_WORKER_THREADS

Concurrent consumer threads for the favicon fetch worker (domain.favicon.fetch queue), which pulls a custom domain's favicon from the live site (#3780). Read by the `ots worker` process at class load (job system; see JOBS_ENABLED). Count; default 2.

#SESSION_SWEEP_WORKER_PREFETCH=5not declaredv0.26.1
Description of SESSION_SWEEP_WORKER_PREFETCH

AMQP prefetch for the session revocation sweep worker (session.revoke.sweep queue): unacked messages RabbitMQ delivers ahead per consumer. Read by the `ots worker` process at class load (job system; see JOBS_ENABLED). Count; default 5.

#SESSION_SWEEP_WORKER_THREADS=2not declaredv0.26.1
Description of SESSION_SWEEP_WORKER_THREADS

Concurrent consumer threads for the session revocation sweep worker (session.revoke.sweep queue), which revokes a customer's other sessions after a password change (#3810). Read by the `ots worker` process at class load (job system; see JOBS_ENABLED). Count; default 2.

#EMAIL_WORKER_PREFETCH=10not declaredv0.24.0
Description of EMAIL_WORKER_PREFETCH

AMQP prefetch for the email delivery worker (email.message.send queue): unacked messages RabbitMQ delivers ahead per consumer. The worker reads this env var directly at class load; the mirrored jobs.workers.email.prefetch YAML default is not consumed by code. Count; default 10.

#EMAIL_WORKER_THREADS=4not declaredv0.24.0
Description of EMAIL_WORKER_THREADS

Concurrent consumer threads for the email delivery worker (email.message.send queue). The worker reads this env var directly at class load; the mirrored jobs.workers.email.threads YAML default is not consumed by code. Count; default 4.

#NOTIFICATION_WORKER_PREFETCH=5not declaredv0.24.0
Description of NOTIFICATION_WORKER_PREFETCH

AMQP prefetch for the notification worker (notifications.alert.push queue): unacked messages RabbitMQ delivers ahead per consumer. The worker reads this env var directly (effective default 5); the jobs.workers.notifications YAML default of 10 is not consumed by code. Count; default 5.

#NOTIFICATION_WORKER_THREADS=2not declaredv0.24.0
Description of NOTIFICATION_WORKER_THREADS

Concurrent consumer threads for the notification worker (notifications.alert.push queue), which dispatches secret viewed/burned alerts. Read directly by the `ots worker` process; the mirrored jobs.workers.notifications.threads YAML default is not consumed by code. Count; default 2.

#TRANSIENT_WORKER_PREFETCH=5not declaredv0.24.0
Description of TRANSIENT_WORKER_PREFETCH

AMQP prefetch for the transient worker (system.transient queue): unacked messages RabbitMQ delivers ahead per consumer. Read by the `ots worker` process at class load (job system; see JOBS_ENABLED). Count; default 5.

#TRANSIENT_WORKER_THREADS=2not declaredv0.24.0
Description of TRANSIENT_WORKER_THREADS

Concurrent consumer threads for the transient worker (system.transient queue), handling ephemeral fire-and-forget tasks like queue ping tests (`ots queue ping`). Read by the `ots worker` process (job system; see JOBS_ENABLED). Count; default 2.

#JOBS_FALLBACK_SYNC=truenot declaredv0.24.0
Description of JOBS_FALLBACK_SYNC

Fall back to synchronous email delivery when RabbitMQ is unavailable (jobs.fallback_to_sync). Default: true; set to 'false' to disable. Note: the key is currently only recorded in config — delivery code chooses its fallback per call (default :async_thread), so this setting is not read at runtime.

#JOBS_SCHEDULER_ENABLED=falsenot declaredv0.24.0
Description of JOBS_SCHEDULER_ENABLED

Scheduler daemon flag (jobs.scheduler.enabled). The docker compose full stack documents setting this to 'true' for its `ots scheduler` service, but no Ruby code currently reads this config path — the scheduler daemon runs whichever jobs enable their own JOBS_*_ENABLED flags. Default: false. Note: the per-job flags are config-file-only (no ENV interpolation, deliberate per #3775) — e.g. jobs.plan_cache_refresh.enabled and jobs.maintenance.entitlement_materialize.enabled in etc/config.yaml. See docs/runbooks/entitlement-rematerialization.md.

#RABBITMQ_CA_CERTIFICATES=not declaredv0.24.0
Description of RABBITMQ_CA_CERTIFICATES

Path to a custom CA certificate file for verifying the RabbitMQ server's TLS certificate. Only read when the rabbitmq_url scheme is amqps://; when unset, the system CA bundle is used (managed services like CloudAMQP need no custom cert). Passed to Bunny/Sneakers as tls_ca_certificates.

#RABBITMQ_CHANNEL_POOL_SIZE=5not declaredv0.24.0
Description of RABBITMQ_CHANNEL_POOL_SIZE

Publisher-side Bunny channel pool size per web (Puma) process (jobs.channel_pool_size). Channels are checked out from this pool for thread-safe publishing. Count; default 5. Only used when JOBS_ENABLED=true; Sneakers workers manage their own consumer connections and skip this pool.

#RABBITMQ_MANAGEMENT_URL=http://localhost:15672not declaredv0.24.0
Description of RABBITMQ_MANAGEMENT_URL

Base URL of the RabbitMQ Management HTTP API used by the `ots queue` CLI (init, status) to create the vhost, set permissions, and apply DLQ policies. A jobs.rabbitmq_management_url config key wins over this env var when present; credentials come from the user:password in RABBITMQ_URL. Default: http://localhost:15672.

#RABBITMQ_VERIFY_PEER=truenot declaredv0.24.0
Description of RABBITMQ_VERIFY_PEER

Whether to verify the RabbitMQ server's TLS certificate on amqps:// connections. Default: true. Set to 'false' to skip verification for local dev with self-signed certs; only the exact string 'true' enables verification. Ignored for plain amqp:// URLs.

#RABBITMQ_VHOST=not declaredv0.24.0
Description of RABBITMQ_VHOST

Overrides the vhost for Sneakers worker processes (`ots worker run`) without editing the AMQP URL — useful for debugging or temporarily running workers against a different vhost. Applied only when set; otherwise the vhost comes from the path component of RABBITMQ_URL. Does not affect the web publisher connection.

Server & Worker Tuning

Example lineDefaultSince
#MAX_THREADS=16not declaredv0.24.0
Description of MAX_THREADS

[deprecated] Compatibility alias for PUMA_MAX_THREADS, which takes precedence when both are set. Maximum Puma threads per worker process (count). Read by etc/puma.rb only when PUMA_MAX_THREADS is unset. Default: 16.

#MIN_THREADS=not declaredv0.24.0
Description of MIN_THREADS

[deprecated] Compatibility alias for PUMA_MIN_THREADS, which takes precedence when both are set. Minimum Puma threads per worker process (count). Default when neither is set: 1 when RACK_ENV=production, 0 in development.

#MAX_WORKER_REQUESTS=1000not declaredv0.24.0
Description of MAX_WORKER_REQUESTS

Restart each Puma worker after N requests to curb memory growth (count, production cluster mode). Currently inert: the worker_max_requests line ships commented out in etc/examples/puma.example.rb — uncomment it in your etc/puma.rb copy to enable. Default when enabled: 1000.

#PUMA_MAX_THREADS=1616.v0.24.0
Description of PUMA_MAX_THREADS

Maximum Puma threads per worker process (count). Read by etc/puma.rb (copied from etc/examples/puma.example.rb in the Docker image); takes precedence over the legacy MAX_THREADS alias. Default: 16.

#PUMA_MIN_THREADS=11 when RACK_ENV=production (the Docker default), 0 in development.v0.24.0
Description of PUMA_MIN_THREADS

Minimum Puma threads per worker process (count). Read by etc/puma.rb; takes precedence over the legacy MIN_THREADS alias. Default: 1 when RACK_ENV=production (the Docker default), 0 in development.

#PUMA_WORKERS=2not declaredv0.24.0
Description of PUMA_WORKERS

Number of Puma worker processes (count). 0 = single-process mode (no cluster, no fork hooks); >0 enables cluster mode with preload_app!. Takes precedence over the legacy WEB_CONCURRENCY alias. Default: 2 when RACK_ENV=production (the Docker default), 0 in development.

#WEB_CONCURRENCY=not declaredv0.24.0
Description of WEB_CONCURRENCY

[deprecated] Compatibility alias for PUMA_WORKERS, which takes precedence when both are set. Number of Puma worker processes (count); >0 enables cluster mode. Default when neither is set: 2 in production, 0 in development.

#PORT=3000not declarednot annotated
Description of PORT

TCP port the Puma web server binds (tcp://0.0.0.0:PORT in etc/puma.rb). Also probed by the Docker healthcheck and `ots status`. Default: 3000; bin/dev defaults it to 7143 for local development (an explicit PORT or its --port flag still wins).

#OTS_IMAGE_TAG=v0.26.14not declaredv0.26.0
Description of OTS_IMAGE_TAG

App image tag used by docker/compose/*.yml (not read by app code). Selects the onetimesecret/onetimesecret image version for the app, worker-email, and scheduler services. Defaults to a pinned release so a fresh `docker compose up` is reproducible; set to 'latest' or any published tag to override.

Compatibility

Example lineDefaultSince
DEPRECATED_CONFIG_MODE=strictstrictv0.25.5
Description of DEPRECATED_CONFIG_MODE

How the boot process responds when a deprecated config key or env var is detected (e.g. UI_HOMEPAGE_TRUSTED_PROXY_DEPTH, site.domains). strict (default): raise an error and refuse to start warn: log a migration message and continue silent: ignore Soft deprecations whose legacy values still work via a fallback (e.g. SITE_NAME/LOGO_URL/LOGO_ALT, #3612) only ever log — even under strict — so a working install keeps booting; silent suppresses them too.

Experimental Features

Opt-in, not-yet-stable feature flags (config: experimental.*). Each is safe to disable at any time — rollback is a config flip. No experimental flags are currently defined: the Colonel admin-console cutover flag was retired once the rebuilt console became the sole admin frontend (docs/specs/colonel-ui/50-cutover-hardening.md). This section is kept as an extension point for future flags.