Skip to content

Authentication

This content is for the 1.0 version. Switch to the latest version for up-to-date documentation.

StreamHub resolves three kinds of credential behind the same Authorization: Bearer header, plus one fully anonymous route for public playback. This page covers minting and managing each of them. See API overview for how the guard picks between them and how STREAMHUB_AUTHZ_ENFORCE gates the resulting permissions.

Bearer tokens meant for automation and server-to-server integrations. Stored SHA-256 hashed; the plaintext is shown exactly once, at creation.

POST /tokens itself requires a Bearer token — so the very first global token on a fresh install can’t come from the API. It’s seeded directly into the database by deploy/seed-token.js, which the installer runs once against the container:

Terminal window
printf '%s' "$STREAMHUB_API_TOKEN" | \
docker compose exec -T core node deploy/seed-token.js - bootstrap

The script is idempotent — it does nothing if a non-revoked global token already exists — and prefers the stdin form (-) so the token never appears in argv/process listings. From there, use that first token to mint any others through the REST API.

Method Path Auth Notes
GET /tokens Bearer List tokens (hashes/plaintext never returned)
POST /tokens Bearer Create a token; plaintext returned once
DELETE /tokens/{id} Bearer Revoke (soft-delete) → 204

POST /tokens body:

Field Type Required Notes
name string yes ≤ 100 chars
scope global app yes
appId number when scope=app numeric app id; forbidden when scope=global
allowedIps string[] no optional IP whitelist — exact IPv4/IPv6 or IPv4 CIDR, honours X-Forwarded-For
Terminal window
curl -s -X POST $BASE/tokens \
-H "Authorization: Bearer $STREAMHUB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"ci-runner","scope":"app","appId":3,"allowedIps":["203.0.113.10"]}'
{ "id": 5, "token": "sk_AbC...store-this-now..." }
Terminal window
curl -s -X DELETE $BASE/tokens/5 -H "Authorization: Bearer $STREAMHUB_TOKEN"
Scope Access Typical use
global Every global and app endpoint, across every tenant. Bypasses tenant scoping and Casbin (isSuperadmin-equivalent). The dashboard’s own backend, platform-level automation, CI that manages multiple apps
app Bound to one appId, inherits that app’s tenant. Can only touch that app’s resources (streams, ingress, vods, config, plugins…). Rejected with 403 on platform-only surfaces (/cluster/* manager routes, /system/settings). A single integration that should never see other tenants’ data

Human sign-in issues a short-lived (~12h) HS256 JWT signed with STREAMHUB_JWT_SECRET, sent back the same way as an API token: Authorization: Bearer <jwt>.

Terminal window
curl -s -X POST $BASE/auth/login \
-H 'Content-Type: application/json' \
-d '{"user":"alice@example.com","password":"s3cret-passphrase"}'
# { "data": { "token": "<jwt>" } }

user accepts either a built-in user’s email or the break-glass admin username. If 2FA is enabled on the account, omit code first — you’ll get 401 totp_required — then retry with the 6-digit code.

Public self-signup (POST /auth/signup) is controlled by STREAMHUB_ALLOW_SIGNUP (default off → invite-only). GET /auth/config tells a client which flows are available before it renders any UI:

Terminal window
curl -s $BASE/auth/config
# { "data": { "allowSignup": false } }
  • When STREAMHUB_ALLOW_SIGNUP is off, POST /auth/signup with a brand-new email returns 403 signup_disabled.
  • An invited user (created via POST /tenant/invites) can always complete signup regardless of the flag — that’s how invite-only deployments onboard people.
Terminal window
curl -s -X POST $BASE/auth/signup \
-H 'Content-Type: application/json' \
-d '{"email":"alice@example.com","password":"s3cret-passphrase","teamName":"Acme Streaming"}'
# { "data": { "token": "<jwt>" } }

Signup creates the user, a new team (tenant) on the free plan, and an owner membership, in one call.

Per-user, RFC-6238 TOTP, managed under /account (human JWT only — sk_ tokens get 403, a machine has no account):

Step Call Result
1 POST /account/2fa/setup { secret, otpauthUri, qrDataUri } — secret stored encrypted at rest, pending (not active yet)
2 POST /account/2fa/enable { code } Verifies a live code against the pending secret → activates 2FA
3 POST /account/2fa/disable { code } Verifies a live code → deactivates 2FA

Once enabled, both POST /auth/login and POST /auth/magic/verify require a code field: 401 totp_required when it’s missing, 401 totp_invalid when it’s wrong.

GET /apps/{app}/play-token/{room} needs no Authorization header at all — it’s how the anonymous /play/{app}/{room} and /embed/{app}/{room} pages get a working LiveKit token to subscribe with.

Terminal window
curl -s $BASE/apps/live/play-token/demo
{ "data": { "token": "<livekit-jwt>", "app": "live", "room": "live-demo",
"wsUrl": "wss://media.example.com", "mode": "viewer" } }

The minted token is always subscribe-only (no publish, no data channel) and hidden — it’s never counted as a viewer/participant, and a fresh random identity is minted per call so concurrent viewers don’t collide. An app can turn this off entirely with its publicPlayback feature flag; when disabled, the route returns 404 rather than confirming the room exists.

The sibling GET /apps/{app}/radio/{room}/listen-token mints the same kind of anonymous, hidden, subscribe-only token but restricted to audio only, for radio-style embeds.

Where join tokens (for publishers/viewers inside your own app) fit in

Section titled “Where join tokens (for publishers/viewers inside your own app) fit in”

The tokens above authenticate against the StreamHub REST API. A completely different kind of token — a LiveKit join token, minted by POST /apps/{app}/tokens using one of the credentials above — is what a browser or OBS-like client uses to actually connect to the media plane (publish/subscribe to a room). That flow, including hidden QC/recorder grants, lives in App endpoints → Tokens.