LiveKit token server (Node)

Issue short-lived JWTs so browser/mobile clients can join a LiveKit room — the missing piece every voice / video MVP needs.

LiveKit token server (Node)

LiveKit clients need a signed JWT to join a room. Issuing one server-side is tiny but the docs spread it across pages — here is the entire endpoint.

Install

npm i livekit-server-sdk

Endpoint

import { AccessToken } from "livekit-server-sdk";

export async function POST(req: Request) {
  const { identity, room } = await req.json();
  if (!identity || !room) {
    return Response.json({ error: "identity and room required" }, { status: 400 });
  }

  const at = new AccessToken(
    process.env.LIVEKIT_API_KEY!,
    process.env.LIVEKIT_API_SECRET!,
    { identity, ttl: "15m" }
  );

  at.addGrant({
    room,
    roomJoin: true,
    canPublish: true,
    canSubscribe: true,
    canPublishData: true,
  });

  const token = await at.toJwt();
  return Response.json({ token, url: process.env.LIVEKIT_URL });
}

Client

import { Room } from "livekit-client";

export async function joinRoom(identity: string, room: string) {
  const res = await fetch("/api/livekit-token", {
    method: "POST",
    body: JSON.stringify({ identity, room }),
  });
  const { token, url } = await res.json();

  const r = new Room({ adaptiveStream: true, dynacast: true });
  await r.connect(url, token);
  await r.localParticipant.setMicrophoneEnabled(true);
  return r;
}

Things that bit me in production

  • TTL too long. Default is 6 hours. Set ttl: "15m" and refresh — leaked tokens stay valid forever otherwise.
  • canPublishData. Needed for the data channel that Pipecat agents use to ship intent / state events alongside audio.
  • CORS. Production-host the token server on the same origin as the page — don't punch wildcard CORS holes for it.
  • Identity. Use a stable user id, not a random UUID per session. participant.identity is what your agent's on_first_participant_joined event fires on.

How it works

The endpoint is a single POST handler. It reads identity and room from the request body and rejects the call with a 400 if either is missing — a cheap guard that stops malformed clients from getting a token. It then constructs an AccessToken using the LiveKit API key and secret, both pulled from environment variables, and sets a 15-minute TTL so the token expires quickly. The addGrant call is the authorization payload baked into the JWT: it names the room and enables roomJoin, plus the publish and subscribe permissions the client needs — including canPublishData, which opens the data channel that a Pipecat agent uses to exchange intent and state events alongside the audio. at.toJwt() signs the token, and the response returns it together with the server URL the client will connect to.

The client helper closes the loop. joinRoom POSTs the identity and room to that endpoint, reads back the token and URL, and creates a Room with adaptiveStream and dynacast enabled — two LiveKit features that reduce bandwidth by adapting stream quality to what each subscriber actually needs. It connects with the URL and token, enables the microphone, and returns the live room object.

Notes & gotchas

The single most important rule: the token is signed with your LiveKit API secret, so this code must run server-side and the secret must never reach the browser. The client only ever sees a short-lived, scoped JWT — it never sees the key that produced it. That is the whole reason a token server exists.

The defaults will bite you if you trust them. LiveKit's default TTL is six hours; a leaked token at that length stays usable for most of a workday, so the explicit ttl: "15m" and a refresh strategy are deliberate. Scope the grants to what the client genuinely needs rather than handing out broad publish rights by reflex. Host the token endpoint on the same origin as the page so you are not punching wildcard CORS holes to reach it. And use a stable user id for identity rather than a fresh UUID each session, because that identity is what a server-side agent keys its participant-joined logic on — randomize it and the agent can't reliably recognize returning users. For anything beyond an MVP, add authentication in front of this endpoint so only logged-in users can request a token at all.