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.
1. API tokens (sk_...)
Section titled “1. API tokens (sk_...)”Bearer tokens meant for automation and server-to-server integrations. Stored SHA-256 hashed; the plaintext is shown exactly once, at creation.
The bootstrap problem
Section titled “The bootstrap problem”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:
printf '%s' "$STREAMHUB_API_TOKEN" | \ docker compose exec -T core node deploy/seed-token.js - bootstrapThe 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.
Mint, list, revoke
Section titled “Mint, list, revoke”| 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 |
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..." }curl -s -X DELETE $BASE/tokens/5 -H "Authorization: Bearer $STREAMHUB_TOKEN"Token scopes
Section titled “Token scopes”| 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 |
2. Dashboard login (human JWT)
Section titled “2. Dashboard login (human JWT)”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>.
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.
# 1) request a link (always 200, never reveals whether the email exists)curl -s -X POST $BASE/auth/magic-link \ -H 'Content-Type: application/json' -d '{"email":"alice@example.com"}'
# 2) user clicks the emailed link: /auth/magic?token=<token># 3) verify it — TOTP `code` required too, if 2FA is oncurl -s -X POST $BASE/auth/magic/verify \ -H 'Content-Type: application/json' \ -d '{"token":"<one-time-token>"}'# { "data": { "token": "<jwt>" } }The one-time token has a 15-minute TTL and a 60-second per-email resend
cooldown (429 + retryAfterSeconds). See rate limits.
curl -s -X POST $BASE/auth/login \ -H 'Content-Type: application/json' \ -d '{"user":"'"$ADMIN_USER"'","password":"'"$ADMIN_PASS"'"}'Logs in with the env-configured ADMIN_USER/ADMIN_PASS (constant-time
compared), yielding a superadmin JWT. This account is exempt from 2FA and
excluded from password reset — the platform owner can never be locked out.
Signup gating
Section titled “Signup gating”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:
curl -s $BASE/auth/config# { "data": { "allowSignup": false } }- When
STREAMHUB_ALLOW_SIGNUPis off,POST /auth/signupwith a brand-new email returns403 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.
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.
2FA (TOTP)
Section titled “2FA (TOTP)”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.
3. The public play-token
Section titled “3. The public play-token”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.
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.