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.

Text
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=… ════════════════════════▶ Thuum

1. 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 R on a few Main Topics and a fallback of 0;
  • 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:

JSON
{ "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-store on the token response.
  • Production uses https:// and wss://.
  • Data that must stay private to some users is not on Main Topics browsers can subscribe to (why).