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/v1endpoints that hand out a media credential for a specific job are the self-serve route:…/sources/{sourceId}/ingestfor an input's publish URLs (sources:ingest),…/contributefor a device, and…/monitorandPOST /v1/viewer-tokensfor 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:
- iOS / Swift — SDK quickstart: Swift
(
ASWebAuthenticationSession) - Android / Kotlin — SDK quickstart: Kotlin (AppAuth / Custom Tabs)
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.*answer404. 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
- Generate a PKCE pair — a random
code_verifier, andcode_challenge = BASE64URL(SHA256(code_verifier)). - Open the authorize URL in a system browser
(
ASWebAuthenticationSession/ Custom Tab — never an embedded webview) withresponse_type=code, yourclient_id,redirect_uri,scope,code_challenge,code_challenge_method=S256, and a randomstate. The user signs in on the hosted page. - Capture the callback on your custom scheme — you receive
?code=<authorization_code>&state=…. Verifystatematches what you sent. - Exchange the code at the token endpoint (
grant_type=authorization_code, withcode,redirect_uri,client_id, and thecode_verifier— no secret). You get back anaccess_token(JWT), arefresh_token, andexpires_in. - Call
/v1withAuthorization: Bearer <access_token>. - Refresh silently with
grant_type=refresh_tokenbefore the access token expires — no browser round-trip. - 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.
/v1trusts Zitadel only. It does not accept the media-planetrax-studio-apiES256 session JWT described at the top of this page — that is a different subsystem (media surfaces + the studio browser session), not a/v1credential. For/v1, use an API key or a Zitadel user token.
The access token must be a JWT, not an opaque bearer token.
/v1validates it by parsing it against Zitadel's JWKS; an opaque token fails to parse and returns401. Thetrax-mobileclient 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.
/v1does not check the audience; the studio control plane does./v1verifies issuer, signature, and expiry, but not theaud(audience) claim, so any valid user token the TRaX identity provider issued is accepted there, whichever client minted it. Audience pinning on/v1is 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 itsaudnames 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/v1and are refused with401on 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 appropriatekSecAttrAccessiblesuch 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.