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.
What we’ll build:
- A
/tokenroute that logs the user in with your existing session system and mints a Rivet JWT for that user. - A JWT that reaches one
userProfileActor 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_tokenvalue. 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:
import { Hono } from "hono";
import { createClient } from "rivetkit/client";
import { registry } from "./registry";
// The issuing credential stays on the backend. It is never sent to a browser.
const client = createClient<typeof registry>({
endpoint: process.env.RIVET_ENDPOINT!,
namespace: process.env.RIVET_NAMESPACE!,
token: process.env.RIVET_ADMIN_TOKEN!,
});
// Replace this with your own session check.
async function authenticateUser(request: Request): Promise<string | null> {
return request.headers.get("x-demo-user");
}
const app = new Hono();
app.post("/token", async (c) => {
const userId = await authenticateUser(c.req.raw);
if (!userId) return c.json({ error: "unauthorized" }, 401);
// Scoped to this one actor. The default permission is gateway read.
const profile = client.userProfile.getOrCreate(["user", userId]);
const { token, expiresAt } = await profile.issueToken({
subject: userId,
expiresIn: 900,
});
return c.json({ actorId: await profile.resolve(), token, expiresAt }, 200, {
"Cache-Control": "no-store",
});
});
export default app;
import { actor, setup } from "rivetkit";
export const userProfile = actor({
state: { displayName: "", visits: 0 },
actions: {
recordVisit: (c) => {
c.state.visits += 1;
return c.state.visits;
},
setDisplayName: (c, displayName: string) => {
c.state.displayName = displayName;
},
},
});
export const registry = setup({ use: { userProfile } });
- Replace
authenticateUserwith your own session check. A request without a user gets a401and no token. getOrCreate(["user", userId])runs on the backend, so the client never names an Actor and cannot ask for someone else’s.issueTokendefaults toactor_gatewayreadon that one resolved ID.expiresInis in seconds, so this token lasts 15 minutes. It is capped at 24 hours.subjectis your user ID. Rivet does not interpret it, but it shows up when you inspect the token.- The response carries
actorIdfromresolve()along with the token, andCache-Control: no-storekeeps 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();
fetchTokencalls your own/tokenroute, never the Rivet control plane directly.- RivetKit calls
getTokenwhen it first needs a credential and again when one expires. A 15 minute token supports a connection that stays open for hours. - Use
getForId, notgetOrCreate. Connecting by ID checksactor_gatewayread, which the token has. Resolving by key checks theactorgrant, 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
authTokenin the connection params. That token is separate from the Rivet JWT, and Rivet never sees it. createConnStateverifies it once, when the user connects, and stores the user ID and role onc.conn.state. Throwinginvalid_tokenrejects the connection.editchecksc.conn.state.roleand throws aUserErrorwith the codeforbidden. The client gets the code and the messageAdmins only.- Use
getParamsinstead ofparamson 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.