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¶
- nginx intercepts the browser request and issues a subrequest to the auth
service via
auth_request. - The auth service returns
2xx(allow) or4xx/5xx(deny), and sets response headers such asX-Forwarded-User,X-Forwarded-Email, andX-Forwarded-Groups. - nginx captures those headers with
auth_request_setand forwards them to ShinyHub. - 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:
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:
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.