Authenticate your backend and your users
Hold one product public key on your server, mint a short-lived token from it for each end user, and keep the key and the environment it belongs to in step.
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:
lpk_0123456789abcdef0123456789abcdefIt 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,organization_public-keys_list,organization_public-keys_update-scopeandorganization_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_…staysrtk_…; it is not reshaped into anlpk_…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
401s 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:
POST /{productId}/realtime/tokens/issue HTTP/1.1
Host: public.lessly.com
Authorization: Bearer lpk_0123456789abcdef…
Content-Type: application/jsonX-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:
import { Realtime } from '@lessly/realtime';
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:
{
"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:
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 —
presencewithout presence enabled,publishwithout client events enabled,historyon a namespace retaining nothing. - If everything you declared is dropped, the call fails with
422rather 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.
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 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:
import { connect } from '@lessly/realtime-client';
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:
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: the whole loop, from namespace to a message in a tab.
- Connect a browser tab: what the client does with the token you hand it.
- Show who is on a channel: mint with
presenceand read the roster. - Look up a limit or an error: what each key refusal,
404and422mean and what to do about each.