Forward-auth with Caddy¶
ShinyHub can trust authentication performed by an upstream reverse proxy. When
auth.forward_auth is enabled, ShinyHub reads the authenticated username (and
optionally email and group memberships) from HTTP request headers set by the
proxy. This lets sites that already use LDAP, SAML, Kerberos, mTLS client
certificates, or any other authentication mechanism integrate without ShinyHub
needing to implement those protocols itself.
How it works¶
- Caddy receives the browser request and calls an auth service via
forward_auth. - The auth service returns
2xxwhen the user is authenticated, setting response headers such asX-Forwarded-Userand optionallyX-Forwarded-EmailandX-Forwarded-Groups. - Caddy copies those headers onto the upstream request and proxies it to ShinyHub.
- ShinyHub sees that the request arrived from a trusted peer IP (loopback in a co-located setup) and trusts the headers. It looks up or auto-provisions the user account and issues a session.
Caddyfile¶
{
# Runtime logging, including reverse_proxy diagnostics.
log default {
level INFO
format filter {
wrap json
fields {
request>headers delete
headers delete
# Expanded configuration can contain header_up credentials.
http delete
tls delete
config delete
}
}
}
}
shiny.example.com, apps.example.com {
route {
# Never accept identity or the proxy credential from the browser.
request_header -X-Forwarded-User
request_header -X-Forwarded-Email
request_header -X-Forwarded-Groups
request_header -X-ShinyHub-Forward-Auth-Secret
# Step 1: authenticate via your auth service.
forward_auth auth-service:9091 {
uri /api/verify
copy_headers X-Forwarded-User X-Forwarded-Email X-Forwarded-Groups
}
# Step 2: proxy HTTP and WebSockets; disable buffering for SSE.
reverse_proxy localhost:8080 {
flush_interval -1
# Supply this through Caddy's environment, not source control.
header_up X-ShinyHub-Forward-Auth-Secret {$SHINYHUB_FORWARD_AUTH_SHARED_SECRET}
}
}
}
Adjust auth-service:9091 and /api/verify to match your auth service (for
example Authelia at authelia:9091/api/verify, or oauth2-proxy at
oauth2-proxy:4180/oauth2/auth).
The route block preserves the order: remove incoming identity headers,
authenticate, then proxy. If you configure additional or differently named
identity headers, update both the removal and copying lists. See Caddy's
forward_auth
and route references.
Protect proxy credentials in logs¶
Keep global debug, server trace, and log_credentials disabled in
production. Reverse-proxy debug messages can include the upstream request,
including the custom forward-auth shared-secret header. The global log default
filter above removes request and response header maps and structured
configuration fields from runtime logs, including the HTTP/TLS configuration
logged at debug startup. Changing only a site's access-log format does not
filter runtime diagnostics.
If you have other named runtime or access loggers, apply the same filter to
each destination that records requests. See Caddy's
global logging options
and filter encoder.
For file logging, explicitly restrict permissions in each logger's output:
Caddy's file writer defaults to 0600; verify existing files, rotated copies,
journals, and log collectors too. Changing the writer mode requires a restart
or a new output filename, and does not secure existing copies. In a test
environment, send a synthetic marker as the proxy credential and check every
log destination before using real credentials. Filters do not redact secrets
in arbitrary message text, URLs, or saved configurations. If a real credential
was logged, restrict the copies and rotate it; redaction only protects future
entries.
WebSockets (Shiny reactivity)¶
Shiny drives every interaction (button clicks, tab switches, reactive updates)
over a WebSocket. The initial page HTML and static assets load over plain
HTTP, so a broken WebSocket looks deceptive: the app renders and static requests
return 200, but every interaction fails with "Shiny disconnected" because
the reactive channel never opens. Python Shiny uses a raw WebSocket with no
polling fallback, so a failed upgrade disconnects immediately and completely.
Caddy v2's reverse_proxy tunnels WebSockets automatically, so the minimal
config above already works. The upgrade breaks only when something in the chain
interferes with it. If interactions disconnect, check these in order:
forward_authruns on the WebSocket upgrade too. Caddy issues the auth subrequest for every request, including the WebSocket handshake. If the auth service answers the handshake with a redirect or401(for example because the session cookie is not carried on the upgrade, or the WebSocket path is not treated as already-authenticated), Caddy never proxies the upgrade to ShinyHub and the interaction fails, even though ordinary GETs succeed. Make sure the auth service authorizes the WebSocket request the same way it authorizes the page that opened it.- Keep HTTP/1.1 to the upstream. A WebSocket
Upgradecannot ride HTTP/2. The default transport is HTTP/1.1, which is correct; do not forcetransport http { versions h2c 2 }or an HTTP/2 upstream for the ShinyHub route. - Do not strip the upgrade headers. A
header_up/request_headerdirective that overwritesConnection, or ahandle/routesplit that sends the app's WebSocket subpath to afile_serveror default handler instead ofreverse_proxy, turns the101into a non-upgrade response. - Watch global timeouts. Short
servers { timeouts { read_timeout ... } }values can close long-lived WebSocket sessions. Support-session WebSockets also have a hard session deadline and close after revocation is detected.
Diagnosing an upgrade failure¶
- Read Caddy's access log for the WebSocket request (the one with
Upgrade: websocket). Status101means it tunneled;200,302,401, or502is the smoking gun and points at one of the causes above. - Use ShinyHub's built-in readiness probe.
GET /app/<slug>/.shinyhub/readyreturns200 {"ready":true}only after at least one WebSocket handshake has completed for that app; it returns503 {"ready":false}(withRetry-After: 1) before the first handshake, and404for an unknown slug. If this probe never reportsready:truewhile users are actively interacting, no WebSocket is reaching the app, which localizes the fault to the proxy hop rather than the app. - Bisect the proxy. Drive the app directly against the ShinyHub port
(
http://<host>:8080/app/<slug>/, bypassing Caddy). If interactions work there but fail through Caddy, the Caddy configuration is the cause.
Headers honored by ShinyHub¶
| Header | Config key | Description |
|---|---|---|
X-Forwarded-User |
user_header |
Username (required). Default header name. |
X-Forwarded-Email |
email_header |
Email address (optional). Accepted by config but not yet used by ShinyHub (reserved). |
X-Forwarded-Groups |
groups_header |
Comma-separated group list. When groups_header is configured the proxy MUST send this header on every request (empty when the user has no groups); the listed groups drive role promotion AND revocation. An absent header is treated as no groups and revokes any group-derived role, so a dropped header demotes the user to the default role. |
X-ShinyHub-Forward-Auth-Secret |
secret_header |
Required proxy credential. Generate at least 32 random characters and configure the same value as shared_secret. ShinyHub strips it before proxying. |
ShinyHub configuration¶
Add auth.forward_auth to your shinyhub.yaml and add Caddy's address to
server.trusted_proxies.
Cross-host requirement. If Caddy and ShinyHub run on DIFFERENT hosts, you MUST add the Caddy host's IP or CIDR to
server.trusted_proxies. The loopback default (127.0.0.0/8,::1/128) only covers the case where both processes run on the same machine. Without the correct entry, ShinyHub silently ignores the forwarded identity headers and users land on the login page with no indication of what went wrong.
server:
base_url: https://shiny.example.com
app_origin: https://apps.example.com
trusted_proxies:
- 127.0.0.0/8 # loopback (Caddy and ShinyHub on the same host)
- ::1/128
auth:
secret_file: /etc/shinyhub/auth.secret # existing root secret; owner-readable 0600
forward_auth:
enabled: true
shared_secret: "replace-with-a-random-32+-character-secret"
user_header: X-Forwarded-User # matches copy_headers above
email_header: X-Forwarded-Email
groups_header: X-Forwarded-Groups # when set, always emit - empty value for users with no groups
admin_groups: ["shinyhub-admins"] # users in this group get admin role
default_role: viewer # deployment rights require an explicit grant
require_groups_header: false # set true to REFUSE (403) any request missing the groups header
When groups_header is set, always emit it - send an empty value for users with
no groups - because ShinyHub treats an absent header as "no groups" and revokes
group-derived roles on that request.
Set require_groups_header: true to REFUSE (403) any forward-auth request that
lacks the groups header instead of treating it as no groups. Use this when your
proxy always sends the header and you want a misconfiguration to fail loudly
rather than silently demote users.
Or with environment variables:
SHINYHUB_FORWARD_AUTH_ENABLED=true
SHINYHUB_FORWARD_AUTH_SHARED_SECRET=replace-with-the-same-random-32+-character-secret
SHINYHUB_FORWARD_AUTH_USER_HEADER=X-Forwarded-User
SHINYHUB_FORWARD_AUTH_EMAIL_HEADER=X-Forwarded-Email
SHINYHUB_FORWARD_AUTH_GROUPS_HEADER=X-Forwarded-Groups
SHINYHUB_FORWARD_AUTH_ADMIN_GROUPS=shinyhub-admins
SHINYHUB_FORWARD_AUTH_DEFAULT_ROLE=viewer
SHINYHUB_FORWARD_AUTH_REQUIRE_GROUPS_HEADER=false
Generate the shared value once and provide it independently to both processes:
For systemd deployments, load environment values from a private file rather
than inline Environment= entries in the unit. Protect shinyhub.yaml too:
the example contains the proxy shared secret. See
private service configuration.
Use the existing root secret when moving it to secret_file; replacing it
requires the rotation procedure.
The default role is now viewer. Older releases defaulted to developer;
explicitly configured roles still apply. If deployment rights for all SSO
users are intentional, set default_role: developer explicitly. Group-based
role reconciliation also uses this default when an elevated mapping no longer
applies, so check existing users during an upgrade.
After restarting both services, sign in through Caddy in a browser and open the
following URL, not the direct ShinyHub listener, so the check covers
forward_auth, copied identity headers, and the proxy credential together:
For command-line verification, send the SSO session cookie or credential required by your auth service.
If the identity request returns 403, inspect the ShinyHub log. A missing or
incorrect proxy credential produces a rate-limited warning naming the proxy IP
and configured secret_header; secret values are never logged.
Support sessions: DNS, TLS, and preflight¶
This section describes the default, isolated-hostname setup. Hosters who trust all deployed app code can instead keep their single-host Caddy/forward-auth configuration and explicitly enable trusted-app support sessions. That choice accepts administrator browser authority exposure to compromised app JavaScript; it still requires HTTPS and configuration preflight.
The two names in the Caddyfile can resolve to the same Caddy address and use the same ShinyHub listener. Create an A/AAAA record for each name, or a DNS alias for the application name pointing to the control name. Browsers must continue to use the distinct names in their URLs; do not redirect the app hostname back to the control hostname or rewrite its Host to the control hostname upstream.
TLS must cover the exact browser-facing DNS names, such as shiny.example.com
and apps.example.com. One certificate with both SANs is sufficient. If your
organization supplies the certificate, add this directive to the existing
two-host Caddy site block, using the paths readable by Caddy:
Otherwise, Caddy can manage certificates for the configured names where the deployment supports its certificate issuance process. A certificate covering only a short name does not also cover that name with a DNS suffix appended. See Caddy's TLS directive.
Keep the server.base_url, server.app_origin, and auth.forward_auth settings
above, and add support_sessions: true under the existing auth mapping:
Configure the auth service to accept both hostnames, including WebSocket
requests on the app hostname. Keep forward_auth on both hosts in this example;
the support cookie and guard take precedence over the forwarded administrator
identity inside ShinyHub's app access checks. Do not remove or rename ShinyHub's
support cookies at the edge, and do not bypass authentication just because a
request includes one. If SSO needs to redirect during launch, it must preserve
the return URL; the launch capability expires after 60 seconds and is single-use.
Before stopping the current service, validate the candidate configurations with each service's environment and file permissions:
shinyhub validate-config --config /etc/shinyhub/shinyhub.yaml
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
After applying them, verify SSO on the control hostname, start a support session
for a test app, and confirm its represented identity and reactive interactions.
End the session and confirm the dashboard still has the administrator identity.
The ShinyHub command checks configuration, not the live DNS/TLS/SSO path. If
ShinyHub exits before /readyz can respond, inspect its stderr or service log;
see preflight and startup diagnostics.
Notes¶
Trust boundary. ShinyHub checks the DIRECT peer IP of the TCP connection,
not the X-Forwarded-For chain. A request is accepted only when the connecting
socket address is in server.trusted_proxies. In a co-located setup (Caddy and
ShinyHub on the same host) the loopback default (127.0.0.0/8, ::1/128) is
sufficient. If ShinyHub listens on a private network interface reachable by
Caddy running on a different host, add that interface's CIDR to
server.trusted_proxies.
Auto-provisioning. When a user header is received from a trusted peer and
no matching account exists, ShinyHub creates one with default_role. If the
user is a member of any group listed in admin_groups, the role is promoted to
admin regardless of default_role. Subsequent authenticated requests re-apply
group mappings, including revoking group-derived roles when memberships change.
Large deploy uploads. ShinyHub accepts bundles up to storage.max_bundle_mb
(default 128 MB). Caddy's default request body limit is high, but if your auth
service enforces a body limit on the forward_auth subrequest make sure it
allows a HEAD-style pass-through for the /api/apps/{slug}/deploy path, or
set a generous limit on the auth service route.