Permissions
Identify callers when they connect to a Rivet Actor, then authorize every action and event subscription with deny-by-default rules.
Permissions are enforced inside your actor, on a caller that has already reached it. This is the only layer that can see the actor’s own data, c.key, and action arguments, so every domain rule lives here.
The layer above it decides which actor a client may reach at all. See Authentication for that, and JWTs to scope a client to a single actor before it ever gets here.
Quickstart
Identify the caller and gate an action
Verify the credential in onBeforeConnect, where throwing rejects the connection. Once the connection is accepted, onConnect stores the caller’s identity in c.vars, keyed by conn.id, and actions look it up with c.conn.id. The document body itself lives in SQLite.
import { actor, setup, UserError } from "rivetkit";
import { db } from "rivetkit/db";
interface ConnParams {
authToken: string;
}
interface Session {
userId: string;
role: "member" | "admin";
}
// Replace this with your session store or auth provider.
async function verifySession(authToken: string): Promise<Session | null> {
if (authToken === "admin-token") return { userId: "u_1", role: "admin" };
if (authToken === "member-token") return { userId: "u_2", role: "member" };
return null;
}
// Resolve the caller's session, or reject the connection.
async function identify(params: ConnParams): Promise<Session> {
const session = await verifySession(params.authToken);
if (!session) {
throw new UserError("Invalid token", { code: "invalid_token" });
}
return session;
}
export const document = actor({
db: db({
onMigrate: async (db) => {
await db.execute(
"CREATE TABLE IF NOT EXISTS document (id INTEGER PRIMARY KEY CHECK (id = 1), body TEXT NOT NULL)",
);
await db.execute("INSERT OR IGNORE INTO document (id, body) VALUES (1, '')");
},
}),
// The session for each live connection, keyed by connection ID
createVars: () => ({ sessions: new Map<string, Session>() }),
// 1. Identify the caller at connect time. Throwing rejects the connection.
onBeforeConnect: async (_c, params: ConnParams) => {
await identify(params);
},
onConnect: async (c, conn) => {
c.vars.sessions.set(conn.id, await identify(conn.params));
},
onDisconnect: (c, conn) => {
c.vars.sessions.delete(conn.id);
},
actions: {
read: async (c) => {
const rows = (await c.db.execute("SELECT body FROM document WHERE id = 1")) as {
body: string;
}[];
return rows[0].body;
},
// 2. Gate the operation on the identity you established.
edit: async (c, body: string) => {
if (c.vars.sessions.get(c.conn.id)?.role !== "admin") {
throw new UserError("Admins only", { code: "forbidden" });
}
await c.db.execute("UPDATE document SET body = ? WHERE id = 1", body);
},
},
});
export const registry = setup({ use: { document } });
import { createClient } from "rivetkit/client";
import type { registry } from "./index";
const client = createClient<typeof registry>();
const doc = client.document.getOrCreate(["welcome"], {
params: { authToken: "member-token" },
});
const conn = doc.connect();
// Allowed: every authenticated caller may read.
console.log(await conn.read());
try {
// Rejected: this connection is a member, not an admin.
await conn.edit("hello");
} catch (error) {
console.error(error);
}
Verify the rejection
Running the client prints the allowed read, then the rejected edit:
(empty string)
ActorError: Admins only
Identifying the Caller
onBeforeConnect runs before a connection is usable. It can be async, and throwing from it rejects the connection.
onBeforeConnect
Use it for pass or fail validation. When actions need the caller’s identity later, resolve it again in onConnect and store it in c.vars keyed by conn.id, as in the Quickstart.
import { actor, UserError } from "rivetkit";
import { db } from "rivetkit/db";
interface ConnParams {
authToken: string;
}
// Example token validation function
async function validateToken(
token: string,
roomKey: string[],
): Promise<boolean> {
// In production, verify JWT or call auth service
return token.length > 0 && roomKey.length > 0;
}
const chatRoom = actor({
db: db({
onMigrate: async (db) => {
await db.execute(
"CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, timestamp INTEGER NOT NULL)",
);
},
}),
onBeforeConnect: async (c, params: ConnParams) => {
const roomName = c.key;
const isValid = await validateToken(params.authToken, roomName);
if (!isValid) {
throw new UserError("Forbidden", { code: "forbidden" });
}
},
actions: {
sendMessage: async (c, text: string) => {
await c.db.execute(
"INSERT INTO messages (text, timestamp) VALUES (?, ?)",
text,
Date.now(),
);
},
},
});
Available Data
onBeforeConnect can read:
| Property | Description |
|---|---|
params | Data the client passed when connecting. See connection params. |
c.request | The underlying HTTP request. |
c.request.headers | Request headers. Not available for .connect(). |
c.key | The actor’s key. See keys. |
Prefer params over c.request.headers. It works for both HTTP and WebSocket connections, while headers do not.
Passing Credentials from the Client
import { createClient } from "rivetkit/client";
async function getAuthToken(): Promise<string> {
return "jwt-token-here";
}
const client = createClient();
const chat = client.chatRoom.getOrCreate(["general"], {
getParams: async () => ({
authToken: await getAuthToken(),
}),
});
// Authentication will happen on connect by reading connection parameters
const connection = chat.connect();
import { createClient } from "rivetkit/client";
const client = createClient();
const chat = client.chatRoom.getOrCreate(["general"], {
params: { authToken: "jwt-token-here" },
});
// Authentication will happen when calling the action by reading input
// parameters
await chat.sendMessage("Hello, world!");
import { createClient } from "rivetkit/client";
// This only works for stateless actions, not WebSockets
const client = createClient({
headers: {
Authorization: "Bearer my-token",
},
});
const chat = client.chatRoom.getOrCreate(["general"]);
// Authentication will happen when calling the action by reading headers
await chat.sendMessage("Hello, world!");
Use getParams rather than params when the credential can change between attempts, such as a token that has to be fresh on every reconnect.
Handling Errors
Rejections surface through the normal error system. See errors.
import { actor, setup } from "rivetkit";
import { ActorError, createClient } from "rivetkit/client";
// Define actor with protected action
const myActor = actor({
actions: {
protectedAction: (c) => ({ success: true }),
},
});
const registry = setup({ use: { myActor } });
const client = createClient<typeof registry>("http://localhost:6420");
const actorHandle = await client.myActor.getOrCreate();
// Helper to show errors
function showError(message: string) {
console.error(message);
}
const conn = actorHandle.connect();
conn.onError((error: ActorError) => {
if (error.code === "forbidden") {
window.location.href = "/login";
} else if (error.code === "insufficient_permissions") {
showError("You don't have permission for this action");
}
});
import { actor, setup } from "rivetkit";
import { ActorError, createClient } from "rivetkit/client";
// Define actor with protected action
const myActor = actor({
actions: {
protectedAction: (c) => ({ success: true }),
},
});
const registry = setup({ use: { myActor } });
const client = createClient<typeof registry>("http://localhost:6420");
const actorHandle = await client.myActor.getOrCreate();
// Helper to show errors
function showError(message: string) {
console.error(message);
}
try {
const result = await actorHandle.protectedAction();
} catch (error) {
if (error instanceof ActorError && error.code === "forbidden") {
window.location.href = "/login";
} else if (
error instanceof ActorError &&
error.code === "insufficient_permissions"
) {
showError("You don't have permission for this action");
}
}
Permission Surfaces
Authorization is explicit per surface. Nothing is checked implicitly.
onBeforeConnectrejects unauthenticated connections.- Action handlers enforce per-action rules.
events.<name>.canSubscribeallows or denies an event subscription.
import { actor, event, UserError } from "rivetkit";
import { db } from "rivetkit/db";
type ConnParams = {
authToken: string;
};
type Session = {
userId: string;
role: "member" | "admin";
};
async function authenticate(authToken: string): Promise<Session | null> {
if (authToken === "admin-token") {
return { userId: "admin-1", role: "admin" };
}
if (authToken === "member-token") {
return { userId: "member-1", role: "member" };
}
return null;
}
// Resolve the caller's session, or reject the connection.
async function identify(params: ConnParams): Promise<Session> {
const session = params.authToken ? await authenticate(params.authToken) : null;
if (!session) {
throw new UserError("Forbidden", { code: "forbidden" });
}
return session;
}
export const chatRoom = actor({
db: db({
onMigrate: async (db) => {
await db.execute(
"CREATE TABLE IF NOT EXISTS messages (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, text TEXT NOT NULL)",
);
},
}),
// The session for each live connection, keyed by connection ID
createVars: () => ({ sessions: new Map<string, Session>() }),
onBeforeConnect: async (_c, params: ConnParams) => {
await identify(params);
},
onConnect: async (c, conn) => {
c.vars.sessions.set(conn.id, await identify(conn.params));
},
onDisconnect: (c, conn) => {
c.vars.sessions.delete(conn.id);
},
events: {
messages: event<{ userId: string; text: string }>(),
moderationLog: event<{ entry: string }>({
canSubscribe: (c) => {
if (c.conn && c.vars.sessions.get(c.conn.id)?.role === "admin") {
return true;
}
return false;
},
}),
},
actions: {
sendMessage: async (c, text: string) => {
const session = c.conn ? c.vars.sessions.get(c.conn.id) : undefined;
if (!session || (session.role !== "member" && session.role !== "admin")) {
throw new UserError("Forbidden", { code: "forbidden" });
}
const message = { userId: session.userId, text };
await c.db.execute(
"INSERT INTO messages (user_id, text) VALUES (?, ?)",
message.userId,
message.text,
);
c.broadcast("messages", message);
},
},
});
Fail by Default
- Keep connection hooks strict and reject invalid credentials.
- In each action, allow the expected roles explicitly and throw
forbiddenotherwise. - In
canSubscribe, returntrueonly for allowed roles and end withreturn false.
Return Value Contract
canSubscribe must return a boolean. true allows, false denies with forbidden. Returning undefined, null, or any non-boolean throws an internal error.
canSubscribe applies only to event names declared in events, and broadcasting an undeclared event still reaches its subscribers.
Patterns
Verifying a JWT from Your Auth Provider
Tokens from Clerk, Auth0, Supabase, or your own issuer are opaque to Rivet. Verify them here with your provider’s SDK or a library such as jose, checking the signature, issuer, audience, and expiry.
import { actor, UserError } from "rivetkit";
interface ConnParams {
token: string;
}
interface Caller {
userId: string;
role: string;
permissions: string[];
}
interface JwtPayload {
sub: string;
role: string;
permissions?: string[];
}
// Supply this from your auth provider's SDK or a JWT library such as `jose`.
// It must verify the signature and check the issuer, audience, and expiry.
// Decoding the payload without verifying the signature authenticates nobody:
// any client can forge a token.
declare function verifyAccessToken(token: string): Promise<JwtPayload>;
// Verify the token and resolve the caller, or reject the connection.
async function identify(params: ConnParams): Promise<Caller> {
let payload: JwtPayload;
try {
payload = await verifyAccessToken(params.token);
} catch {
throw new UserError("Invalid or expired token", {
code: "invalid_token",
});
}
return {
userId: payload.sub,
role: payload.role,
permissions: payload.permissions ?? [],
};
}
const jwtActor = actor({
// The caller for each live connection, keyed by connection ID
createVars: () => ({ callers: new Map<string, Caller>() }),
onBeforeConnect: async (_c, params: ConnParams) => {
await identify(params);
},
onConnect: async (c, conn) => {
c.vars.callers.set(conn.id, await identify(conn.params));
},
onDisconnect: (c, conn) => {
c.vars.callers.delete(conn.id);
},
actions: {
protectedAction: (c) => {
const caller = c.vars.callers.get(c.conn.id);
if (!caller?.permissions.includes("write")) {
throw new UserError("Write permission required", {
code: "forbidden",
});
}
return { success: true };
},
},
});
Calling an External Auth Service
import { actor, UserError } from "rivetkit";
interface ConnParams {
apiKey: string;
}
interface Caller {
userId: string;
tier: string;
}
// Validate the API key with the auth provider, or reject the connection.
async function identify(params: ConnParams): Promise<Caller> {
const response = await fetch(
`https://api.my-auth-provider.com/validate`,
{
method: "POST",
headers: { "X-API-Key": params.apiKey },
},
);
if (!response.ok) {
throw new UserError("Invalid API key", { code: "invalid_api_key" });
}
const data = await response.json();
return { userId: data.id, tier: data.tier };
}
const apiActor = actor({
// The caller for each live connection, keyed by connection ID
createVars: () => ({ callers: new Map<string, Caller>() }),
onBeforeConnect: async (_c, params: ConnParams) => {
await identify(params);
},
onConnect: async (c, conn) => {
c.vars.callers.set(conn.id, await identify(conn.params));
},
onDisconnect: (c, conn) => {
c.vars.callers.delete(conn.id);
},
actions: {
premiumAction: (c) => {
if (c.vars.callers.get(c.conn.id)?.tier !== "premium") {
throw new UserError("Premium subscription required", {
code: "forbidden",
});
}
return "Premium content";
},
},
});
Authorizing Against Actor Data
c.vars, c.db, and c.key are all available in onBeforeConnect, so an actor can decide access from its own data. This profile stores its owner in SQLite and loads it into c.vars on start.
import { actor, UserError } from "rivetkit";
import { db } from "rivetkit/db";
interface ConnParams {
userId?: string;
}
const userProfile = actor({
db: db({
onMigrate: async (db) => {
await db.execute(
"CREATE TABLE IF NOT EXISTS profile (id INTEGER PRIMARY KEY CHECK (id = 1), owner_id TEXT NOT NULL, is_private INTEGER NOT NULL)",
);
},
}),
// Record the owner once, when the profile is first created
onCreate: async (c, input: { ownerId: string }) => {
await c.db.execute(
"INSERT INTO profile (id, owner_id, is_private) VALUES (1, ?, 1)",
input.ownerId,
);
},
// Keep the profile settings in memory for fast permission checks
createVars: async (c): Promise<{ ownerId: string; isPrivate: boolean }> => {
const rows = (await c.db.execute(
"SELECT owner_id, is_private FROM profile WHERE id = 1",
)) as { owner_id: string; is_private: number }[];
return { ownerId: rows[0].owner_id, isPrivate: rows[0].is_private === 1 };
},
onBeforeConnect: (c, params: ConnParams) => {
// Use the actor's own data to check access permissions
if (c.vars.isPrivate && params.userId !== c.vars.ownerId) {
throw new UserError("Access denied to private profile", {
code: "forbidden",
});
}
},
actions: {
getProfile: (c) => ({ ownerId: c.vars.ownerId }),
},
});
Role-Based Access Control
import { actor, UserError } from "rivetkit";
const ROLE_HIERARCHY = { user: 1, moderator: 2, admin: 3 };
interface Caller {
role: keyof typeof ROLE_HIERARCHY;
permissions: string[];
}
// Example token validation function
async function validateToken(
token: string,
): Promise<{ role: keyof typeof ROLE_HIERARCHY; permissions: string[] }> {
// In production, verify JWT or call auth service
return { role: "user", permissions: ["read", "edit_posts"] };
}
function requireRole(requiredRole: keyof typeof ROLE_HIERARCHY) {
return (caller: Caller | undefined) => {
if (!caller || ROLE_HIERARCHY[caller.role] < ROLE_HIERARCHY[requiredRole]) {
throw new UserError(`${requiredRole} role required`, {
code: "forbidden",
});
}
};
}
function requirePermission(permission: string) {
return (caller: Caller | undefined) => {
if (!caller?.permissions.includes(permission)) {
throw new UserError(`Permission '${permission}' required`, {
code: "forbidden",
});
}
};
}
const forumActor = actor({
// The caller for each live connection, keyed by connection ID
createVars: () => ({ callers: new Map<string, Caller>() }),
onBeforeConnect: async (c, params: { token: string }) => {
await validateToken(params.token);
},
onConnect: async (c, conn) => {
const user = await validateToken(conn.params.token);
c.vars.callers.set(conn.id, { role: user.role, permissions: user.permissions });
},
onDisconnect: (c, conn) => {
c.vars.callers.delete(conn.id);
},
actions: {
deletePost: (c, postId: string) => {
requireRole("moderator")(c.vars.callers.get(c.conn.id));
// Delete post...
},
editPost: (c, postId: string, content: string) => {
requirePermission("edit_posts")(c.vars.callers.get(c.conn.id));
// Edit post...
},
},
});
Rate Limiting
Track attempts in c.vars and reject callers that exceed a limit.
import { actor, UserError } from "rivetkit";
interface ConnParams {
authToken: string;
}
interface RateLimitEntry {
count: number;
resetAt: number;
}
// Example token validation function
async function validateToken(token: string): Promise<{ userId: string }> {
// In production, verify JWT or call auth service
return { userId: "user-123" };
}
const rateLimitedActor = actor({
createVars: () => ({ rateLimits: {} as Record<string, RateLimitEntry> }),
onBeforeConnect: async (c, params: ConnParams) => {
// Extract user ID
const { userId } = await validateToken(params.authToken);
// Check rate limit
const now = Date.now();
const limit = c.vars.rateLimits[userId];
if (limit && limit.resetAt > now && limit.count >= 10) {
throw new UserError("Too many requests, try again later", {
code: "rate_limited",
});
}
// Update rate limit
if (!limit || limit.resetAt <= now) {
c.vars.rateLimits[userId] = { count: 1, resetAt: now + 60_000 };
} else {
limit.count++;
}
},
actions: {
getData: (c) => ({ success: true }),
},
});
These counters are ephemeral. Store them in SQLite to persist them.
Caching Validated Tokens
Avoid revalidating the same token on every reconnect, and between onBeforeConnect and onConnect.
import { actor, UserError } from "rivetkit";
interface ConnParams {
authToken: string;
}
interface Caller {
userId: string;
role: string;
}
interface TokenCache {
[token: string]: {
userId: string;
role: string;
expiresAt: number;
};
}
// Example token validation function
async function validateToken(
token: string,
): Promise<{ sub: string; role: string } | null> {
// In production, verify JWT or call auth service
if (token.length > 0) {
return { sub: "user-123", role: "member" };
}
return null;
}
// Validate a token, reusing a recent result from the actor's cache.
async function identify(
cache: TokenCache,
params: ConnParams,
): Promise<Caller> {
const token = params.authToken;
// Check cache first
const cached = cache[token];
if (cached && cached.expiresAt > Date.now()) {
return { userId: cached.userId, role: cached.role };
}
// Validate token (expensive operation)
const payload = await validateToken(token);
if (!payload) {
throw new UserError("Invalid token", { code: "invalid_token" });
}
// Cache the result
cache[token] = {
userId: payload.sub,
role: payload.role,
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
};
return { userId: payload.sub, role: payload.role };
}
const cachedAuthActor = actor({
createVars: () => ({
tokenCache: {} as TokenCache,
// The caller for each live connection, keyed by connection ID
callers: new Map<string, Caller>(),
}),
// Validates the token and fills the cache
onBeforeConnect: async (c, params: ConnParams) => {
await identify(c.vars.tokenCache, params);
},
// Reads the cached result instead of validating again
onConnect: async (c, conn) => {
c.vars.callers.set(conn.id, await identify(c.vars.tokenCache, conn.params));
},
onDisconnect: (c, conn) => {
c.vars.callers.delete(conn.id);
},
actions: {
getData: (c) => ({ userId: c.vars.callers.get(c.conn.id)?.userId }),
},
});
When You Need Less of This
If you scope each user’s JWT to a single actor, that actor has exactly one participant and needs little authorization of its own. These hooks earn their keep once an actor is shared, as in a chat room or a per-tenant database.