Status: measured 2026-08-01 by standing up both candidate architectures as real processes and driving them side by side. Not derived from design intent.
Who this is for: anyone putting a Signet instance behind a proxy at someone else's domain — e.g. serving the instance at https://tenant.example.com/_auth/* while it also has its own hostname. The Kapable "mashup" app class is the first consumer.
Every rule below is silent when violated. None produces a startup error. Most produce a working-looking instance that fails later, in production, in a way that looks like someone else's bug. That is why they are written down rather than left to be rediscovered.
The five hard requirements
1. base_url must be the OUTER origin — the tenant's, not the instance's
Set [server] base_url = "https://tenant.example.com". Not https://{app}.{org}.kapable.run.
base_url is the instance's identity, not a hint. It is load-bearing on four axes:
| axis | consequence of getting it wrong |
|---|---|
| canonical-host enforcement | every request whose Host differs gets 308-redirected |
Secure cookie attribute | emitted iff base_url starts with https:// — an http:// value silently drops Secure |
| trusted-origin set | base_url is always element 0 of trusted_origins |
| OIDC issuer | issuer = base_url + base_path, published in discovery |
There is deliberately no use_secure_cookies escape hatch. An http:// public base_url behind a TLS-terminating edge is not a supported alternate configuration: it also publishes the wrong OIDC issuer, installs the wrong trusted origin, and changes canonical-host/scheme behavior. Set the outer browser origin here, including https://, and let the one derived value keep those security surfaces coherent. trust_forwarded_headers below narrows callback resolution to the edge-reported scheme; it does not replace the instance's public identity.
⚠ base_url accepts a path and a trailing slash and the issuer does not round-trip. The loader's two-parser check compares only origins, so https://tenant.example.com/ is accepted and publishes an issuer containing a double slash. Give it a bare origin: scheme, host, optional port, nothing else.
2. Forward Host UNCHANGED — this is hard, not advisory
enforce_canonical_host cannot be turned off. There is no TOML key and no env var for it: base_url is the identity, so enforcement is not a deployment option.
If your proxy rewrites Host to the instance's internal name, every request 308s. Exemptions are exactly /health and /__health, so a health check will pass while nothing else works — a green monitor over a dead surface.
3. OVERWRITE X-Forwarded-For — never append
The rate limiter derives the client identity from the first X-Forwarded-For entry. If your proxy appends to a client-supplied header, the client chooses its own rate-limit bucket: full sign-in limit evasion, plus quota exhaustion against a forged victim address.
The trust boundary belongs at the edge. Signet cannot know how many hops precede it or which are trustworthy — that is deployment knowledge. Your proxy is the only component that knows it is the first hop, so it must state the answer rather than pass the client's claim through.
# correct — the proxy replaces whatever arrived
X-Forwarded-For: <connecting peer address>
# WRONG — the client's value survives in first position
X-Forwarded-For: <client-supplied>, <connecting peer address>4. Trust the forwarded scheme only behind an overwrite boundary
Signet ignores X-Forwarded-Proto by default. That keeps callback redirects safe when a client can reach the process directly or supply its own proxy headers, but conservatively requires a callback to be safe under both HTTP and HTTPS.
To make callback resolution agree exactly with the browser in a proxy-only deployment, set:
[server]
trust_forwarded_headers = trueThis setting is an operator assertion, not an auto-detection switch. Before enabling it, the edge MUST satisfy all of this contract:
- Signet is not reachable by clients except through that trusted edge.
- The edge deletes every inbound
ForwardedandX-Forwarded-*header. - The edge writes exactly one lowercase
X-Forwarded-Proto: httporX-Forwarded-Proto: httpsvalue from the connection it terminated.
Do not append, preserve, or comma-join this header. Signet believes only one exact lowercase value; a missing, repeated, comma-joined, differently-cased, or otherwise invalid value falls back to the safe both-schemes check. The standard Forwarded header is deliberately ignored, so there is only one parser and one trust path.
Leaving this setting false is always safe. Enabling it while Signet remains directly reachable lets a client lie about the browser's scheme and can make a scheme-dependent callback resolve differently at the security fence and in the browser.
5. Forward ONLY the reserved prefix — never /*
The plugin/auth routes are nested under base_path. The instance operator API (/admin/v1/*) is mounted on the OUTER router, deliberately outside that nest.
- Forwarding only
/_auth/*from the tenant domain means the admin API is unreachable from that domain — a property you get for free, and want. - Forwarding
/*publishes the whole instance admin API on your customer's public domain, guarded only by possession of a live platform credential.
The platform must reach /admin/v1/* on the instance's own hostname, not through the tenant domain. Design for that from the start; discovering it later looks like an erasure call 404ing in production.
Set base_path, do not rewrite paths in the proxy
Do: [server] base_path = "/_auth" on the instance, and forward /_auth/* verbatim. Do not: leave base_path at its default and have the proxy rewrite /_auth/* → /api/auth/*.
Both work in the narrow sense that requests succeed. The rewrite is wrong because the instance's self-knowledge then diverges from its public reality:
base_path="/_auth", no rewrite | default + proxy rewrite | |
|---|---|---|
issuer | https://tenant.example.com/_auth ✅ | …/api/auth ❌ |
authorization_endpoint | /_auth/oauth2/authorize ✅ | /api/auth/oauth2/authorize ❌ |
jwks_uri, OpenAPI servers[0] | /_auth ✅ | /api/auth ❌ |
Under the rewrite the instance publishes https://tenant.example.com/api/auth as its issuer — a prefix inside the tenant application's URL space, which you never reserved. RFC 8414 §3.3 calls a document naming an issuer that does not match the URL it came from a mix-up condition.
⚠ Measured cost nobody anticipated: under the rewrite the instance answers on both prefixes through the outer domain — /api/auth/* passes through unrewritten and is served. So the proxy would have to block /api/auth/* as well as rewrite /_auth/* — more edge logic than the alternative, to reach a worse result than one TOML line.
What base_path may be
A leading /, then one or more segments of [A-Za-z0-9._~-] (RFC 3986 unreserved), no trailing slash, and no segment that is . or ...
This is an allowlist on purpose. Axum's Router::nest rejects or mis-mounts a value across at least four independent mechanisms, and {capture} segments are treated as match-anything — so base_path = "/_auth/{x}" silently wildcard-mounts the entire auth API while the issuer publishes literal braces. A denylist cannot track that set; the next axum release moves the boundary.
⚠ /:tenant — the axum-0.7 / Express / Gin parameter spelling — is refused. It is the shape someone writes by reflex, and it used to panic at boot.
What you do NOT need to worry about
- Cookie scoping. The session cookie is set with
Path=/, not withbase_path, so it is visible at/_auth/*. There is no hardcodedDomainattribute. The anticipated "cookie invisible under the public prefix" failure does not exist.
Provisioning
Set the instance's base path to /_auth and add https://tenant.example.com to its trusted origins when provisioning it.
⚠ trusted_origins is settable at provision time and NOT mutable afterwards. There is no config-write route on the admin API, and AuthConfig is built once and cloned. Attaching a custom domain to a live instance today means an out-of-band config rewrite plus a container restart — and because base_url is an identity, changing it also changes the issuer and the canonical host. Treat it as a re-provision, not a config edit.
Verify a deployment, at the surface
H=https://tenant.example.com
# 1. the API is where you think it is
curl -s -o /dev/null -w '%{http_code}\n' $H/_auth/get-session # expect 200
# 2. the issuer round-trips — no double slash, no /api/auth on a tenant domain
curl -s $H/_auth/.well-known/openid-configuration | grep -o '"issuer":"[^"]*"'
# 3. the admin API is NOT exposed on the tenant domain
curl -s -o /dev/null -w '%{http_code}\n' $H/admin/v1/config # expect 404
# 4. rate limiting is shared, not per-surface — spend the JSON quota, then hit the page
for i in 1 2 3 4; do curl -s -o /dev/null -X POST -H 'content-type: application/json' \
-d '{"email":"nobody@example.invalid","password":"wrong"}' $H/_auth/sign-in/email; done
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
-H 'content-type: application/x-www-form-urlencoded' \
-d 'email=nobody@example.invalid&password=wrong' $H/login # expect 429⚠ Do not trust restart-window probes as deployment evidence. They run inside the deploy's restart window, so a fresh deploy routinely reports 502 failures that are pure transients — poll to stable first (three consecutive get-session 200s is enough). A probe can also return PASS on a 502, so a red restart receipt is not evidence of a bad deploy by itself.