Skip to content

Credentials

Switchboard hands out five kinds of credential. Most apps need only the first three.

Looks like Who holds it Used for Lasts
API key sb_... Your backend Managing your Organization through the Switchboard API Until you revoke it, or an expiry you set
Signing key a JWK, kid acme_backend Your backend Minting Client tokens yourself Until you delete it
Client token a JWT A publisher or viewer Connecting to a relay An hour by default
Relay enrollment token sbe_... You, briefly Enrolling a Self-hosted relay Used once, expires within a day
Node key sbn_... Your Self-hosted relay Talking to Switchboard and its peers Until you delete the Node

You use the API key to manage things, such as creating signing keys and enrolling relays. You use the signing key for the job you do constantly, which is minting a Client token for each publisher and viewer.

Only a Client token belongs in a browser or a mobile app. Keep the others on your servers.

An AI agent can also call the API with an access token it gets from WorkOS AuthKit, without you handing it any of these. See AI agents.

The examples on this page use Organization 4 and its Namespace acme. See Organizations and Namespaces for what those are and how API paths name them.

An API key authenticates your calls to the Switchboard API. Send it as a bearer token:

curl https://app.caprid.com/v1/account \
-H "Authorization: Bearer $SB_API_KEY"

You get one when you sign up. To make more, use your Organization’s page in the dashboard, or send POST /v1/organizations/{id}/api-keys. The secret is shown once, when the key is created. After that you see only its first few characters.

An API key belongs to the Organization, not to the person who made it, so it keeps working when that person leaves. It reaches its own Organization and everything in it, and nothing else.

You should treat an API key like a password, because it can create signing keys. Relays never accept an API key, so a leaked key can’t be used to watch or publish directly. It can be used to mint tokens that can.

If you keep prod and staging in separate Namespaces, your staging CI should hold a key that can’t read prod’s signing keys. You can limit an API key to some of your Organization’s Namespaces when you create it, in the dashboard or by sending namespaces:

curl https://app.caprid.com/v1/organizations/4/api-keys \
-H "Authorization: Bearer $SB_API_KEY" \
-d '{"label": "staging-ci", "namespaces": ["acme-staging"]}'

The response is the new key, with its limit:

{
"id": "3f9a1c07be42",
"label": "staging-ci",
"preview": "R2x0Z9",
"created_at": 1767225600,
"namespaces": ["acme-staging"],
"secret": "sb_R2x0Z9..."
}

This key:

  • can create, read and delete signing keys in acme-staging
  • can mint and verify Client tokens in acme-staging
  • can turn acme-staging’s platform_peering setting on or off
  • gets a 403 for anything in acme or any other Namespace
  • gets a 403 for every /v1/organizations/{id} route, which covers relays, enrollments and API keys

Because it can’t create API keys, the key can’t make itself a new key without the limit.

A limited key can’t list your Organization’s Namespaces, but it can look itself up. GET /v1/account returns the key as api_key, limit included:

curl https://app.caprid.com/v1/account \
-H "Authorization: Bearer $SB_STAGING_KEY"

The response is:

{
"account": {
"api_key": {
"id": "3f9a1c07be42",
"label": "staging-ci",
"preview": "R2x0Z9",
"created_at": 1767225600,
"last_used_at": 1767312000,
"namespaces": ["acme-staging"]
},
"organizations": [{"id": 4, "name": "Acme", "created_at": 1760000000}],
"namespace": "acme-staging",
"platform_peering": false
}
}

namespace and platform_peering appear only because this key reaches exactly one Namespace. A key with no limit has no namespaces in its api_key.

You can’t change a key’s limit later. Create a new key instead. If you delete a Namespace, it drops out of every key’s limit. A key whose Namespaces have all been deleted shows "namespaces": [] and can’t do anything, so you should revoke it.

Scripts written before Organizations existed use routes without an Organization or a Namespace in them: /v1/keys, /v1/tokens, /v1/enrollments, /v1/nodes and /v1/account/api-keys. These routes still work with an API key, and act in the key’s Organization.

/v1/keys and /v1/tokens work only while the key reaches exactly one Namespace, because otherwise they can’t tell which Namespace you mean. That’s the Organization’s only Namespace, or the only one in the key’s limit. A limited key gets a 403 from the other old routes, because they act on the whole Organization.

Move to the new routes when you next touch those scripts.

A signing key mints Client tokens. You can also mint Client tokens through the API, but with a signing key, your own server-side code mints them directly, without a call to Switchboard for each one.

To create a signing key:

curl -X POST https://app.caprid.com/v1/namespaces/acme/keys \
-H "Authorization: Bearer $SB_API_KEY" \
-d '{"label": "backend"}'

The response is the key:

{
"alg": "HS256",
"key_ops": ["sign", "verify"],
"kty": "oct",
"k": "5mT0...",
"kid": "acme_backend",
"scope": {"root": "acme", "put": [""], "get": [""]}
}

k is the secret. The kid is your Namespace and the label, joined with an underscore.

scope is the most the key can ever sign. By default, that’s your whole Namespace, for publishing (put) and subscribing (get). You can narrow the scope when you create the key, and you can’t widen it afterwards:

# Signs only tokens rooted under acme/customer-foo, for either role
-d '{"label": "customer-foo", "path": "customer-foo"}'
# Signs only tokens that publish, and only under acme/ingest
-d '{"label": "ingest", "publish": "ingest"}'

If you name publish and not subscribe, the key can’t sign tokens that subscribe. The same goes the other way round.

Relays enforce the scope every time they check a token, not only when the token is signed. So someone who gets hold of a signing key can’t reach outside its scope, but they can do anything inside it. You should keep signing keys on your servers.

Because the scope holds no matter who has the key, you can give a narrowly scoped key to someone else. Suppose your customers run their own backends. A key scoped to acme/customer-foo lets customer-foo mint tokens for their own viewers, and they can’t touch any other customer’s broadcasts. They can still do anything inside that scope, so the key is theirs to protect.

A Client token is what a publisher or viewer presents to a relay. It’s a JWT with three claims that matter:

  • root: the path the token is anchored at. It’s the full path, so it starts with your Namespace.
  • put: the prefixes under root that the holder may publish to.
  • get: the prefixes under root that the holder may subscribe to.

"" in put or get means everything under root. Prefixes match whole path segments, so live covers live/cam1 but not lively.

The Client passes the token as jwt= in the URL it connects to. For Mux’s relays, that’s relay.mux.global, which sends each Client to the nearest region:

https://relay.mux.global/acme/room/42?jwt=eyJhbGciOiJIUzI1NiIs...

A Client of a Self-hosted relay uses that relay’s URL instead.

Minting through the API takes one request per token. That’s fine for testing or for low volume:

curl -X POST https://app.caprid.com/v1/namespaces/acme/tokens \
-H "Authorization: Bearer $SB_API_KEY" \
-d '{"kid": "acme_backend", "path": "room/42", "subscribe": ""}'

The response has the token and the claims inside it. root includes your Namespace:

{
"token": "eyJhbGciOiJIUzI1NiIs...",
"kid": "acme_backend",
"claims": {"root": "acme/room/42", "get": [""], "exp": 1790301600, "iat": 1790298000}
}

A token grants nothing unless you ask for it, so you have to name publish, subscribe, or both. The token expires after an hour unless you set expires_in, in seconds. 0 means it never expires.

While the Namespace has exactly one signing key, you can leave out kid.

In production, sign tokens in your backend. You save a round trip per viewer, and minting keeps working if Switchboard is unreachable.

A Client token is an ordinary HS256 JWT. Put the signing key’s kid in the header, and use its k, base64url-decoded, as the secret. Here’s how with jose in Node:

import { SignJWT, base64url } from "jose";
const key = JSON.parse(process.env.SB_SIGNING_KEY); // the JWK from /v1/namespaces/acme/keys
export function viewerToken(room) {
return new SignJWT({ root: `acme/room/${room}`, get: [""] })
.setProtectedHeader({ alg: "HS256", typ: "JWT", kid: key.kid })
.setIssuedAt()
.setExpirationTime("1h")
.sign(base64url.decode(key.k));
}

A token from viewerToken("42"):

  • can subscribe to anything under acme/room/42
  • can’t publish anything
  • expires after an hour

Here, root starts with acme/ because nothing adds the Namespace for you, the way the API does.

The claims have to fit inside the signing key’s scope. If a token asks for more, the relay refuses it when a Client uses it. To check a token while you build this, send it to POST /v1/namespaces/acme/tokens/verify.

You can’t revoke a single Client token. Keep tokens short-lived, and issue a new one when a session needs to go on longer.

Deleting a signing key revokes every token it signed. Clients using those tokens are disconnected within about a minute.

To rotate a signing key without cutting anyone off:

  1. Create the new key.
  2. Switch your backend to the new key.
  3. Wait for the tokens signed with the old key to expire.
  4. Delete the old key.

You need these only if you run your own relays. A Relay enrollment token is a one-time code that you create with POST /v1/organizations/{id}/enrollments. Your relay exchanges it for a Node key. The relay then uses the Node key to talk to Switchboard and to the other relays it peers with. See Running a Self-hosted relay.

Deleting a Node deletes its Node key. Switchboard refuses the relay on its next poll, and the relay has to be enrolled again, with a new token, to come back.