Guides
Connect browsers securely
Keep the App Key on your server and give browsers short-lived tokens through a token route, with Express, Next.js or plain Node.js.
Browsers should never see an App Key. Instead, your server exposes a token route: it checks who is asking, exchanges the key for a token, and returns only the token. The browser uses it to open a WebSocket, and asks again for every new connection.
browser ──GET /api/thuum-token──▶ your server ──POST /api/acme/token {key}──▶ Thuum
browser ◀──── { token } ───────── your server ◀──── { token, expires_in } ─── Thuum
browser ════ wss://…/api/acme/event/connect?token=… ════════════════════════▶ Thuum1. Create a key for browsers#
In the console, create an App Key just for this route, for example web-browser:
- a short token TTL, 30–60 seconds: the token is only needed for the WebSocket handshake;
- Event Broker and State Broker claims that grant browsers what they need and nothing else, usually
P S Ron a few Main Topics and a fallback of0; - no Domain Claim.
See Set up your Domain and Security model.
2. Add the token route#
The route must run your own authentication first: only signed-in users (or whoever you decide) get a
token. Return no-store so no cache keeps it.
import express from 'express';
const app = express();
const API = process.env.THUUM_API_URL; // e.g. https://api.thuum.dev
app.get('/api/thuum-token', requireUser, async (req, res) => {
const response = await fetch(`${API}/api/acme/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: process.env.THUUM_BROWSER_KEY })
});
if (!response.ok) {
console.error('Thuum token exchange failed', response.status);
return res.status(502).json({ error: 'realtime_unavailable' });
}
const { data } = await response.json();
res.set('cache-control', 'no-store').json({ token: data.token, expiresIn: data.expires_in });
});
// Your own middleware: rejects requests without a valid session.
function requireUser(req, res, next) {
if (!req.session?.userId) return res.status(401).end();
next();
}// app/api/thuum-token/route.ts
import { ThuumClient, isApiError } from '@thuum/client';
import { getSession } from '@/lib/session'; // your own auth
const thuum = new ThuumClient({ baseUrl: process.env.THUUM_API_URL! });
export async function GET() {
const session = await getSession();
if (!session) return new Response(null, { status: 401 });
try {
const { token, expires_in } = await thuum.exchangeToken('acme', process.env.THUUM_BROWSER_KEY!);
return Response.json(
{ token, expiresIn: expires_in },
{ headers: { 'cache-control': 'no-store' } }
);
} catch (e) {
console.error('Thuum token exchange failed', isApiError(e) ? e.status : e);
return Response.json({ error: 'realtime_unavailable' }, { status: 502 });
}
}// Plain node:http, no framework.
import { createServer } from 'node:http';
const API = process.env.THUUM_API_URL;
createServer(async (req, res) => {
if (req.method !== 'GET' || req.url !== '/api/thuum-token') {
res.writeHead(404).end();
return;
}
if (!(await isSignedIn(req))) {
res.writeHead(401).end();
return;
}
const response = await fetch(`${API}/api/acme/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: process.env.THUUM_BROWSER_KEY })
});
if (!response.ok) {
res.writeHead(502).end();
return;
}
const { data } = await response.json();
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
res.end(JSON.stringify({ token: data.token, expiresIn: data.expires_in }));
}).listen(3000);3. Connect from the browser#
import { EventBrokerSocket } from '@thuum/client';
async function connect() {
const response = await fetch('/api/thuum-token', { cache: 'no-store' });
if (!response.ok) throw new Error(`Token route answered ${response.status}`);
const { token } = await response.json();
const socket = new EventBrokerSocket({ baseUrl: 'https://api.thuum.dev', domain: 'acme', token });
await socket.ready;
return socket;
}
const socket = await connect();
socket.onMessage((event) => render(event));
await socket.sub('chat:lobby');async function connect() {
const response = await fetch('/api/thuum-token', { cache: 'no-store' });
if (!response.ok) throw new Error(`Token route answered ${response.status}`);
const { token } = await response.json();
const ws = new WebSocket(
`wss://api.thuum.dev/api/acme/event/connect?token=${encodeURIComponent(token)}`
);
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, { once: true });
ws.addEventListener('close', () => reject(new Error('Handshake refused')), { once: true });
});
return ws;
}
const ws = await connect();
ws.addEventListener('message', (message) => {
for (const op of JSON.parse(message.data)) if (op.op === 'message') render(op);
});
ws.send(JSON.stringify([{ op: 'sub', topic: 'chat:lobby', ref: '1' }]));The token is used once, immediately. When the connection drops, call connect() again: a new token,
a new socket. Build reliable clients adds backoff and catch-up.
Hand out the base URL too#
If your API URL differs per environment, return it from the token route along with the token, so the browser bundle needs no configuration:
{ "baseUrl": "https://api.thuum.dev", "domain": "acme", "token": "eyJ…" }That object is exactly the options EventBrokerSocket and StateBrokerSocket take:
new EventBrokerSocket(await response.json()).
Checklist#
- The App Key is only in server-side environment variables or a secret manager.
- The token route checks your user's session and is rate-limited.
- The browser key's token TTL is short (30–60 s).
- The browser key's claims list Main Topics explicitly; fallbacks are
0; no Domain Claim. -
cache-control: no-storeon the token response. - Production uses
https://andwss://. - Data that must stay private to some users is not on Main Topics browsers can subscribe to (why).