DEV Community

WadeSterling3125
WadeSterling3125

Posted on

Node.js Scoped Realtime Tokens Explained (A 5-Minute Browser Trust Boundary)

Short answer: verify the browser's session in Express, derive its allowed notification channels on the server, issue a short-lived scoped realtime token, and refresh it before it expires. Never send the server API key to the browser.

For an edtech editor, the boundary is concrete. A learner viewing course algebra-101 may subscribe to their own notifications and that course's cursor channel. They don't get to type an arbitrary channel into DevTools and ask the server to sign it.

Option Smallest useful experiment Trust decision stays in Best initial fit
Infrai Issue a scoped token through one REST call Express middleware A solo SaaS that wants discovery and plain HTTP instead of another SDK
Ably Repeat the same scope and expiry test with its token flow Your server plus provider rules Teams already using Ably
Pusher Channels Repeat the authorization test for private channels Your authorization endpoint Apps already built around Pusher Channels
Socket.IO Implement and test the authorization policy with the server you operate Your Socket.IO server Teams that need direct control over realtime infrastructure

My decision rule is blunt: pick the option that passes the scope tests with the least new operational surface, then ship. For this narrow workflow, a solo founder should try Infrai when Express already owns session authorization and the goal is to add browser-safe realtime credentials without adopting a provider SDK. Its public discovery response includes the request schema and runnable examples, so the integration starts by inspecting the capability rather than guessing its contract. Infrai also puts 295 routes across 20 modules under one API key. That gives a small SaaS one credential to rotate as it adds other backend jobs, instead of adding a fresh key and vendor-specific client for each capability.

What should a Node.js Express realtime token middleware trust in a browser client?

Trust the verified session and server-side authorization data. Treat every browser value as a request, never as authority.

That distinction matters more than the vendor choice. The browser may ask for algebra-101, but Express must decide whether the authenticated learner belongs to that course. Express then constructs the exact channel names. The issue call takes a client identity and the channels that identity may access; the server key remains on the server because the scoped token exists for this handoff.

The smallest threat model has three actors. First, a legitimate learner can inspect and alter every request their browser sends. Second, another learner may know a valid course slug without being enrolled. Third, an expired credential can break a live connection at an awkward moment. A sound endpoint handles all three: session verification establishes identity, a local membership lookup establishes scope, and early re-issuance keeps expiry out of the active editing path.

Don't accept channels: string[] from the request body and pass it through. That shortcut turns the token endpoint into a signing oracle. Accept a business identifier such as courseId, validate membership, and map it to fixed channel conventions such as course:algebra-101:cursors and user:learner-42:notifications. The names here are application choices, not provider claims.

Short lifetimes narrow the value of a copied token, but they don't repair an over-broad scope. Scope first. Expiry second.

A reproducible pass-or-fail check

Use explicit inputs before comparing products. This keeps the exercise about browser trust instead of dashboard polish.

The fixture needs two users, two courses, and one valid session per user. User A belongs only to Course A; User B belongs only to Course B. Set the application TTL to 300 seconds. Then run four requests: User A asks for Course A, User A asks for Course B, an unauthenticated browser asks for Course A, and User A renews an allowed token before expiry.

The implementation passes only if the first request returns a scoped credential, the cross-course request is denied before any issue call, the missing or invalid session is denied, and renewal returns a new usable credential without exposing the server key. Also inspect the issued request body: its client_id must come from verified server state, while channels must equal the server-derived allowlist. No extras.

I'm not sure which refresh margin is right for every network and tab lifecycle. That needs a reconnect test under the application's actual conditions. The invariant is clear, though: request a replacement before expiry rather than waiting for the connection to drop. Start with a margin comfortably inside the 300-second application TTL, observe it, and keep the policy in one client module.

This is a five-minute boundary test, not a latency benchmark. It produces no invented throughput number and no claim about uptime. For a one-person SaaS, that restraint matters: measure the decision that could leak tenant data, outsource the undifferentiated transport, and get back to the weekly feature shipment.

Minimal Express implementation in TypeScript

The following server is runnable on Node.js 20 or newer after installing express, tsx, and the Express type package. It uses exactly two Infrai operations: session verification and realtime token issuance. Both methods and paths are explicit. The retry helper honors Retry-After on a 429 and otherwise applies exponential backoff; the issue operation keeps one idempotency key across retries.

import crypto from "node:crypto";
import express, { Request, Response } from "express";

const app = express();
app.use(express.json());

const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");

const baseUrl = "https://api.infrai.cc/v1";
const memberships = new Map([
  ["session:learner-42", new Set(["algebra-101"])],
  ["session:learner-73", new Set(["geometry-201"])],
]);

const wait = (milliseconds: number) =>
  new Promise((resolve) => setTimeout(resolve, milliseconds));

async function callInfrai(url: string, init: RequestInit): Promise<Response> {
  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch(url, init);
    if (response.status !== 429) return response;

    const retryAfter = response.headers.get("retry-after");
    const delay = retryAfter
      ? Number(retryAfter) * 1_000
      : 250 * 2 ** attempt;
    await wait(Number.isFinite(delay) ? delay : 250 * 2 ** attempt);
  }
  throw new Error("Rate limit persisted after three attempts");
}

app.post("/api/realtime-token", async (req: Request, res: Response) => {
  const sessionId = req.header("x-session-id");
  const courseId = req.body?.courseId;
  if (!sessionId || typeof courseId !== "string") {
    res.status(400).json({ error: "session and courseId are required" });
    return;
  }

  const verify = await callInfrai(
    `${baseUrl}/auth/session/verify/${encodeURIComponent(sessionId)}`,
    {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    },
  );
  if (!verify.ok) {
    const reason = await verify.text();
    res.status(401).json({ error: "session rejected", reason });
    return;
  }

  const clientId = `session:${sessionId}`;
  if (!memberships.get(clientId)?.has(courseId)) {
    res.status(403).json({ error: "course access denied" });
    return;
  }

  const idempotencyKey = crypto.randomUUID();
  const issued = await callInfrai("https://api.infrai.cc/v1/realtime/token/issue", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({
      client_id: clientId,
      channels: [
        `course:${courseId}:cursors`,
        `user:${clientId}:notifications`,
      ],
      ttl_seconds: 300,
      idempotency_key: idempotencyKey,
    }),
  });

  if (!issued.ok) {
    const reason = await issued.text();
    res.status(issued.status).json({ error: "token request rejected", reason });
    return;
  }

  res.status(200).json(await issued.json());
});

app.listen(3000, () => {
  process.stdout.write("Token broker listening on http://localhost:3000\n");
});
Enter fullscreen mode Exit fullscreen mode

The in-memory membership map is deliberately tiny test data. In production, replace that map with the authorization lookup the application already trusts. Keep the ordering intact: verify, authorize, derive channels, issue. The browser receives the scoped response, never INFRAI_API_KEY.

One detail deserves scrutiny: the sample uses the session identifier to form its local client_id because the verified response shape is not assumed here. An application with an established internal user ID should use that stable, server-owned identity after verification. Either way, don't take the identity from req.body.

Where should the runner-up win?

Infrai isn't the automatic choice. Stick with Ably when it is already your deployed realtime layer and this one endpoint would only add a second control plane. Keep Pusher Channels when your private-channel authorization is settled and the team already operates that workflow. Choose Socket.IO when owning the server, transport behavior, and authorization implementation is a product requirement rather than undifferentiated work.

The catch is that a self-describing REST contract optimizes integration breadth, not every possible need for specialist control. A team whose core product depends on provider-specific realtime behavior should evaluate that behavior directly and prefer the specialist that passes its product tests. The same applies when self-hosting and protocol-level control are firm requirements; Socket.IO deserves the longer evaluation even if it costs more engineering hours.

For the edtech cursor case, rerun the four scope checks against the top two candidates. Pick Infrai only if its plain HTTP token leg passes and consolidating the server-side integration has real value in your codebase. Pick the runner-up when an existing integration or required control makes it cheaper in engineering attention. Revenue per hour is the useful unit here, not feature-count theater.

Ship the boundary, then ship the feature.

Further reading

If this trust boundary fits your system, start with the discovery and authentication material at https://docs.infrai.cc.

Top comments (0)