Skip to content

Authenticate your backend and your users

Keep product keys on the backend and delegate scoped tokens to Realtime clients.

Keep a product public key on your backend and use it to issue a short-lived delegated token for each signed-in user. The browser receives only that token, with access to the channels and operations your backend allows.

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

Before you start

  • Owner or Admin access to the product to create or change a public key.
  • A backend with a secret store and a way to identify signed-in users.
  • A registered namespace for the channels you will authorize.
  • A public edge origin in the same environment as your key; production and development keys are separate.

The product public key

A product public key authorizes requests to the public routes in its scope for one product. New keys use this format; this value is an example:

lpk_0123456789abcdef0123456789abcdef

The format is lpk_ followed by 32 hexadecimal characters. Copy the secret when you create it: it is shown only once. Later, you can retrieve only its short prefix. If you lose the secret, revoke that key and create a replacement.

Manage keys in Product → Settings → Public Access with your Lepsto login. Agents use the platform tools organization_public-keys_create, organization_public-keys_list, organization_public-keys_update-scope and organization_public-keys_revoke. Realtime routes and SDK methods do not manage these keys.

Keys you already have

Existing rtk_… keys still work on the same routes. Keep the value you already use and send it with the header described below.

The platform has already imported active legacy keys into its key store. You do not need to run a migration.

  • The secret and prefix stay unchanged: an rtk_… key remains an rtk_… key. Only its hash and prefix were copied.
  • The name and creation date are preserved. Public Access shows an Imported badge and no creator; unnamed keys appear as Imported from realtime.
  • Imported keys initially cover all Realtime public routes. Use the management API to limit one to individual routes.
  • The plaintext is never displayed, including for imported keys.
  • Previously revoked keys were not imported. Revocation on the platform is permanent.

Owners and Admins can change an imported key’s scope or revoke it just as they can for a new lpk_… key. Both formats use the authentication steps below.

Create one

  1. Open Product → Settings → Public Access.
  2. Select Create public key.
  3. Enter a name and choose a scope that covers the Realtime routes you need.
Field
Purpose
Name
A label you will recognize later; up to 64 characters
Scope
All toolkits, or Selected toolkits with Realtime selected. Each selected toolkit grants access to all its keyed routes.
  1. Copy the full secret from the dialog into your backend’s secret store before closing it.

To limit a key to individual routes, use the management API. The key list marks it as limited to specific routes. Opening Edit scope in the app uses the toolkit-level editor; saving replaces the route scope and widens the grant.

The key list shows each key’s name, truncated value, toolkit scope, creation date and active or revoked status. Active rows have Edit scope and Revoke actions, available to Owners and Admins.

To rotate a key, create a replacement, update your callers, then revoke the old key. Both keys remain valid during the changeover. There is no separate rotation operation, and a revoked key cannot be restored.

Give a key change 30 seconds

Allow about 30 seconds for creation, scope changes, or revocation to reach the edge. It refreshes a key snapshot roughly every 30 seconds, so a new key may initially be rejected and a revoked key may briefly keep working. Once revocation propagates, requests return 401 public_key_invalid. 403 public_key_scope means an active, valid key does not cover the requested route.

Wait for that window before testing a new key. A tight retry loop can hit the edge rate limit without telling you whether the key is configured correctly.

Where to keep it

  • Store keys in backend secrets or environment variables.
  • Keep them out of frontend code, mobile bundles, public repositories and browser requests. Users can read anything sent to their device.
  • Use a separate key for each system so you can revoke one without affecting the others.

Send the key

Send Authorization: Bearer <key> on each public HTTP request. Replace the example product ID and key with your own:

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

Older integrations can still send the same key in X-Api-Key. If both headers are present, Authorization takes precedence. Use Authorization for new integrations.

On your backend, createRealtime from @lepsto/sdk/realtime builds the product URL and sends the header for you:

import { createRealtime } from '@lepsto/sdk/realtime';

const realtime = createRealtime({
  productId: process.env.LEPSTO_PRODUCT_ID!,
  publicKey: process.env.LEPSTO_REALTIME_API_KEY!,
});

For namespace, grant or webhook management, use a bearer token and explicit base URL with @lepsto/sdk/realtime/server. A product public key does not authorize those operations.

When a key is refused

The edge checks your key before sending a request to Realtime. Use the error code to identify what to change:

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 active and valid, but its scope does not cover the Realtime route you called
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"
  }
}

The three key errors include step: public_key_check, which you can use to filter logs. Unknown, revoked and other-product keys all return public_key_invalid; the response does not reveal whether another product owns a key.

For a persistent public_key_scope error, use Edit scope on Product → Settings → Public Access, or choose a key that already covers the route. Retrying does not expand its scope.

A key belongs to one environment

Choose the host for the environment where you created the key. These environments have separate databases:

Host
Environment
https://public.lepsto.com
Production
https://public.lepsto.dev
Development

A key sent to the other environment returns 401 public_key_invalid, just like an unknown or revoked key. The response body does not identify the environment.

When troubleshooting a 401, check the origin and product ID before replacing the key.

Delegated tokens are also specific to the environment that signed them. Return the issued gatewayUrl to the browser so it connects to the service that can verify the token.

Store each environment’s key and matching edge origin together in its backend configuration.

Mint a token for one user

Your backend must authenticate the caller and decide which channels they may use before issuing a token. Realtime trusts the subject you supply, then applies namespace policy to the requested operations.

Call POST /tokens/issue with your public key, or use the SDK as below. Replace user-42 with the authenticated user’s ID and the channel names with channels they are allowed to access:

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

Before signing a token, Realtime checks each requested channel and operation against namespace policy:

  • Channels in unregistered namespaces are removed.
  • presence requires presence to be enabled; publish requires client events; history requires retention. Operations without the required setting are removed.
  • If no capabilities remain, issuance fails with 422.

The issued token can contain less access than you requested, but never more than namespace policy permits. See the namespace policy table.

External subjects

Realtime prefixes your end-user subject with ext: and marks it as external. For example, user-42 becomes ext:user-42. This separates your users from Lepsto platform identities, even if their unprefixed IDs match.

Use the prefixed identity when matching members in a presence roster.

Refresh a live connection

Choose a TTL that fits how quickly access changes must take effect. The token expires at expiresAt; a shorter lifetime makes your backend reassess a revoked or reassigned user’s access sooner.

Give the browser a token provider that calls your backend and returns a fresh token and WebSocket URL. The client runs it before every connection attempt:

import { connectRealtime } from '@lepsto/sdk/realtime';

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

At this endpoint, identify the caller from your session and issue only the access they currently have. When a token expires, the service closes its connection. The client immediately fetches a new token and reconnects, without backoff for that expiration case.

To change access while the connection is open, call auth() without an argument. It fetches a fresh token through the provider:

await socket.auth();

The promise resolves when the service accepts the token and rejects if it refuses it.

Verify it works

  1. After the key propagation window, issue a token for a registered channel. Confirm the response contains token, gatewayUrl and expiresAt.
  2. Connect a browser using that response and subscribe to the authorized channel. Publish a backend message and confirm the browser receives it.
  3. Check the browser’s requests: it should receive the delegated token, never your product public key.

Next steps

Was this page helpful?
Esc

Start typing to search the docs.

↑↓navigate↵select