TRUSTED_PROXY_ENABLED=false | false | v0.25.5 |
Description of TRUSTED_PROXY_ENABLEDTurns 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=filter | not declared | v0.25.5 |
Description of TRUSTED_PROXY_MODEHow 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. |
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. | v0.25.5 |
Description of TRUSTED_PROXY_HEADERdepth 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 declared | v0.25.5 |
Description of TRUSTED_PROXY_CIDRSfilter 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=1 | not declared | v0.25.5 |
Description of TRUSTED_PROXY_DEPTHdepth 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. |
| not declared | v0.26.5 |
Description of GEO_HEADEROptional 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 declared | v0.26.5 |
Description of GEO_DB_PATHOptional 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=false | not declared | v0.26.5 |
Description of SECRET_ACTIVITY_GEO_COUNTRY_ENABLEDCountry 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=false | not declared | v0.26.2 |
Description of ASSUME_HTTPSAssume 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=false | not declared | v0.26.15 |
Description of PUBLIC_HOST_REWRITEPublic 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= | Unset | v0.26.5 |
Description of ADMIN_ALLOWED_HOSTSAdmin (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 declared | v0.26.0 |
Description of ADMIN_ALLOWED_CIDRSAdmin (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 declared | v0.26.12 |
Description of RODAUTH_ADMIN_URLBase 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=true | not declared | v0.26.11 |
Description of COLONEL_ELEVATION_ENABLEDStep-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=600 | not declared | v0.26.11 |
Description of COLONEL_ELEVATION_WINDOWWindow length in seconds (default 600 = 10 minutes). |
#COLONEL_ELEVATION_REAUTH_GRACE=0 | not declared | v0.26.11 |
Description of COLONEL_ELEVATION_REAUTH_GRACESeconds 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=true | not declared | v0.26.11 |
Description of COLONEL_RATE_LIMIT_ENABLEDRate 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=true | not declared | v0.26.11 |
Description of COLONEL_ELEVATION_RATE_LIMIT_ENABLEDStep-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=5 | not declared | v0.26.11 |
#COLONEL_ELEVATION_RATE_WINDOW=900 | not declared | v0.26.11 |
#COLONEL_ELEVATION_LOCKOUT=900 | not declared | v0.26.11 |
#COLONEL_MUTATION_RATE_LIMIT_ENABLED=true | not declared | v0.26.11 |
Description of COLONEL_MUTATION_RATE_LIMIT_ENABLEDEvery 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=120 | not declared | v0.26.11 |
#COLONEL_MUTATION_RATE_WINDOW=300 | not declared | v0.26.11 |
#COLONEL_MUTATION_LOCKOUT=300 | not declared | v0.26.11 |
#COLONEL_DESTRUCTIVE_RATE_LIMIT_ENABLED=true | not declared | v0.26.11 |
Description of COLONEL_DESTRUCTIVE_RATE_LIMIT_ENABLEDTIER 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=10 | not declared | v0.26.11 |
#COLONEL_DESTRUCTIVE_RATE_WINDOW=300 | not declared | v0.26.11 |
#COLONEL_DESTRUCTIVE_LOCKOUT=900 | not declared | v0.26.11 |
#COLONEL_HANDLE_RESOLVE_RATE_LIMIT_ENABLED=true | not declared | v0.26.11 |
Description of COLONEL_HANDLE_RESOLVE_RATE_LIMIT_ENABLEDThe 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=60 | not declared | v0.26.11 |
#COLONEL_HANDLE_RESOLVE_RATE_WINDOW=300 | not declared | v0.26.11 |
#COLONEL_HANDLE_RESOLVE_LOCKOUT=300 | not declared | v0.26.11 |
#ADMIN_SESSION_LIFETIME_ENABLED=true | not declared | v0.26.11 |
Description of ADMIN_SESSION_LIFETIME_ENABLEDSession 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=3600 | not declared | v0.26.11 |
#ADMIN_SESSION_ABSOLUTE_TIMEOUT=43200 | not declared | v0.26.11 |