---
title: Authentication
description: API keys for the control plane, short-lived tokens for the media plane — what to use where, and how to handle both safely.
sidebar:
  label: Authentication
  order: 1
---

Every Orvyn API surface is authenticated, with **two different credential
types** for two different planes:

| Credential | Where it is used | Lifetime |
|---|---|---|
| **API key** | All six `/v1/` REST endpoints (headers) | Long-lived, admin-managed |
| **Client token** | The media WebSocket (`?token=` query parameter) | Short-lived, 5 minutes |

## API key authentication

All `/v1/` routes accept your API key via either header:

<CodeGroup>

```http Authorization
Authorization: Bearer <api_key>
```

```http x-api-key
x-api-key: <api_key>
```

</CodeGroup>

### Where keys come from

API keys are created and revoked through the admin surface of the API itself:

- `POST /v1/api-keys` — create a key. The plaintext is shown **exactly once** at
  creation; Orvyn stores only a SHA-256 hash.
- `DELETE /v1/api-keys/{key_id}` — revoke a key. Revocation takes effect
  immediately; keys do not expire on their own.

:::danger[Never expose API keys]
API keys are long-lived credentials.

- Keep them in backend secrets or environment bindings — never in frontend
  code, source, or checked-in config.
- Never send them in URL query strings.
- If a key leaks, revoke it immediately with
  `DELETE /v1/api-keys/{key_id}`.
:::

### Auth errors

Every error body is the flat AVTR `ErrorResponse`:

```json
{
  "code": "AVTR_AUTH_MISSING_CREDENTIALS",
  "message": "Missing API credentials.",
  "request_id": "3f2a9c1e-...",
  "doc_url": "https://<orvyn-public-api-host>/docs/errors"
}
```

Auth error codes, per the binding contract:

| Status | `code` | Meaning |
|---|---|---|
| 401 | `AVTR_AUTH_MISSING_CREDENTIALS` | No credential provided |
| 401 | `AVTR_AUTH_INVALID_KEY` | Credential invalid, unrecognized, revoked — or (Clerk) rejected by the authorized-parties (`azp`) allowlist |
| 401 | `AVTR_AUTH_EXPIRED_TOKEN` | Bearer token expired |
| 403 | `AVTR_AUTH_FORBIDDEN` | Credential recognized but not permitted |
| 403 | `AVTR_AUTH_MISSING_SCOPE` | Scope missing for this endpoint |
| 403 | `AVTR_AUTH_CLERK_ORG_UNMAPPED` | Valid Clerk credential but the Clerk org is not mapped to an AVTR org — map the org or contact support |

See [errors, limits & security](/guides/errors-limits-security) for the full
catalog and the `retry_after` rules.

## Client token authentication

The media WebSocket (`GET /v1/sessions/{session_id}/ws`) is **not**
API-key authenticated. It uses a short-lived HMAC-signed token passed as the
`?token=` query parameter:

```
wss://<orvyn-public-api-host>/v1/sessions/{session_id}/ws?token=...
```

### Token properties

| Property | Value |
|---|---|
| Signing | HMAC-SHA256 |
| TTL | 5 minutes (300 seconds) |
| Payload | Session id, tenant id, purpose — never the raw API key |
| Binding | HMAC-bound to one session and one tenant |

The token is **the authorization**: anyone holding a valid token can connect to
that session's WebSocket. This is by design, but it means the token (and any URL
you assemble with it) must be handled like a credential.

### Token lifecycle

You receive the first token — `client_token` — in the `POST /v1/sessions`
response, delivered **exactly once** (`expires_at` carries its epoch-ms expiry).
For reconnects after the 5-minute expiry, mint a fresh one:

```bash
curl -X POST \
  "https://<orvyn-public-api-host>/v1/sessions/$SESSION_ID/client-token" \
  -H "Authorization: Bearer $AVTR_API_KEY"
```

The response is a bare `ClientTokenResponse`:

```json
{
  "client_token": "<fresh-hmac-client-token>",
  "expires_at": 1718352600000,
  "transport": "websocket_fmp4",
  "stream_metadata": {
    "session_token": "<purpose-scoped-media-token>",
    "sfu_endpoint": null,
    "ice_servers": []
  }
}
```

| Field | Meaning |
|---|---|
| `client_token` | Freshly-minted short-lived (5-minute) client token |
| `expires_at` | Unix epoch **milliseconds** when the new token expires |
| `transport` | Transport for this session: `auto`, `webrtc_sfu`, or `websocket_fmp4` |
| `stream_metadata.session_token` | Purpose-scoped media-plane token — an alternative `?token=` value |
| `stream_metadata.sfu_endpoint` | SFU publisher endpoint; `null` on the `websocket_fmp4` transport |
| `stream_metadata.ice_servers` | ICE servers for WebRTC negotiation; may be empty for `websocket_fmp4` |

Build the WebSocket URL as
`wss://<orvyn-public-api-host>/v1/sessions/{session_id}/ws?token=<client_token>`
with the fresh token.

:::note[Refreshing does not revoke]
Previously issued tokens stay valid until their own expiry. There is no
per-token revocation — the 5-minute TTL is the revocation window.
:::

## What to never put in a URL

| Never in a URL | Use instead |
|---|---|
| API keys (`Authorization` values) | Headers on REST calls |
| Long-lived provider credentials | `provider_config` in `POST /v1/sessions`, minted per session by your backend |
| The `client_token` (or an assembled media URL) in logs/analytics | Build the URL, open the connection, then discard both |

## Request correlation

Every `/v1/` response includes an `X-Request-Id` header for tracing. You may
supply your own (1–128 characters of `[a-zA-Z0-9_-]`); it is echoed back when
valid, otherwise a fresh id is generated. Error responses echo the same id as
`request_id` in the error body (`ErrorResponse.request_id`); success bodies
are bare schema objects and carry no such field.

## Response shape contract

Successful responses are **bare schema objects** — the exact shape documented
for each endpoint (`SessionResponse`, `SessionStatusResponse`,
`EndSessionResponse`, `ClientTokenResponse`, …). There is no envelope:

```json
{
  "session_id": "session:8f3c...",
  "status": "active"
}
```

Errors keep the flat AVTR shape — `{code, message, request_id, doc_url}` plus
`retry_after` on retryable codes only — see
[errors, limits & security](/guides/errors-limits-security).
