Authenticate Users End to End
Wire a login endpoint to Rivet Actors: authenticate the user, mint a scoped JWT on your backend, connect from the browser, and authorize actions inside the actor.
This guide connects both halves of Rivet authentication in one working flow. A user logs in, your backend mints a JWT that reaches only that user’s actor, the browser connects with it, and the actor authorizes what the user may do once connected.
Read Authentication first for the two-layer model. This guide is the runnable version of it.
What You Are Building
A per-user profile actor. Alice can reach user:alice and nothing else, enforced by the control plane before her request touches your code. Inside the actor, an admin flag decides who may edit.
Steps
Keep the admin token on the backend
Only your server 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 admin token reaches every actor in every namespace, so it never leaves your backend. See Configuration.
Mint a scoped token at login
Authenticate the user with your existing session system, resolve the actor they own, then request a JWT that grants actor_gateway read on that single actor ID.
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 } });
Resolving the ID on the backend is what makes the grant tight. The client never names an actor, so it cannot ask for someone else’s.
Connect from the browser
Give the client getToken instead of a static token. RivetKit calls it when it needs a credential and again when one expires, so a 15 minute token supports a connection that stays open for hours.
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://api.rivet.dev",
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();
Authorize inside the actor
The token decides which actor Alice reaches. It cannot decide what she may do there, because the control plane does not know what your actions mean. Check that in 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 } });
Confirm both layers reject
Point the token at another actor and the control plane refuses before your code runs:
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
Where to Go Next
- JWTs for the full grant vocabulary, expiry behavior, and renewal.
- Permissions for queue and event gating and deny-by-default patterns.
examples/jwt-counterfor a complete project with a smoke test covering renewal and rejection.