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, and messages on that WebSocket are not re-authenticated per-message.
The connection is re-authorized on a timer instead. Every ShinyHub instance
sweeps its own live upgraded connections every server.session_recheck_interval
(default 30s) and closes the ones whose access has lapsed:
| What changed | How it is detected |
|---|---|
| An admin revoked the user's sessions, or their password changed | users.token_epoch no longer matches the connection's |
| The user was deleted | the user row is gone |
| The user's global role changed | the live role no longer matches the one the app was told |
| The user's app entitlements changed | the effective business permission set differs from the handshake snapshot |
| The user signed out | that session token's jti is revoked |
| The user lost access to the app (membership or group grant removed) | the admission check is re-run against the live database |
| A public app was made private | same, for a connection admitted anonymously |
A lapsed connection is closed at the socket, without a WebSocket close frame. The browser sees a dropped connection. Automatic reconnection depends on the app and runtime; ShinyHub's default status overlay offers a fresh session in a new tab or a restart in the current tab. Any new connection meets the normal access check, which denies a user who has lost admission. If only business entitlements changed, an admitted user's new session receives the updated set. The old connection normally closes within one sweep interval plus query/sweep time. This does not erase results already delivered or cancel work already accepted by the app; starting a fresh session can lose unsaved state.
The sweep is per-instance by design. A hijacked connection can only be closed by the process holding it, so in a high-availability deployment every instance sweeps its own connections, standbys included, rather than the control-plane owner doing it for the cluster.
Raw group/profile claim drift is not itself swept. Group changes that alter an app's effective entitlements do close affected sessions. Raw groups and profile updates otherwise reach apps on a subsequent HTTP request, after the upstream change has reached ShinyHub and any identity cache has expired.
Token validity window¶
Tokens are valid for 5 minutes from issuance (the exp claim). During a
support session the token expires at the session deadline instead when that
comes first, so an app never keeps trusting a support identity that ShinyHub
has already ended. A token otherwise 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-Support-Session |
"true" |
Present only during an app-scoped administrator support session |
X-Shinyhub-Actor-Id |
Decimal administrator user ID | Present with X-Shinyhub-Support-Session; the represented user remains X-Shinyhub-User-Id |
X-Shinyhub-Actor |
Administrator username | Present with X-Shinyhub-Support-Session; intended for display and logs |
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) |
entitlements |
array of strings | Effective business permissions for this app only; omitted when empty. See App entitlements |
groups_truncated |
bool | true when the list was truncated to 100; absent otherwise |
support_session_id |
string | Random support-session ID; present only during a support session |
act |
object | Administrator actor with sub (decimal ID) and preferred_username; the top-level sub remains the represented user |
iat |
NumericDate | Token issue time |
exp |
NumericDate | iat + 5 minutes, or the support-session deadline when that is earlier |
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, so your app needs no JWT plumbing and stays testable without SSO.
Both read the injected SHINYHUB_IDENTITY_KEY / SHINYHUB_APP_SLUG
automatically, and both return the same identity shape: user_id, username,
role, groups, entitlements, name, email, groups_truncated, and the raw verified
claims. email and name are empty when the IdP asserted none.
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.
Anonymous and broken are different answers¶
None / NULL means exactly one thing: the request carried no identity
token, so the visitor is anonymous. A token that is present but fails
verification - missing or wrong key, audience or issuer mismatch, expired,
clock skew - is a broken deployment rather than a visitor, so it raises:
- Python:
IdentityError, carrying.reasonand.detail. - R: a
shinyhub_identity_errorcondition carrying$reasonand$detail, caught withtryCatch(expr, shinyhub_identity_error = function(e) ...).
reason is a stable classification, identical in both languages and pinned by
a cross-language conformance test:
reason |
Meaning |
|---|---|
no_token |
nothing to verify (verify_token only; current_user returns the anonymous value instead) |
no_key |
SHINYHUB_IDENTITY_KEY unset or empty |
bad_key |
SHINYHUB_IDENTITY_KEY is not valid hex |
no_slug |
SHINYHUB_APP_SLUG unset or empty |
bad_signature |
signed with a different key |
expired |
past its exp (tokens live 5 minutes, or until the support-session deadline when that is earlier) |
wrong_audience |
minted for a different app slug |
wrong_issuer |
iss is not shinyhub |
malformed |
unparseable, or missing the required exp claim |
Let it propagate unless you have something better to do with it. An app that renders a misconfigured deployment as "logged out" hides the outage behind an empty dashboard, which is exactly what this contract exists to prevent.
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.shiny import session_identity
def server(input, output, session):
user = session_identity(session) # None when anonymous
if user and "platform-admins" in user.groups:
... # gate on the VERIFIED groups
session_identity verifies the handshake once and returns that answer for the
session's life. That is a correctness property, not a cache: ShinyHub binds
identity at the WebSocket handshake, and the token it forwarded there expires
five minutes later, so an app that re-verifies the handshake headers from a
reactive starts failing part-way through a long session even though nothing
about the user changed.
Outside Shiny (Streamlit, Dash, FastAPI, ...) use the framework-free primitive, which takes any header mapping and verifies per request:
from shinyhub_identity import current_user
user = current_user(request.headers) # None when anonymous
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$username, user$role, user$email, user$name, user$groups
}
current_user(session) verifies once per session, for the same reason the
Python helper does.
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 session with no
token is simply anonymous, and a broken one is a classified error instead of a
silent empty page).
Testing an identity-gated app¶
Strict verification has a consequence for app tests: a signed-in code path cannot be driven with a made-up token string, because a token that is present but unverifiable is an error rather than an anonymous visitor. Both packages therefore ship a test-token minter.
from shinyhub_identity.testing import fake_session, identity_env
with identity_env():
user = session_identity(fake_session(username="alice", groups=["admins"]))
user <- with_shinyhub_identity(
current_user(shinyhub_test_session(username = "alice", groups = "admins"))
)
The wrapper sets SHINYHUB_IDENTITY_KEY and SHINYHUB_APP_SLUG for the
duration, so the app under test calls the helper exactly as it does in
production. Each rejection reason is reachable by changing one minting argument
(expires_in, key, slug, issuer), so a test can name the failure it
expects instead of hand-crafting a broken token.
The minted tokens carry the production claim set, including which claims are
omitted when empty. TestConformance_TestHelperMatchesProduction compares them
field by field against identity.MintToken in both languages, so a helper that
drifted into being more permissive than the proxy fails the build rather than
handing app authors green tests for code that breaks on deploy.
These helpers are test-only: the default key is a fixed published constant, so tokens minted with it are forgeable by anyone.
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,
options={"require": ["exp"]})
jwt.decode raises jwt.exceptions.InvalidTokenError (or a subclass) when the
token is expired, has the wrong audience or issuer, omits exp, or fails the
signature check. Let it propagate: the anonymous case already returned None
above, so an exception here means the deployment is broken, not that the
visitor is logged out.
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
Verify once, at the top of the server function as above, and keep the result.
The handshake headers stay in memory for the session's life while the token
they carry expires five minutes later, so calling current_user from inside a
reactive starts raising part-way through a long session even though nothing
about the user changed. session_identity does this for you.
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
# No token at all: an anonymous visitor.
if (is.null(token) || token == "") return(NULL)
# Present but unverifiable: a broken deployment. jose signals a bad signature
# or an expired token as an error; let it propagate rather than turning it
# into "logged out".
claims <- jose::jwt_decode_hmac(token, secret = identity_key)
# jose validates the signature, and exp/nbf when present (with a fixed
# 60-second grace period of its own). Assert iss, aud, the presence of exp,
# and the narrower 30 seconds of skew the Python recipe above allows, so the
# same token gets the same answer in both languages.
if (is.null(claims$exp)) stop("identity token: no exp claim")
if (as.numeric(claims$exp) + 30 < as.numeric(Sys.time()))
stop("identity token: expired")
if (!identical(claims$iss, "shinyhub")) stop("identity token: wrong issuer")
if (!(app_slug %in% claims$aud)) stop("identity token: wrong audience")
claims
}
Use current_user(session) inside your Shiny server function. It returns
NULL only for a visitor who sent no token; anything else is an error.
A real Shiny session's request is frozen at the WebSocket handshake while the
token it carries expires five minutes later, so verify once and keep the
result for the session rather than re-verifying inside a reactive. Both client
packages do this for you.
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.
Session re-check interval¶
How often each instance re-authorizes its live WebSocket app sessions (see WebSocket and session binding). It bounds how long a revoked user keeps a session that was already open; ordinary HTTP requests are authorized on every request and are unaffected by this setting.
The value is a duration, so write 30s or 2m, not 30. A bare 0 turns the
general sweep off. An entitlement-only sweep still runs every 30 seconds so
business permissions remain revocable. SHINYHUB_SESSION_RECHECK_INTERVAL
overrides the YAML key.
Lowering it costs little: a sweep decides once per distinct (app, user) pair that holds a live connection, not once per connection and not once per request, so a user with eight tabs open costs one decision. An instance with no upgraded connections does no work at all. If the database is unreachable, connections holding nonempty business entitlements close because their permissions cannot be verified. Other connections retain the existing fail-open behavior on lookup errors. See App entitlements for propagation and IdP limits.
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.
Restarting the apps is required, not cosmetic: a running app keeps the key it
was spawned with, so until it restarts it rejects every token the server mints
(the client helpers report bad_signature). See
Rotating auth.secret for the full procedure, including
the at-rest secrets that need shinyhub rotate-secret.
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.