Skip to main content
Architecture

Authenticate Users End to End

Log users in on your backend, mint a JWT scoped to each user's Rivet Actor, connect from the browser with it, and authorize actions inside the Actor.

See the full example on GitHub. View on GitHub
Your backendBrowseryour appPOST /tokenRivet control planeverifies the JWTuserProfileauthorizes actionslog in1issueToken2JWT3connect with the JWT4recordVisit5One per userRivet ActorCallReply

What we’ll build:

  • A /token route that logs the user in with your existing session system and mints a Rivet JWT for that user.
  • A JWT that reaches one userProfile Actor and nothing else, enforced before a request touches your code.
  • A browser client that connects with the JWT and renews it automatically when it expires.
  • A permission check inside the Actor that decides what a connected user may do.

Install

npm add rivetkit hono

Keep the Rivet token on the backend

Only your backend holds a credential that can mint tokens. Set it once:

export RIVET_ENDPOINT="http://localhost:6420"
export RIVET_NAMESPACE="production"
export RIVET_ADMIN_TOKEN="$(openssl rand -hex 32)"
  • The Rivet token is the control plane’s auth.admin_token value. It works in every namespace, so it never leaves your backend. On Rivet Cloud, use a secret key instead.
  • A browser cannot hold a secret, because anyone can read it out of the bundle. That is why it gets a short-lived JWT instead. See Authentication for which credential to use where.
  • Issuing and verifying JWTs is on by default when a self-hosted control plane has a Rivet token.

Mint a scoped token at login

The /token route authenticates the user, resolves the Actor they own, and asks for a JWT that reaches only that Actor:

  • Replace authenticateUser with your own session check. A request without a user gets a 401 and no token.
  • getOrCreate(["user", userId]) runs on the backend, so the client never names an Actor and cannot ask for someone else’s. issueToken defaults to actor_gateway read on that one resolved ID.
  • expiresIn is in seconds, so this token lasts 15 minutes. It is capped at 24 hours. subject is your user ID. Rivet does not interpret it, but it shows up when you inspect the token.
  • The response carries actorId from resolve() along with the token, and Cache-Control: no-store keeps it out of caches.

Connect from the browser

Give the client getToken instead of a static token, and connect to the Actor by the ID your backend returned:

import { createClient } from "rivetkit/client";
import type { registry } from "./registry";

// Calls your own backend, never the Rivet control plane directly.
async function fetchToken(): Promise<{ actorId: string; token: string }> {
	const response = await fetch("/token", {
		method: "POST",
		cache: "no-store",
	});
	if (!response.ok) throw new Error("could not get a Rivet token");
	return (await response.json()) as { actorId: string; token: string };
}

const { actorId } = await fetchToken();

const client = createClient<typeof registry>({
	endpoint: "https://rivet.example.com",
	namespace: "production",

	// Called whenever RivetKit needs a credential, including after one expires.
	getToken: async () => (await fetchToken()).token,
});

const profile = client.userProfile.getForId(actorId);
const conn = profile.connect();

await conn.recordVisit();
  • fetchToken calls your own /token route, never the Rivet control plane directly.
  • RivetKit calls getToken when it first needs a credential and again when one expires. A 15 minute token supports a connection that stays open for hours.
  • Use getForId, not getOrCreate. Connecting by ID checks actor_gateway read, which the token has. Resolving by key checks the actor grant, which it does not.

Expiry is enforced on open connections. When a token expires, the control plane closes the WebSocket with code 1008 and auth.token_expired. With getToken, RivetKit fetches a new token and reconnects. With a static token, the connection stays closed. There is no revocation, so keep tokens short and let renewal do the work.

Authorize actions inside the Actor

The token decides which Actor a user reaches. It cannot decide what they may do there, because the control plane does not know what your actions mean. actor_gateway read allows every action, including ones that change state. Check that inside the Actor:

import { actor, setup, UserError } from "rivetkit";

interface ConnParams {
	authToken: string;
}

interface ConnState {
	userId: string;
	role: "member" | "admin";
}

// Replace this with your session store or auth provider.
async function verifySession(authToken: string): Promise<ConnState | null> {
	if (authToken === "admin-token") return { userId: "u_1", role: "admin" };
	if (authToken === "member-token") return { userId: "u_2", role: "member" };
	return null;
}

export const document = actor({
	state: { body: "" },

	// 1. Identify the caller once, at connect time.
	createConnState: async (_c, params: ConnParams): Promise<ConnState> => {
		const session = await verifySession(params.authToken);
		if (!session) {
			throw new UserError("Invalid token", { code: "invalid_token" });
		}
		return session;
	},

	actions: {
		read: (c) => c.state.body,

		// 2. Gate the operation on the identity you established.
		edit: (c, body: string) => {
			if (c.conn.state.role !== "admin") {
				throw new UserError("Admins only", { code: "forbidden" });
			}
			c.state.body = body;
		},
	},
});

export const registry = setup({ use: { document } });
  • The client passes your app’s own session token as authToken in the connection params. That token is separate from the Rivet JWT, and Rivet never sees it.
  • createConnState verifies it once, when the user connects, and stores the user ID and role on c.conn.state. Throwing invalid_token rejects the connection.
  • edit checks c.conn.state.role and throws a UserError with the code forbidden. The client gets the code and the message Admins only.
  • Use getParams instead of params on the client when the session token can change between reconnects.

Try it

The jwt-counter example runs this flow with a counter Actor. It has no login, so its /api/token route hands any visitor a token for one shared counter. Point it at a control plane with JWT issuance enabled:

git clone https://github.com/rivet-dev/rivet.git
cd rivet
pnpm install
export RIVET_ENDPOINT="http://default:YOUR_TOKEN@127.0.0.1:6420"
pnpm --filter jwt-counter-example dev

Open http://localhost:5173, click Connect to counter, then Increment. Its tokens last 30 seconds, so Refreshes goes up about once a minute while the counter stays connected. Run pnpm --filter jwt-counter-example smoke to check issuance, Actor isolation, expiry, and renewal.

Then confirm that both layers reject. Point a token at another Actor, and the control plane refuses before your code runs:

curl -i -H "Authorization: Bearer $TOKEN" \
  "$RIVET_ENDPOINT/gateway/$SOME_OTHER_ACTOR_ID/"
HTTP/1.1 403 Forbidden
x-rivet-error: auth.insufficient_permissions

Call an admin-only action as a member, and the Actor refuses:

ActorError: Admins only

Next steps

  • JWTs: the full grant list, expiry, renewal, and token inspection.
  • Permissions: gate queues and events, and deny by default.
  • examples/jwt-better-auth: the same flow with signup and login through Better Auth.