# Authentication

> API keys, relay tokens, namespace-auth, and the JWT model.

ClutchCall runs two auth surfaces:

- **Control plane** (control-plane API tRPC: voice control, streams CRUD, agents, webhooks,
  analytics) — long-lived **API keys** scoped to an org and a set of
  capabilities.
- **Data plane** (MoQT publish / subscribe, audio bridge, robotics topics,
  games rooms, data pub/sub) — short-lived **relay tokens** scoped to a
  set of namespaces, verified by the relay's namespace_auth hook on every
  publish or subscribe.

The two are linked: a client uses its API key to mint relay tokens scoped
to exactly the namespaces it needs.

## Control-plane: API keys

API keys are created in the dashboard or via
`streams.apiKeys.create({ orgId, label, scopes })`. The key string is
returned **once**, at creation; the control-plane API stores only the hash.

```bash
export CLUTCHCALL_API_KEY=tqs_live_abc123…
```

Every control-plane API call (voice originate, streams create, webhook list, analytics,
agent attach) authenticates with this key over HTTPS. The control-plane API rejects keys
that don't have the right capability scope for the procedure being called.

## Data-plane: relay tokens

The relay never sees the API key. The control-plane API mints a short-lived **relay
token** — a signed JWT carrying namespace claims — and the client presents
that during the MoQT handshake. The relay's `namespace_auth` hook verifies
the JWT signature, extracts the namespace scope, and gates every subsequent
publish / subscribe against it.

A typical relay token looks like:

```json
{
  "alg": "EdDSA",
  "kid": "rk_live_abc…",
  "iss": "clutchcall-bff",
  "sub": "tenant_abc",
  "ns":  ["voice/sid_xyz/*", "playback/li_xyz"],
  "exp": 1733900000
}
```

| Claim | Meaning                                                              |
| ----- | -------------------------------------------------------------------- |
| `kid` | Signing-key id; the relay fetches the matching public key from Redis. |
| `iss` | The control-plane API that minted the token.                         |
| `sub` | Tenant id the relay stamps on the session.                           |
| `ns`  | Namespace patterns this token may publish or subscribe to.           |
| `exp` | Expiry (typically 1h for client tokens, longer for service-to-service). |

Every modality client knows which namespaces it needs and asks the control-plane API
for an appropriately scoped token. You don't construct these JWTs by hand
— the SDK's modality clients call the control-plane API's token-mint procedures for you
when given a control-plane API key.

## Browser tokens

In the browser, you cannot ship a long-lived API key. Instead, your
server-side mints a **short-lived relay token** for each browser session
and the SPA passes it in:

```ts
// server (your control-plane API)
const token = await streams.signingKeys.mintPlaybackToken({
  inputId: "li_xyz", scopes: ["playback/li_xyz"], ttlSeconds: 3600,
});

// browser
const viewer = await BroadcastViewer.open(signedUrl);
```

For voice in a browser:

```ts
// server
const token = await voice.audioBridge.mintBrowserToken({
  callSid: "sid_xyz", ttlSeconds: 1800,
});
// browser
const v = new Voice({ baseUrl: controlPlaneApi, browserToken: token });
```

`browserToken` skips the API-key-on-client antipattern; the SDK presents
it directly to the relay.

## Signing-key rotation

Every relay token is signed with a per-org **signing key**. Rotate keys
without downtime by:

1. `streams.signingKeys.create({ orgId, label })` — issues a new active key
2. wait for clients with old tokens to expire (1h by default)
3. `streams.signingKeys.retire({ id })` — old key stops minting new tokens

The relay hydrates the active set of public keys from Redis on a 30 s
refresh, so retirements take effect within that window.

## Legacy: service-account JWT

The original control plane (the `ClutchCallClient` root import — `dial`,
`hangup`, `barge`, `push_audio`) authenticated with an RSA service-account
JSON file pointed at by `CLUTCHCALL_CREDENTIALS`:

```json
{
  "tenant_id": "acme",
  "private_key": "-----BEGIN PRIVATE KEY-----\n…\n-----END PRIVATE KEY-----",
  "private_key_id": "kid-2026-01"
}
```

The SDK signed an RS256 JWT (`iss=clutchcall-sdk`, `sub=tenant_id`)
and presented it on the QUIC handshake. This still works for backwards
compat — new code should use API keys + relay tokens via the Voice modality.

## Related

- [Modalities overview](/modalities/overview)
- [Architecture](/concepts/architecture) — namespace_auth fits into the relay
- [Streams reference](/sdks/typescript/reference) — `apiKeys`, `signingKeys`
