Subscription tokens

Authorize each viewer on your server and give the browser a token for one channel and the topics it needs.

A browser can't subscribe to a Realtime channel on its own. Your server checks who the user is, decides what they may read, and mints a subscription token for one channel and a list of topics. The browser uses that token to connect. Your Inngest signing key never leaves the server, and you control access in one place.

Mint a token

Call getClientSubscriptionToken() on your server. It returns a { key, apiBaseUrl } object that's safe to send to the browser.

app/actions.ts
"use server";

import { getClientSubscriptionToken } from "inngest/react";
import { inngest } from "@/inngest/client";
import { aiChannel } from "@/inngest/channels";

export async function fetchAIToken(threadId: string) {
  return getClientSubscriptionToken(inngest, {
    channel: aiChannel({ threadId }),
    topics: ["status", "tokens", "result"],
  });
}

getClientSubscriptionToken() isn't React-specific. It runs on the server in any framework; the SDK exports it from inngest/react alongside useRealtime.

The server sets apiBaseUrl from INNGEST_DEV, so the browser connects to the right environment. You don't need NEXT_PUBLIC_INNGEST_DEV, VITE_INNGEST_DEV, or any other browser-side variable.

Authorize before you mint

Anyone who holds a token can read every message on its channel and topics until the token expires. Check permissions in the code that mints it, where you still have the user's session.

app/actions.ts
"use server";

import { getClientSubscriptionToken } from "inngest/react";
import { inngest } from "@/inngest/client";
import { documentChannel } from "@/inngest/channels";
import { getSession } from "@/lib/session";
import { db } from "@/lib/db";

export async function fetchDocumentToken(documentId: string) {
  const { userId } = await getSession();
  if (!userId) throw new Error("Not authenticated");

  // Confirm this user may read this document before minting a token for it.
  const document = await db.documents.findFirst({
    where: { id: documentId, ownerId: userId },
  });
  if (!document) throw new Error("Not found");

  return getClientSubscriptionToken(inngest, {
    channel: documentChannel({ documentId }),
    // Grant only the topics this view needs.
    topics: ["status", "result"],
  });
}

Never pass an unchecked request parameter straight into your channel function. If you do, a caller can mint a token for someone else's data.

Keep each token narrow:

  • One channel per token. The channel is the security boundary. If a user watches three documents, mint three tokens.
  • Only the topics the view needs. A client can't subscribe to a topic its token omits, so keep internal topics such as debug or audit off browser tokens.
  • Take user IDs from the session, not from the request body.

Refresh tokens automatically

Tokens are short-lived. Pass useRealtime a token factory instead of a token. The hook calls the factory when it connects and again on every reconnect, so it always has a fresh token.

app/thread.tsx
"use client";

import { useRealtime } from "inngest/react";
import { aiChannel } from "@/inngest/channels";
import { fetchAIToken } from "./actions";

export function Thread({ threadId }: { threadId: string }) {
  const { messages, connectionStatus } = useRealtime({
    channel: aiChannel({ threadId }),
    topics: ["status", "tokens", "result"] as const,
    // Called on connect and on each reconnect.
    token: () => fetchAIToken(threadId),
  });

  return (
    <p>
      {connectionStatus}: {messages.byTopic.status?.data.message}
    </p>
  );
}

If a server loader passes data to your component, you can hand useRealtime a token object instead. Still pass channel and topics: the client token holds only key and apiBaseUrl, and the hook uses the channel and topics to type your messages.

const { threadId, realtimeToken } = useLoaderData<typeof loader>();

const { messages } = useRealtime({
  channel: aiChannel({ threadId }),
  topics: ["status", "tokens"] as const,
  token: realtimeToken,
});

Use a factory for long-lived views so the hook can get a fresh token when it reconnects.

Framework examples

Each example should authorize the user before it mints the token, as shown above.

// app/actions.ts
"use server";

import { getClientSubscriptionToken } from "inngest/react";
import { inngest } from "@/inngest/client";
import { pipelineChannel } from "@/inngest/channels";

export async function getRealtimeToken(contentId: string) {
  // Authorize the user for contentId here.
  return getClientSubscriptionToken(inngest, {
    channel: pipelineChannel({ contentId }),
    topics: ["status", "tokens"],
  });
}

Subscribe from the server without a token

Server-side code that has your Inngest client doesn't need a token. inngest.realtime.subscribe() and subscribe({ app: inngest, ... }) authenticate with the client's signing key. See Server-side subscriptions.

To get a full token on the server, for example to pass to subscribe() yourself, call inngest.realtime.token({ channel, topics }) or getSubscriptionToken() from inngest/realtime. These tokens include the channel and topics. Don't send them to a browser.

Next steps