Skip to content

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 inspect output 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).

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 .reason and .detail.
  • R: a shinyhub_identity_error condition carrying $reason and $detail, caught with tryCatch(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

pip install shinyhub-identity   # or: uv add shinyhub-identity
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:

uv add PyJWT
# or: pip 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:

install.packages(c("jose", "sodium"))
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

# shinyhub.yaml
auth:
  identity_headers: true   # default; omitting this key has the same effect

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:

[app]
identity_headers = false

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

# shinyhub.yaml
server:
  session_recheck_interval: 30s   # default; 0 disables general session checks

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.