reference

Authentication & session tokens

TRaX has two separate credential systems — don't mix them up:

System Credential Used on
Media plane A short-lived ES256 session JWT (iss: trax-studio-api), or a stream key SRT / RTMP / WHIP / WHEP publish + read — see below
/v1 Developer API An sk_live_… API key, or a Zitadel user access token (the OIDC + PKCE login token) Authorization: Bearer … on /v1 — see User login and Using the token at /v1

They are minted by different issuers and validated by different code. A media-plane session JWT does not authenticate /v1, and a /v1 credential does not authorize a media publish. This page starts with the media-plane session JWT; the /v1 login token is the last section.

Media-plane session JWTs

Every authenticated surface on the TRaX media plane accepts a session JWT: a short-lived, ES256-signed token minted by the TRaX platform. There are no long-lived shared secrets in the media path.

You get media credentials from /v1, not by minting tokens. You never sign a session JWT yourself. The /v1 endpoints that hand out a media credential for a specific job are the self-serve route: …/sources/{sourceId}/ingest for an input's publish URLs (sources:ingest), …/contribute for a device, and …/monitor and POST /v1/viewer-tokens for playback (viewer:tokens). Anything outside those is minted by the platform for its own applications.

Token shape

Session JWTs are compact JWS tokens, signed with ES256 (ECDSA P-256 + SHA-256).

Key claims:

Claim Meaning
iss trax-studio-api — the minting service
aud The consuming surface. Media-plane audiences: trax-mediamtx (SRT/RTMP publish + read), trax-mediamtx-talk (WebRTC talk/publish), trax-mediamtx-read (single-path WebRTC read)
action What the token authorizes: publish or read
path The exact stream path the token is bound to
exp Short TTL — tokens are minted per session/purpose, not stored

The action + path claims bind a token 1:1 to one operation on one path. A publish token for one path cannot read another path; token reuse across paths is rejected at the media edge.

Verifying TRaX tokens (JWKS)

The platform publishes its current signing keys at:

https://studio-api.traxstreaming.live/.well-known/jwks.json

This is not the /v1 host: https://api.traxstreaming.live/.well-known/jwks.json is a 404. The /v1 user tokens are verified against the identity provider's own keys instead (see Endpoints).

Standard JWKS semantics apply: match the token's kid header to a key in the set, verify the ES256 signature, then validate exp, iss, and the aud you expect. Keys are stable across service restarts; rotate-tolerant verifiers should refetch the JWKS on an unknown kid.

Where the token goes, per protocol

Protocol Credential placement
SRT Inside the streamid: publish:<path>:<stream-key>:<stream-key> — see SRT streamid contract
RTMP / RTMPS URL query string: rtmp://host:1935/<path>?user=<stream-key>&pass=<stream-key> — see RTMP contract
WHIP / WHEP Authorization: Bearer <jwt> header — see WHIP / WHEP

Stream keys

Publishing to an input over SRT or RTMP is authenticated by the input's stream key, not by a JWT. The key is minted when the input is created and is handed to you already embedded in a publish URL: by POST /v1/studios/{id}/sources/{sourceId}/ingest or by the studio's Connection Information panel. It occupies both credential positions of the SRT streamid and both RTMP query parameters. Treat stream keys like passwords: they do not expire on their own, and a deleted input's key stops authenticating. RTMP credentials in the URL's userinfo (rtmp://user:pass@host/…) are not read by the ingest server.

User login for mobile and native apps (OIDC + PKCE)

The tokens above are media-plane session JWTs the platform mints for a specific publish/read operation. A first-party app a user signs into — a native iOS or Android app — needs something different: a token that says who the user is, so the app can call the /v1 Developer API as that user. That token is an OIDC access token (JWT) minted by the TRaX identity provider (Zitadel), obtained with the standard OAuth 2.0 Authorization-Code flow with PKCE.

This is the login path for user-facing mobile apps. It is platform-agnostic; the platform-specific code lives in the SDK guides:

Why PKCE against the hosted login? The app opens the identity provider's hosted login page in a system browser, so the user's password (and MFA / passkeys, when enabled) is entered in the IdP — never in your app. PKCE (Proof Key for Code Exchange, S256) replaces a client secret, which a shipped mobile binary can't keep secret anyway. This is a public client: no secret is embedded in the app.

Endpoints

The IdP is a plain OIDC provider — every value below also appears in its discovery document at https://auth.traxstreaming.live/.well-known/openid-configuration.

Production
Issuer https://auth.traxstreaming.live
Authorize …/oauth/v2/authorize
Token …/oauth/v2/token
JWKS …/oauth/v2/keys
End session (logout) …/oidc/v1/end_session

auth.traxstreaming.live is TRaX's own sign-in service, which runs the login pages and forwards the OIDC protocol endpoints above to the identity provider behind it. The issuer, the signing keys and the client ids are the same as before, so existing access and refresh tokens keep working and nothing in your app's configuration changes. Use the endpoints from discovery and nothing else:

  • The identity provider's own pages and APIs are not a public surface. /ui/login, /ui/v2/login/… and the management console (/ui/console) are not served; old links redirect to the current sign-in or account pages, and /management/…, /v2/… and /zitadel.* answer 404. Never hard-code a login-page URL: always start at the authorize endpoint.
  • Account changes (profile, @handle, email, password, authenticator app, passkeys, signed-in sessions) are made in the TRaX account app at https://account.traxstreaming.live, not in the identity provider.

Client id: 386717562563526868 — the first-party TRaX mobile app, a public native client (no secret). A third-party app that signs other TRaX users in needs its own registration; that is the planned OAuth client-app phase (see Roadmap).

Scopes: openid profile email offline_access. The offline_access scope is what gets you a refresh token for silent re-login.

Redirect URI: the app registers a custom URI scheme (or a verified https App Link / Universal Link) that the browser hands the authorization code back on — the TRaX iOS app uses live.traxstreaming.ios://oauth/callback. The registered value must match what the app sends exactly.

The flow

  1. Generate a PKCE pair — a random code_verifier, and code_challenge = BASE64URL(SHA256(code_verifier)).
  2. Open the authorize URL in a system browser (ASWebAuthenticationSession / Custom Tab — never an embedded webview) with response_type=code, your client_id, redirect_uri, scope, code_challenge, code_challenge_method=S256, and a random state. The user signs in on the hosted page.
  3. Capture the callback on your custom scheme — you receive ?code=<authorization_code>&state=…. Verify state matches what you sent.
  4. Exchange the code at the token endpoint (grant_type=authorization_code, with code, redirect_uri, client_id, and the code_verifier — no secret). You get back an access_token (JWT), a refresh_token, and expires_in.
  5. Call /v1 with Authorization: Bearer <access_token>.
  6. Refresh silently with grant_type=refresh_token before the access token expires — no browser round-trip.
  7. Log out by clearing stored tokens and, to end the IdP session, redirecting to the end-session endpoint.

Token lifetimes

Token Lifetime
Access token 1 hour
ID token 1 hour
Refresh token 30 days without use, 90 days at most from sign-in
Sign-in session (the browser's SSO cookie) 30 days

Access tokens used to last 12 hours. Read expires_in from every token response instead of assuming either number, and refresh when roughly three quarters of it has passed. A refresh response can carry a new refresh_token; store it and send that one next time. When a refresh fails with invalid_grant, the refresh token has expired or was revoked: send the user through the authorize flow again.

A /v1 call with an expired access token answers 401 with the same invalid_key code as an unknown API key, plus a reason that says why:

{"error":{"code":"invalid_key","reason":"token_expired","message":"invalid or expired token","requestId":"…"}}

and the header WWW-Authenticate: Bearer error="invalid_token". On token_expired, refresh and retry. Any other 401, or a retry that fails again, means sign the user in again. reason is present only when the token was genuinely issued by TRaX and has merely expired; a forged or malformed token gets no reason. Clients that ignore reason keep working: the status and code are unchanged.

Claims

Use sub as the user's stable id. It never changes.

preferred_username is the user's TRaX @handle. Before this change it held the user's email address, and it can still hold the email for an account that has not claimed a handle yet. It also lags a handle change until the next refresh. Treat it as a display hint only: never as a key, and never as an email address (a value containing @ is not a handle). Read the email from the email claim (scope email), and the current handle from GET /v1/account.

Role claims in the token are internal to TRaX and are not part of the API contract. /v1 authorizes by ownership and scopes, not by token roles.

Using the token at /v1

/v1 accepts this user access token directly: send Authorization: Bearer <access_token> and the request runs as the user, with their full scope set. (An API key may instead be sent as X-API-Key: <key>, for tools that reserve the Authorization header.) /v1 accepts exactly two credential types, discriminated by prefix:

  • an sk_live_… / sk_test_… API key, verified against the account service, or
  • anything else — treated as a Zitadel user access token and validated against the identity provider's JWKS.

The token is validated by running OIDC discovery against the Zitadel issuer (https://auth.traxstreaming.live) and checking issuer + signature + expiry against Zitadel's keys. The mobile PKCE token drops straight onto /v1 with no special claim required — no custom scope, no audience value to set.

/v1 trusts Zitadel only. It does not accept the media-plane trax-studio-api ES256 session JWT described at the top of this page — that is a different subsystem (media surfaces + the studio browser session), not a /v1 credential. For /v1, use an API key or a Zitadel user token.

The access token must be a JWT, not an opaque bearer token. /v1 validates it by parsing it against Zitadel's JWKS; an opaque token fails to parse and returns 401. The trax-mobile client is configured to mint JWT access tokens, so this is already correct — just don't repoint the app at a client that issues opaque tokens.

/v1 does not check the audience; the studio control plane does. /v1 verifies issuer, signature, and expiry, but not the aud (audience) claim, so any valid user token the TRaX identity provider issued is accepted there, whichever client minted it. Audience pinning on /v1 is a tracked hardening follow-up (#382). The studio control plane (studio-api.traxstreaming.live: the WebSocket, the monitor and contribute mints) is different: it accepts a user token only when its aud names a client on the TRaX allowlist. The first-party mobile client above is on it. A newly registered client is added to the allowlist as part of its registration; until it is, its tokens work on /v1 and are refused with 401 on the control plane. Either way, treat the access token as a first-class credential: keep it in the Keychain / Keystore and refresh rather than stashing a long-lived copy.

Token storage

Store the refresh token — and the access token — in the platform secure store, never in plain app storage or source:

  • iOS — the Keychain (kSecClassGenericPassword, with an appropriate kSecAttrAccessible such as …WhenUnlockedThisDeviceOnly).
  • Android — EncryptedSharedPreferences or a key from the Android Keystore.

Treat the refresh token like a password: it is long-lived and can mint new access tokens. Wipe both on logout.