# Customer tokens

A customer token lets your own login stay the only login. Your server knows who is signed in. It signs a short token that says "this is user 42". The browser sends that token to Vabu with a publishable key, and Vabu opens the viewing session for user 42. Vabu never asks your viewer to sign in again, and your secret key never reaches the browser.

## Make a signing key

Create a key once. Only a secret key can do this.

```http
POST /v1/access/customer_token_keys

{ "name": "backend" }
```

```json
{
  "data": {
    "id": "ctk_01J9ZQ4M8T3K6Y2E5W7N0B1C9D",
    "object": "customer_token_key",
    "name": "backend",
    "status": "active",
    "livemode": true,
    "secret": "cts_..."
  }
}
```

The `secret` is shown once. Keep it in your server's secret store. Keys are per environment: a token signed for test mode does not work with a live publishable key. Rotate by making a second key, deploying it, then revoking the first with `DELETE /v1/access/customer_token_keys/{id}`. Revoking stops new sessions at once.

## The token

A token is a standard JWT signed with HMAC-SHA256, so a few lines in any language make one.

| Part | Value |
|---|---|
| Header `alg` | `HS256`. Anything else is refused. |
| Header `kid` | The key's `id`, starting `ctk_`. |
| `sub` | Your own id for the viewer, 1 to 255 characters. It must match the `customer` on the entitlement. |
| `aud` | `vabu` |
| `iat` and `exp` | Both required, at most 15 minutes apart, and `exp` at most 15 minutes ahead. A clock skew of 60 seconds is allowed. |
| `ctry` | Optional. A two-letter country code you vouch for, used for country limits. |

Sign a fresh token each time a page loads. Ten minutes is a good lifetime.

::: code-group

```js [Node]
import { createHmac } from 'node:crypto'

const b64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url')

export function customerToken(customer, keyId, secret) {
  const now = Math.floor(Date.now() / 1000)
  const head = b64({ alg: 'HS256', typ: 'JWT', kid: keyId })
  const body = b64({ sub: customer, aud: 'vabu', iat: now, exp: now + 600 })
  const signature = createHmac('sha256', secret).update(`${head}.${body}`).digest('base64url')
  return `${head}.${body}.${signature}`
}
```

```php [PHP]
<?php

function b64url(string $data): string
{
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

function customerToken(string $customer, string $keyId, string $secret): string
{
    $now = time();
    $head = b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT', 'kid' => $keyId]));
    $body = b64url(json_encode(['sub' => $customer, 'aud' => 'vabu', 'iat' => $now, 'exp' => $now + 600]));
    $signature = b64url(hash_hmac('sha256', "$head.$body", $secret, true));

    return "$head.$body.$signature";
}
```

```python [Python]
import base64
import hashlib
import hmac
import json
import time


def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


def customer_token(customer: str, key_id: str, secret: str) -> str:
    now = int(time.time())
    head = b64url(json.dumps({"alg": "HS256", "typ": "JWT", "kid": key_id}, separators=(",", ":")).encode())
    body = b64url(json.dumps({"sub": customer, "aud": "vabu", "iat": now, "exp": now + 600}, separators=(",", ":")).encode())
    signature = b64url(hmac.new(secret.encode(), f"{head}.{body}".encode(), hashlib.sha256).digest())
    return f"{head}.{body}.{signature}"
```

```go [Go]
package vabu

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"time"
)

// CustomerToken signs a short-lived token for one viewer.
func CustomerToken(customer, keyID, secret string) string {
	enc := base64.RawURLEncoding
	now := time.Now().Unix()

	head, _ := json.Marshal(map[string]any{"alg": "HS256", "typ": "JWT", "kid": keyID})
	body, _ := json.Marshal(map[string]any{"sub": customer, "aud": "vabu", "iat": now, "exp": now + 600})

	unsigned := enc.EncodeToString(head) + "." + enc.EncodeToString(body)
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(unsigned))

	return unsigned + "." + enc.EncodeToString(mac.Sum(nil))
}
```

:::

## Use it in the browser

Send the publishable key and the token. Leave `customer` out of the body: with a publishable key the customer is always the token's `sub`.

```http
POST /v1/streams/playback_tokens
Vabu-Publishable-Key: vpk_live_...
Authorization: Bearer <customer token>

{ "resource": "ls_...", "device_id": "browser-1" }
```

These calls accept a publishable key with a customer token: open a session (`POST /v1/streams/playback_tokens` or `POST /v1/access/sessions`), heartbeat and end. A heartbeat or an end only acts on the token's own sessions. Everything else, including entitlements and access checks, needs a secret key.

**Refresh the token before it expires.** With a publishable key every call, heartbeats included, is checked against the token. When it expires, heartbeats fail with `401 invalid_customer_token` and the session lapses. Ask your server for a new token before the old one runs out, or fetch one for each call.

## Errors

A rejected token returns `401 invalid_customer_token`, and `error.details.reason` says why.

| Reason | Fix |
|---|---|
| `missing` | Send the token as `Authorization: Bearer`, beside the publishable key. |
| `malformed` | The token is not a three-part HS256 token with a `kid`. |
| `unknown_key` | The `kid` is not a key in this tenant and environment. A token for test mode does not work with a live key. |
| `key_revoked` | The signing key was revoked. Sign with a current one. |
| `invalid_signature` | The secret does not match the key. |
| `not_yet_valid` | `iat` or `nbf` is in the future. Check your server's clock. |
| `token_expired` | `exp` has passed. Sign a new token. |
| `invalid_claims` | `aud`, `sub`, `iat` or `exp` is wrong, or the lifetime is over 15 minutes. |

## Why HMAC

The signer needs no library and no key format beyond HMAC. The secret lives in two places only: your server, and Vabu, where it is encrypted at rest. If you use Vabu ID for login instead, see [sign-in options](https://docs.vabu.live/concepts/sign-in-options.md).
