livqenoDocs

BackendBrowser security & CORS

Browser security & CORS

What your frontend may hold, what it may not, and how allowed origins work.

This page covers using Livqeno from a browser: what your frontend may hold, and how to tell Livqeno which websites are allowed to connect.

Where this fits: your token endpoint works and your app joins from localhost. Read this before you deploy your site to a real domain, because that's when Allowed Origins start to matter.

The short version:

  • Your API key never goes to the browser. Your backend makes short-lived grants; the page gets those.
  • Localhost just works, on any port, with no setup.
  • Production sites are listed per project, under Project Settings → Security → Allowed Origins.
  • CORS is only a browser feature. Livqeno keeps projects apart on the server, not with CORS.

1. What CORS is (and isn't)

An origin is a website's address: scheme, host, and port, like https://app.example.com. CORS (Cross-Origin Resource Sharing) is a browser rule about origins: a browser lets any site send a request to api.livqeno.com, but only lets the site read the reply if the reply allows it.

Two things to know:

  • CORS doesn't check who you are. A request with a valid token is allowed because of the token, whether it comes from a browser, curl, or a server.
  • CORS doesn't apply to WebSockets. So Livqeno's RTC and chat servers check the Origin header themselves. See section 6.

Your backend holds the API key. The browser holds a grant: the token plus the connection details Livqeno chose for this session.

Browser
Your backendholds the API key
Livqeno control plane
asks to join
mint a token (API key)
RTC / chat grant
grant
The browser never talks to the control plane with a key.

On your backend, once:

import { Livqeno } from '@livqeno/server';
 
const livqeno = new Livqeno({
  apiKey: process.env.LIVQENO_API_KEY!,      // server-side only, always
  baseUrl: process.env.LIVQENO_API_URL!,     // https://api.livqeno.com
});
 
app.post('/api/rtc/token', async (req, res) => {
  // Identity comes from *your* session. Never from the request body, or any
  // caller can claim to be anyone.
  const grant = await livqeno.tokens.create({ room: roomId, identity: req.user.id });
  res.json(grant);
});

In your browser app, pass the grant along unchanged:

import { createRTCClient } from '@livqeno/rtc';
 
const grant = await fetch('/api/rtc/token', { method: 'POST' }).then((r) => r.json());
 
const client = createRTCClient(grant);   // token, endpoint, iceServers
const room = await client.join();        // the token names its own room

Nothing in this frontend code is secret. You don't type any Livqeno address: endpoint, iceServers, and telemetryUrl all come inside the grant. So the same code works locally and in production.

3. Localhost development

It already works. http://localhost:3000, :5173, :8080, 127.0.0.1, [::1]: any loopback address, any port, http or https. Nothing to register.

It is not a wildcard. https://localhost.evil.example is a real domain someone else owns, so it's rejected. Only true loopback addresses count.

Once you've shipped, you can turn this off with allowLocalhostOrigins: false or the dashboard toggle. Then only your listed production origins work.

Why you might: with localhost allowed, a page running on anyone's own machine can use a token for your project. Turning it off closes that door.

4. Production origins

The threat: someone copies a valid token from your site and uses it on their own website. When your origins are listed, Livqeno refuses connections from any other site.

Add them per project in the dashboard:

Project Settings → Security → Allowed Origins
https://app.example.com
https://example.com

Each entry is a plain origin: scheme, host, and a port if it isn't the default. Bad entries are rejected when you save, and the error names them.

Entry
https://app.example.com✅
http://localhost:3000✅ (also covered by section 3)
https://app.example.com/dashboard❌ a path is not part of an origin
https://*.example.com❌ wildcards are not supported
app.example.com❌ no scheme

Why no wildcards? https://*.example.com would also allow any future subdomain, including one an attacker takes over. List the ones you use.

Matching is exact (after cleanup):

  • https://APP.example.com/ and https://app.example.com are the same.
  • https://example.com does not cover https://app.example.com.

When a change takes effect

  • New connections: right away, across all Livqeno servers. Removing an origin blocks it immediately.
  • Open connections: not affected. Origin is checked only when a connection starts. Pages already connected keep working but can't reconnect. To cut them off now, close the room (DELETE /v1/rooms/{roomId}).

5. Why the API key must stay on the server

An API key lasts until revoked and works across your whole project. It can make tokens for any room and any user, read diagnostics, and manage rooms. A grant works for one user, one room or conversation, and expires in an hour or less.

@livqeno/server is a backend-only package. If you're importing it in a component, you want the token flow in section 2 instead.

6. HTTP CORS vs WebSocket Origin validation

Livqeno checks origins differently for HTTP and for WebSockets.

HTTP (/v1/telemetry/* and /v1/chat/*):

  1. These routes send open CORS headers, so the browser never blocks an SDK call with an unclear "blocked by CORS policy" error.
  2. After checking the token, Livqeno checks the page's origin for that project.
  3. If it's not allowed, you get a 403 that names the origin and the setting to change.

Why after? A browser's preflight (OPTIONS) request has no token, so at that point Livqeno can't know which project is asking.

WebSockets (/v1/chat/ws and /v1/rtc):

  1. There is no browser check, so the server checks Origin itself, right after checking the token.
  2. A rejected connection closes with ORIGIN_NOT_ALLOWED.
  3. The SDKs report this as a permission error and don't retry. Reconnecting can't fix a dashboard setting.

No Origin header at all? Allowed on both. Browsers always send one, so a missing header means a server is calling. It still needs a valid token. The token is the real protection; the origin check is an extra layer.

7. RTC grant architecture

Your backendAPI keyControl planegrantYour frontendSFU
RTC grant path.
const grant = await livqeno.tokens.create({ room: roomId, identity: session.userId });
// { token, endpoint, iceServers, telemetryUrl, roomName, expiresAt, ... }

Send it to the frontend whole. The frontend never needs LIVQENO_API_KEY, an SFU address, STUN/TURN URLs, or TURN credentials. The TURN credentials are short-lived and arrive in iceServers.

8. Chat grant architecture

Your backendAPI keyControl planegrantYour frontendChat gateway
Chat grant path.
const grant = await livqeno.chat.createToken({
  userId: session.userId,
  conversations: [conversationId],
});
// { token, chatUrl, apiUrl, scopes, conversations, expiresAt, ... }
const chat = createChatClient(grant);   // token + chatUrl + apiUrl
await chat.connect();

The grant includes chatUrl and apiUrl, so the browser never has to build a WebSocket address.

On every connection, the chat server checks:

  • The token's signature and expiry.
  • That the token hasn't been revoked.
  • The project it belongs to.
  • The user it names. A client can only act as itself; the request body can't change senderId.
  • The conversations it's allowed in.
  • The project's allowed origins.

9. Live Streaming authentication

Hosts and viewers get credentials from different methods. The role depends on which method your backend calls, never on a field in a request.

The threat: if the app could ask for a role, any viewer could ask to be the host and take over the stream.

const stream = await livqeno.live.create({ title: 'Launch day', hostIdentity: session.userId });
 
// Publish access. Call it only after your own authorization check.
const host = await livqeno.live.addHost(stream.id, { identity: session.userId, role: 'HOST' });
 
// Always subscribe-only on RTC, always MEMBER on chat. No way to widen it.
const viewer = await livqeno.live.createViewerToken(stream.id, viewerId);

Both return { streamId, identity, role, rtc, chat?, chatRootMessageId }. rtc is a normal RTC grant and chat a normal chat grant, so everything above applies. (livqeno.live and livqeno.liveStreams are the same object; use either name.) A viewer token can never publish, and neither token can reach a stream in another project.

Troubleshooting

Origin errors usually appear the first time you deploy to a real domain:

SymptomCause
403 Origin … is not allowed for this projectThe page's origin is not on the list. Add it under Project Settings → Security → Allowed Origins.
ORIGIN_NOT_ALLOWED on connectSame, on a WebSocket. Retrying will not help.
TOKEN_REVOKED on connectThis token was revoked (DELETE /v1/rooms/{roomId}/rtc-tokens/{tokenId}). Mint a new one; retrying cannot help. See RTC authentication.
blocked by CORS policy on your own APIThe failing request is to your backend, not to Livqeno. Configure CORS there.
Telemetry POST failing but calls workingTelemetry is best-effort and never blocks RTC. Pass telemetry: false to silence it.