Skip to content

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

Browser --> Caddy (TLS, auth) --> ShinyHub :8080
                 |
                 v
          Auth service
          (LDAP, SAML, ...)
  1. Caddy receives the browser request and calls an auth service via forward_auth.
  2. The auth service returns 2xx when the user is authenticated, setting response headers such as X-Forwarded-User and optionally X-Forwarded-Email and X-Forwarded-Groups.
  3. Caddy copies those headers onto the upstream request and proxies it to ShinyHub.
  4. 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:

output file /var/log/caddy/shinyhub.log {
    mode 0600
}

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:

  1. forward_auth runs 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 or 401 (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.
  2. Keep HTTP/1.1 to the upstream. A WebSocket Upgrade cannot ride HTTP/2. The default transport is HTTP/1.1, which is correct; do not force transport http { versions h2c 2 } or an HTTP/2 upstream for the ShinyHub route.
  3. Do not strip the upgrade headers. A header_up / request_header directive that overwrites Connection, or a handle / route split that sends the app's WebSocket subpath to a file_server or default handler instead of reverse_proxy, turns the 101 into a non-upgrade response.
  4. 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). Status 101 means it tunneled; 200, 302, 401, or 502 is the smoking gun and points at one of the causes above.
  • Use ShinyHub's built-in readiness probe. GET /app/<slug>/.shinyhub/ready returns 200 {"ready":true} only after at least one WebSocket handshake has completed for that app; it returns 503 {"ready":false} (with Retry-After: 1) before the first handshake, and 404 for an unknown slug. If this probe never reports ready:true while 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:

export SHINYHUB_FORWARD_AUTH_SHARED_SECRET=$(openssl rand -hex 32)

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:

https://shiny.example.com/api/auth/me

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:

tls /etc/caddy/certs/shinyhub.pem /etc/caddy/certs/shinyhub.key

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:

auth:
  support_sessions: true
  # Keep your existing secret and forward_auth settings here.

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.