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.

Access is operator-provisioned today. TRaX does not yet offer self-serve API-key or token issuance for third parties — session tokens are minted by the platform for its own applications, and for partners by hand. Self-serve issuance is a separate, tracked design effort. Until it ships, contact the TRaX team to get credentials.

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://<trax-api-host>/.well-known/jwks.json

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 <issuer>/.well-known/openid-configuration.

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

Client id (dev): 385862192278274086 — a public native client (no secret). The production client id is issued separately when the app is registered in the production IdP; request it with your production credentials.

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 — e.g. live.traxstreaming.ios://oauth/callback on iOS and live.traxstreaming.android://oauth/callback on Android. These are placeholders until the real app identifiers exist; 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.

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. /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-dev.traxstreaming.live in dev, https://auth.traxstreaming.live in prod) 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.

Audience is not checked (yet). /v1 verifies issuer, signature, and expiry, but not the aud (audience) claim — so any valid user token the TRaX Zitadel instance issued is accepted, regardless of which client minted it. Audience pinning is a tracked hardening follow-up (#382). Two consequences: (1) your mobile token needs no special aud/scope to work, and (2) because it is not audience-scoped, treat the access token as a first-class credential — keep it in the Keychain / Keystore, keep it short-lived, 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).
  • AndroidEncryptedSharedPreferences 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.