Identity Forwarding¶
ShinyHub forwards the authenticated user's identity to every app process it
proxies. For each request, the proxy injects a set of X-Shinyhub-* plain
headers and a short-lived signed JWT (X-Shinyhub-Identity-Token). The plain
headers are a convenience for quick reads. The JWT is the authoritative
artifact for any code that makes access-control decisions. Both are on by
default and can be disabled per-app in
shinyhub.toml.
Trust model¶
This section is the most important one. Read it before writing any app logic that acts on identity.
What you can trust¶
The proxy strips every inbound X-Shinyhub-* header unconditionally on every
request, regardless of what the client or an upstream reverse proxy sends.
Headers that arrive at your app process through the ShinyHub proxy therefore
originated from ShinyHub itself.
What you cannot trust from plain headers alone¶
App processes listen on host-local ports (for example 127.0.0.1:PORT). Any
other process running on the same host can connect directly to that port and
supply arbitrary X-Shinyhub-* headers. The strip only fires in the proxy's
Director. A request that bypasses the proxy never passes through it.
Apps that make security decisions (show/hide data, gate writes, check roles) must verify the JWT, not rely on the plain headers. The plain headers are useful for display or logging where a forgery carries no consequence.
Anonymous requests¶
An absent X-Shinyhub-User header means the visitor is not authenticated.
This happens for public apps accessed by a logged-out user, or for any request
that bypassed the proxy entirely. Do not infer identity from absence; treat
absent headers as anonymous.
WebSocket and session binding¶
For Shiny apps, identity is bound at WebSocket upgrade time. The session that is opened belongs to the user who triggered the upgrade. Subsequent messages on that WebSocket are not re-authenticated per-message; the initial identity check governs the session's lifetime.
Token validity window¶
Tokens are valid for 5 minutes from issuance (the exp claim). A token
remains cryptographically valid for up to 5 minutes even if the user logs out
or their role changes between requests. New HTTP requests immediately carry a
freshly minted token reflecting the current state, so the lag only matters for
long-lived connections that do not issue new requests. Apps that require
sub-5-minute revocation (for example a payment flow) should issue their own
short-lived action tokens after verifying the identity token.
Groups cache lag¶
Group membership is cached server-side per user for up to 30 seconds. An IdP change (group add or remove) can therefore lag by up to 30 seconds before it appears in forwarded headers and tokens.
Key exposure and blast radius¶
SHINYHUB_IDENTITY_KEY is injected into the app process environment as a
plaintext hex string. It is visible in:
docker inspectoutput on the host- ECS task descriptions returned by
ecs:DescribeTasks, even when Secrets Manager routing is enabled for other app env vars - Remote Docker worker hosts
The blast radius of a leaked key is limited to the single app it belongs to: an attacker with the key can only forge or verify identity tokens addressed to that app's audience. Keys are derived from the app's numeric database ID (not its slug), so a deleted-and-recreated app under the same slug receives a fresh key and cannot be reached with the old one.
Headers reference¶
| Header | Value | Notes |
|---|---|---|
X-Shinyhub-User |
Username string | Absent for anonymous visitors |
X-Shinyhub-User-Id |
Decimal user ID | Integer encoded as a string |
X-Shinyhub-Role |
One of viewer, developer, operator, admin |
The user's global platform role |
X-Shinyhub-App-Role |
One of owner, manager, viewer |
The user's capability on THIS app: owner owns it; manager covers platform admins/operators and manager-role members (by membership or group rule); everyone else who passed the access gate is viewer. Absent when the membership lookup was unavailable. Changes propagate within ~30 s (cached) |
X-Shinyhub-Email |
Email address | Present when the IdP asserts one: from the forward-auth email_header, or persisted from an OAuth/OIDC login for native sessions. Absent for local-password accounts and anonymous visitors |
X-Shinyhub-Name |
Display name | The user's friendly name when the IdP asserts one: from the forward-auth name_header, or persisted from an OAuth/OIDC login for native sessions. Absent for local-password accounts and anonymous visitors |
X-Shinyhub-Groups |
Comma-joined sorted group names | Capped at 100 names; group names that contain a comma are omitted from this header (they appear in the JWT claim instead) |
X-Shinyhub-Groups-Truncated |
"true" |
Present only when the group list exceeded 100 and was truncated |
X-Shinyhub-Identity-Token |
Signed HS256 JWT | The authoritative identity artifact; verify before acting on it |
X-Shinyhub-Groups is absent when the user belongs to no groups. It is also
absent when every group name contains a comma (in that case only the JWT claim
carries them). X-Shinyhub-Groups-Truncated is absent when the cap did not
fire.
Email comes from one of two sources. Behind forward-auth SSO it is read
request-scoped from the header named by auth.forward_auth.email_header (e.g.
Authelia's Remote-Email). For native ShinyHub sessions it is the address the
identity provider asserted at OAuth/OIDC login, persisted on the user record and
refreshed on each SSO login. Local username/password accounts have no email
source, so the header (and the helper) return empty for them.
The display name (X-Shinyhub-Name) follows the same rule. Behind forward-auth
it is read request-scoped from the header named by auth.forward_auth.name_header;
for native sessions it is the name the IdP asserted at OAuth/OIDC login, persisted
on the user record and refreshed on each SSO login. Local username/password
accounts carry no IdP-governed name, so the header (and the helper) return empty
for them.
Token reference¶
The identity token is a standard JWT signed with HS256. Its claims are:
| Claim | Type | Value |
|---|---|---|
iss |
string | "shinyhub" |
aud |
string (serialized as a one-element array) | The app slug (also available as SHINYHUB_APP_SLUG in the process env) |
sub |
string | Decimal user ID |
preferred_username |
string | Username |
role |
string | One of viewer, developer, operator, admin |
app_role |
string | One of owner, manager, viewer (see the X-Shinyhub-App-Role header); omitted when the membership lookup was unavailable |
email |
string | The user's email when the IdP asserts one (forward-auth email_header, or persisted from an OAuth/OIDC login); omitted for local-password accounts |
name |
string | The user's display name when the IdP asserts one (forward-auth name_header, or persisted from an OAuth/OIDC login); omitted for local-password accounts |
groups |
array of strings | Sorted group names, capped at 100 (all names, including comma-bearing ones) |
groups_truncated |
bool | true when the list was truncated to 100; absent otherwise |
iat |
NumericDate | Token issue time |
exp |
NumericDate | iat + 5 minutes |
When verifying, check iss == "shinyhub", aud == SHINYHUB_APP_SLUG, the
signature, and exp (allow about 30 seconds of clock leeway).
Client helpers (recommended)¶
Rather than decode the token yourself, use the one-call helper for your
language. Each returns the verified identity or a defined anonymous value
(None / NULL), so your app needs no JWT plumbing and stays testable without
SSO. Both read the injected SHINYHUB_IDENTITY_KEY / SHINYHUB_APP_SLUG
automatically.
The helpers are versioned independently of the server: their versions track
changes to the helper APIs, not the server release train. Any helper release
verifies tokens from any ShinyHub v0.8.6 or later (identity forwarding
shipped in v0.8.6); the token contract is stable across server releases, and
claims a later server added (email, name) are simply empty when an older
server minted the token.
Failure handling is fail-closed but not silent where it matters. Every
verification failure returns the anonymous value - no exception. A token that
is present but rejected (missing or wrong key, audience/issuer mismatch,
expired, clock skew) additionally emits a diagnostic - a Python WARNING on
the "shinyhub_identity" logger, an R warning() - once per distinct reason
per process, because a rejected-but-present token almost always means a
misconfigured deployment, while a genuine anonymous visitor sends no token at
all. Deployments that gate on identity should still self-check at startup
(assert SHINYHUB_IDENTITY_KEY and SHINYHUB_APP_SLUG are set) so
misconfiguration fails fast instead of rendering every user anonymous.
For local development (no proxy, so no token and no injected key), both
helpers honor SHINYHUB_IDENTITY_DEV_USER (plus optional
SHINYHUB_IDENTITY_DEV_GROUPS, ..._DEV_EMAIL, ..._DEV_NAME,
..._DEV_ROLE, default viewer) and return a synthetic identity marked with
a dev claim. The dev identity never activates when SHINYHUB_IDENTITY_KEY
is set, so it cannot mask a real verification failure in a deployment.
Python¶
from shinyhub_identity import current_user
def server(input, output, session):
user = current_user(session.http_conn.headers) # None when anonymous
if user and "platform-admins" in user.groups:
... # gate on the VERIFIED groups
The returned identity exposes user_id, username, role, email, name,
groups, and groups_truncated (with the raw verified claims mapping). email
and name are "" when the IdP asserted none.
R¶
install.packages(c("jose", "sodium"))
remotes::install_github("rvben/shinyhub", subdir = "packaging/r-identity")
library(shinyhubidentity)
server <- function(input, output, session) {
user <- current_user(session) # NULL when anonymous
# user$preferred_username, user$role, user$email, user$name, user$groups
}
Migrating from a hand-rolled per-app JWT fetch? Delete it - the client-side
get_jwt / decode_jwt code and any browser-side token fetch. ShinyHub already
injects and signs the identity server-side; call current_user instead. That
also removes the internal-CA ERR_CERT_AUTHORITY_INVALID failure mode a
client-side fetch hits, and makes the app load-testable (a forged or absent
session is simply anonymous, not an error).
The manual recipes below show what the helpers do under the hood, for languages or frameworks the packages do not cover.
Verifying manually (Python)¶
Install PyJWT:
import os
import jwt # PyJWT
KEY = bytes.fromhex(os.environ["SHINYHUB_IDENTITY_KEY"])
SLUG = os.environ["SHINYHUB_APP_SLUG"]
def current_user(headers) -> dict | None:
"""Verified identity of the request, or None for anonymous."""
token = headers.get("x-shinyhub-identity-token")
if not token:
return None
return jwt.decode(token, KEY, algorithms=["HS256"],
audience=SLUG, issuer="shinyhub", leeway=30)
jwt.decode raises jwt.exceptions.InvalidTokenError (or a subclass) when
the token is missing, expired, has the wrong audience, or fails the signature
check. Treat any exception as unauthenticated.
In Shiny for Python, read request headers inside the server function via
session.http_conn.headers:
from shiny import App, ui, render, session as shiny_session
def server(input, output, session):
user = current_user(dict(session.http_conn.headers))
# user is None for anonymous visitors
A runnable demo is in examples/identity-demo/.
Verifying manually (R)¶
Install the jose and
sodium packages:
library(jose)
library(sodium)
# Decode the hex key injected by ShinyHub into the process environment.
identity_key <- sodium::hex2bin(Sys.getenv("SHINYHUB_IDENTITY_KEY"))
app_slug <- Sys.getenv("SHINYHUB_APP_SLUG")
current_user <- function(session) {
token <- session$request$HTTP_X_SHINYHUB_IDENTITY_TOKEN
if (is.null(token) || token == "") return(NULL)
claims <- tryCatch(
jose::jwt_decode_hmac(token, secret = identity_key),
error = function(e) NULL
)
if (is.null(claims)) return(NULL)
# jose validates signature, exp, and nbf (with a 60-second grace period).
# Manually assert iss and aud, which jose does not check.
if (!identical(claims$iss, "shinyhub")) return(NULL)
if (!identical(claims$aud, app_slug)) return(NULL)
claims
}
Use current_user(session) inside your Shiny server function. It returns
NULL for anonymous visitors or when the token fails any check.
Worked example: per-group UI gating¶
After verifying the token, check the groups claim to gate access to parts of
your app. The following Shiny for Python snippet shows an admins-only panel:
from shiny import App, ui, render, session as shiny_session
def server(input, output, session):
user = current_user(dict(session.http_conn.headers))
groups = user.get("groups", []) if user else []
@output
@render.ui
def admin_panel():
if "platform-admins" not in groups:
return ui.p("Access restricted.")
return ui.div(
ui.h3("Admin controls"),
# ... admin widgets ...
)
app = App(ui.page_fluid(ui.output_ui("admin_panel")), server)
For a complete working app (Python and R, covering anonymous/viewer/admin
flows, plus the R equivalent), see examples/identity-demo/.
Configuration¶
Global switch¶
Set identity_headers: false (or SHINYHUB_IDENTITY_HEADERS=false) to
disable forwarding across the entire installation. This is a hard operator
kill switch: no per-app manifest setting can override it. Use this to satisfy
a compliance requirement or to roll out the feature gradually.
Per-app opt-out¶
Add an [app] section to your bundle's shinyhub.toml:
Setting identity_headers = false opts this app out while leaving the rest of
the fleet forwarding identity. Removing the key (or the whole [app] section)
reverts to the global default on the next deploy.
The global false kill switch always wins. If the operator has set
auth.identity_headers: false, setting identity_headers = true in a
manifest has no effect.
Key rotation¶
Identity keys are derived from auth.secret. To rotate, change auth.secret,
restart the ShinyHub server, and restart (or redeploy) each app so it picks up
its new SHINYHUB_IDENTITY_KEY. Tokens minted with the old key become invalid
immediately after the server restarts.
High-availability deployments¶
In a multi-instance deployment, every control-plane instance derives the same
per-app keys from auth.secret, so tokens minted by one instance are
verifiable by apps regardless of which instance proxied the request. Keep the
global auth.identity_headers flag identical on every instance; each instance
resolves a per-app NULL (inherit) against its own local config.
Overhead¶
The group list is capped at 100 names, but long group names still add
kilobytes per request. Intermediate proxies or load balancers with small header
size limits (commonly 8 KB) may need tuning if your users belong to many
groups with long names. Per-app opt-out (identity_headers = false in
shinyhub.toml) is the relief valve for high-traffic apps that do not consume
identity.