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:
- 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 <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
- 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.
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.
/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.
Audience is not checked (yet).
/v1verifies issuer, signature, and expiry, but not theaud(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 specialaud/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 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.