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.
"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.
"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
debugorauditoff 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
- React hooks renders typed messages with
useRealtime. - Subscribing reference documents
getClientSubscriptionToken(),subscribe(), and message shapes. - Troubleshooting helps when a client can't connect.