Skip to content

Forward-auth with nginx

ShinyHub can trust authentication performed by an upstream reverse proxy. This guide shows how to wire nginx's auth_request module so that an external auth service authenticates every request before it reaches ShinyHub.

How it works

Browser --> nginx (TLS, auth_request) --> ShinyHub :8080
                       |
                       v
                Auth service
                (LDAP, SAML, ...)
  1. nginx intercepts the browser request and issues a subrequest to the auth service via auth_request.
  2. The auth service returns 2xx (allow) or 4xx/5xx (deny), and sets response headers such as X-Forwarded-User, X-Forwarded-Email, and X-Forwarded-Groups.
  3. nginx captures those headers with auth_request_set and forwards them to ShinyHub.
  4. ShinyHub sees that the request arrived from a trusted peer IP (loopback in a co-located setup) and trusts the headers.

nginx.conf

server {
    listen 443 ssl;
    server_name shiny.example.com apps.example.com;

    # TLS configuration omitted for brevity.

    # Allow large deploy bundles (match storage.max_bundle_mb in shinyhub.yaml).
    client_max_body_size 256m;

    location / {
        # Authenticate every request via the auth service subrequest.
        auth_request /_auth;

        # Capture the identity headers set by the auth service response.
        auth_request_set $auth_user   $upstream_http_x_forwarded_user;
        auth_request_set $auth_email  $upstream_http_x_forwarded_email;
        auth_request_set $auth_groups $upstream_http_x_forwarded_groups;

        proxy_pass http://127.0.0.1:8080;

        # Forward the identity headers to ShinyHub.
        proxy_set_header X-Forwarded-User   $auth_user;
        proxy_set_header X-Forwarded-Email  $auth_email;
        proxy_set_header X-Forwarded-Groups $auth_groups;
        # Load this value from a root-readable secrets include; never commit it.
        proxy_set_header X-ShinyHub-Forward-Auth-Secret "replace-with-the-same-random-secret-as-shinyhub";

        # Standard proxy headers.
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Required for SSE log streaming.
        proxy_buffering       off;
        proxy_read_timeout    3600s;
        proxy_http_version    1.1;
        proxy_set_header Connection "";
    }

    # Internal-only auth subrequest location.
    location = /_auth {
        internal;
        proxy_pass              http://auth-service:9091/api/verify;
        proxy_pass_request_body off;
        proxy_set_header        Content-Length "";
        proxy_set_header        X-Original-URI $request_uri;
    }
}

Replace http://auth-service:9091/api/verify with the verify endpoint of your auth service (for example Authelia at http://authelia:9091/api/verify, or oauth2-proxy at http://oauth2-proxy:4180/oauth2/auth).

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 nginx's address to server.trusted_proxies:

server:
  base_url: https://shiny.example.com
  app_origin: https://apps.example.com
  trusted_proxies:
    - 127.0.0.0/8   # loopback (nginx 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     # must match proxy_set_header names 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

The default role is now viewer; older releases defaulted to developer. Explicitly configured roles still apply. Set default_role: developer only if all authenticated users should be able to deploy. Group reconciliation also uses this default when an elevated mapping no longer applies.

Keep the proxy secret out of debug logs, and protect the nginx secrets include and shinyhub.yaml with owner-only permissions. Use a private environment file for systemd rather than inline unit secrets; see private service configuration. Move the existing root secret to secret_file; generating a replacement requires the rotation procedure.

Generate the shared value once and provide it independently to both processes:

export SHINYHUB_FORWARD_AUTH_SHARED_SECRET=$(openssl rand -hex 32)

After restarting both services, sign in through nginx in a browser and open the following URL, not the direct ShinyHub listener, so the check covers auth_request, forwarded 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.

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 (nginx and ShinyHub on the same host) the loopback default (127.0.0.0/8, ::1/128) is sufficient. If ShinyHub runs on a different host, add nginx's outbound IP 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 in admin_groups, the role is promoted to admin. Subsequent logins update group-based promotions but do not downgrade manually-set roles.

SSE log streaming. ShinyHub streams app logs over Server-Sent Events. Set proxy_buffering off and a long proxy_read_timeout (shown above) so long-lived SSE connections are not dropped by nginx.

Large deploy uploads. Match client_max_body_size in nginx to storage.max_bundle_mb in ShinyHub (default 128 MB). Adding multipart overhead, 256 MB is a safe ceiling for a 128 MB bundle.