# Authenticate your backend and your users

Two credentials, two levels. Your backend holds a long-lived **product public key**, issued by the platform. Each of your end users gets a short-lived **delegated token**, minted by your backend from that key. The key never leaves your servers; the token is the only thing a browser ever sees.

| Credential | Held by | Lifetime | Reaches |
|---|---|---|---|
| Product public key | Your backend only | Until you revoke it | The public routes its scope covers, for one product |
| Delegated token | One end user's browser | 60–3600 seconds | Exactly the channels and operations you named, after namespace narrowing |

## The product public key

A product public key is a machine secret that unlocks the public routes of one product. A key created today looks like this:

```text
lpk_0123456789abcdef0123456789abcdef
```

It is `lpk_` followed by 32 hexadecimal characters. It is **shown once, at creation, and never again** — only a short prefix is retrievable afterwards, which is enough to tell your keys apart and useless as a credential. If you lose a key, revoke it and create another.

> Keys belong to the platform, not to Realtime. Realtime has no operation of its own that issues one: there is no Realtime route and no SDK method that mints a key. They are created, scoped, listed and revoked in the workspace, on the **Product → Settings → Public Access** page, using your own Lessly login — and the same platform operations are available to an agent as [`organization_public-keys_create`](/reference/mcp-tools/organization_public-keys_create), [`organization_public-keys_list`](/reference/mcp-tools/organization_public-keys_list), [`organization_public-keys_update-scope`](/reference/mcp-tools/organization_public-keys_update-scope) and [`organization_public-keys_revoke`](/reference/mcp-tools/organization_public-keys_revoke).

### Keys you already have

A key your backend was already using — the `rtk_…` form Realtime issued before — keeps working, unchanged, on the same routes. Only the header it travels in has moved.

The platform carried those keys into its own key store in a one-off job, before Realtime's routes started demanding a key. It is not something you request or run, and there is nothing to migrate on your side.

- **The value is the same string, prefix and all.** A key issued as `rtk_…` stays `rtk_…`; it is not reshaped into an `lpk_…` key. Only its hash and its prefix were copied — the secret itself never left the people holding it, so no integration has to be reconfigured.
- **It keeps its name and its creation date**, carries an **Imported** badge on **Public Access**, and lists no creator, because nobody on the platform pressed a button to mint it. A key that had no name reads as `Imported from realtime`.
- **Its scope is the whole Realtime toolkit** — every one of the public routes, not a narrower slice. Narrow it afterwards with **Edit scope** if you want less than that.
- **The plaintext is still not shown.** The workspace never displays an imported key's value, the same as a minted one. Keep using the value you already hold.
- **A key you had already revoked was not carried over**, and revoking on the platform is final.

From then on an imported key behaves like any other product public key: an Owner or Admin can widen or narrow its scope and revoke it. A key created from now on is `lpk_…`. Both forms are presented the same way and work identically everywhere below.

### Create one

On **Product → Settings → Public Access**, **Create public key** asks for two things.

| Field | What it decides |
|---|---|
| Name | How you recognise the key later. Up to 64 characters. |
| Scope | How far the key reaches: every public route of the product, one toolkit, or a single route |

The full secret is shown once, in the dialog, with a copy button. Close the dialog and it is gone.

The key list afterwards shows the name, the truncated key, the toolkits it reaches, the creation date and whether it is active or revoked, with **Edit scope** and **Revoke** on every active row. Creating, scoping and revoking are Owner or Admin actions on the product.

There is no rotation. Rotating a key is three moves — create the replacement, move your callers onto it, then revoke the old one. Both are valid in between, so nothing has to go down during the swap. A revoked key is permanent; there is no un-revoke.

### Give a key change 30 seconds

The platform edge does not read the key store per request. It reads a snapshot it refreshes roughly every 30 seconds, so **creating or revoking a key lands within about one such window, not immediately**. A key you just created can be refused for that long, and a key you just revoked can keep answering for that long — and while it is on its way out it may be refused with `403 public_key_scope` rather than the `401 public_key_invalid` it settles on once the revocation has propagated.

> Do not verify a fresh key in a tight retry loop: you will collect a burst of `401`s that say nothing about the key, and you may hit the edge's rate limit while you are at it. Wait out the window, then make one call.

### Where to keep it

- In your backend's secret store or environment, alongside your database password.
- Never in frontend code, a mobile app bundle, a public repository or a browser request. Anything that reaches a user's device can be read by that user.
- One key per system that needs one, so revoking a leaked key does not take down everything else.

## Send the key

Send the key as a bearer credential on every call to the public HTTP surface, which is rooted at your product:

```http
POST /{productId}/realtime/tokens/issue HTTP/1.1
Host: public.lessly.com
Authorization: Bearer lpk_0123456789abcdef…
Content-Type: application/json
```

`X-Api-Key` is still accepted, carrying the same value, so an integration written against the older header keeps working. `Authorization` wins when both are present. New code should send `Authorization`.

The server SDK does this for you — construct it with `apiKey` and `productId` and it builds the same base URL and sends the same header:

```ts

const realtime = new Realtime({
  apiKey: process.env.LESSLY_REALTIME_API_KEY!,
  productId: process.env.LESSLY_PRODUCT_ID!,
});
```

## When a key is refused

The key is authenticated before Realtime sees the request, so a rejection names the key, not the operation you were attempting.

| Status | `code` | What happened |
|---|---|---|
| `401` | `public_key_required` | No key was presented at all |
| `401` | `public_key_invalid` | The key is not one of this product's active keys — unknown, mistyped, revoked (once the revocation has propagated), or belonging to another product |
| `403` | `public_key_scope` | The key is real, but its scope does not cover the Realtime route you called. A key revoked moments ago can also land here until the revocation propagates |
| `503` | `public_config_unavailable` | No key snapshot is loaded yet and the edge fails closed. Nothing is wrong with your key — retry with backoff |

The body of a key rejection is:

```json
{
  "error": {
    "code": "public_key_invalid",
    "message": "Unauthorized",
    "step": "public_key_check"
  }
}
```

`step` is `public_key_check` on all three key outcomes, so they can be filtered together in a log. Unknown and revoked are deliberately not told apart, and neither is a key that belongs to a different product: all three are the same `public_key_invalid`, so the surface cannot be used to probe for other products.

`public_key_scope` is the one that will not fix itself by retrying: widen the key on **Product → Settings → Public Access** with **Edit scope**, or use a key that already covers Realtime.

## A key belongs to one environment

There are two public edge hosts, and they are separate environments with separate databases.

| Host | Environment |
|---|---|
| `https://public.lessly.com` | Production |
| `https://public.lessly.dev` | Development |

A key is created in one of them and exists only there. Sent to the other host it is not a rejected key, it is a key that was never heard of — so mixing the pair gives a bare `401 public_key_invalid`, the same answer an unknown or revoked key gets, and nothing in the response says which environment answered.

> **This is the single most expensive way to misconfigure Realtime.** The key is correct, the header is correct, and the only thing wrong is the host — but the response reads as a broken credential, so the search starts at the key and can stay there for hours. Before you suspect the key, check the origin your backend is calling against the environment the key was created in. Nothing in the response body will do that for you.

The pairing runs the whole way down. A delegated token is signed with the signing key of the environment that minted it, so it is only good on that environment's service. Hand the browser the `gatewayUrl` that came back with the token rather than a URL of your own, and the connection lands where the token is valid.

Keep one key per environment in that environment's configuration, and let the same configuration carry the matching edge origin, so neither can be changed without the other.

## Mint a token for one user

A delegated token is a short-lived credential your backend mints for one of your end users, naming the exact channels that user may use and what they may do there. Your backend is the authority on who its users are; Realtime takes your word for the subject and enforces the rest.

`POST /tokens/issue`, authenticated with the public key. Through the SDK:

```ts
const { token, gatewayUrl, expiresAt } = await realtime.tokens.issue({
  subject: 'user-42',
  channels: [
    { name: 'chat:room-1', ops: ['subscribe', 'history'] },
    { name: 'chat:room-7', ops: ['subscribe'] },
  ],
  ttlSeconds: 900,
});
```

The request declares:

| Field | Rules |
|---|---|
| `subject` | Your identifier for the end user. 1 to 128 characters of `a-z`, `A-Z`, `0-9`, `.`, `_` and `-`. No colons. |
| `channels` | 1 to 32 entries. Each has a `name` — a **concrete** channel, no wildcards — and `ops`, one or more of `subscribe`, `publish`, `presence`, `history`. |
| `ttlSeconds` | Optional. 60 to 3600. Defaults to 3600. |

The response carries:

| Field | Meaning |
|---|---|
| `token` | The credential to hand to the browser |
| `gatewayUrl` | The WebSocket URL the browser client connects to |
| `expiresAt` | ISO-8601 timestamp at which the token stops working |

## What narrowing does to it

What you declare is a ceiling, not a guarantee. Before the token is signed, each channel is checked against its namespace policy:

- A channel whose namespace is not registered is dropped.
- Operations the namespace does not allow are dropped — `presence` without presence enabled, `publish` without client events enabled, `history` on a namespace retaining nothing.
- If everything you declared is dropped, the call fails with `422` rather than returning a token that could connect and then be refused on every action.

Narrowing only ever removes. A token can never carry more than its namespace allows, whatever your backend asks for. The full policy table is on [Realtime](/ship/realtime).

## External subjects

Your end users live in a different identity space from Lessly platform users. The subject you pass is stored in the token prefixed with `ext:`, so `user-42` becomes `ext:user-42`, and the token is marked as belonging to an external subject. The two spaces cannot collide: naming your own user after a Lessly user id borrows none of that user's authority.

That prefixed subject is what identifies the connection on a channel roster, so it is the value you will see in [presence](/ship/realtime/presence) members for tokens minted this way.

## Refresh a live connection

A token stops working at `expiresAt`. Plan for that rather than minting hour-long tokens by default: a shorter TTL means a revoked or reassigned user loses access sooner.

The browser client is built around this. You give it a **token provider** — a function that calls an endpoint on your backend and returns a fresh token and WebSocket URL — and it calls that function on every connection attempt:

```ts

const client = connect({
  tokenProvider: async () => {
    const res = await fetch('/api/realtime/token', { credentials: 'include' });
    return res.json(); // { token, gatewayUrl }
  },
});
```

Your endpoint decides who the caller is with your own session, then mints for that user and nobody else. A connection whose token has expired is closed by the realtime service; the client fetches a new token and reconnects straight away, with no backoff on that particular case.

To swap the token on a connection that is still open — after the user gains access to another channel, for instance — call `auth()`. With no argument it asks the token provider for a fresh one:

```ts
await client.auth();
```

The call resolves when the service accepts the new token, and rejects if it does not.

## Next steps

- [Send your first realtime message](/ship/realtime/quickstart): the whole loop, from namespace to a message in a tab.
- [Connect a browser tab](/ship/realtime/browser-client): what the client does with the token you hand it.
- [Show who is on a channel](/ship/realtime/presence): mint with `presence` and read the roster.
- [Look up a limit or an error](/ship/realtime/limits-and-errors): what each key refusal, `404` and `422` mean and what to do about each.
