Permissions
Identify callers when they connect to a Rivet Actor, then authorize every action, queue publish, 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 c.state, 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
Use createConnState to turn a credential into connection state, then check that state in the actions that need it. Throwing from createConnState rejects the connection.
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 } });
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
Two hooks run before a connection is usable. Both can be async, and throwing from either rejects the connection.
onBeforeConnect
Use it for pass or fail validation when you do not need the result later.
import { actor, UserError } from "rivetkit";
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;
}
interface Message {
text: string;
timestamp: number;
}
const chatRoom = actor({
state: { messages: [] as Message[] },
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: (c, text: string) => {
c.state.messages.push({ text, timestamp: Date.now() });
},
},
});
createConnState
Use it when actions need to know who is calling. The returned object becomes c.conn.state. See Connections for the full lifecycle.
import { actor, UserError } from "rivetkit";
interface ConnParams {
authToken: string;
}
interface ConnState {
userId: string;
role: string;
}
interface Message {
userId: string;
text: string;
timestamp: number;
}
// Example token validation function
async function validateToken(
token: string,
roomKey: string[],
): Promise<{ sub: string; role: string } | null> {
// In production, verify JWT or call auth service
if (token.length > 0 && roomKey.length > 0) {
return { sub: "user-123", role: "member" };
}
return null;
}
const chatRoom = actor({
state: { messages: [] as Message[] },
createConnState: async (c, params: ConnParams): Promise<ConnState> => {
const roomName = c.key;
const payload = await validateToken(params.authToken, roomName);
if (!payload) {
throw new UserError("Forbidden", { code: "forbidden" });
}
return {
userId: payload.sub,
role: payload.role,
};
},
actions: {
sendMessage: (c, text: string) => {
// Access user data via c.conn.state
const { userId, role } = c.conn.state;
if (role !== "member") {
throw new UserError("Insufficient permissions", {
code: "insufficient_permissions",
});
}
c.state.messages.push({ userId, text, timestamp: Date.now() });
c.broadcast("newMessage", { userId, text });
},
},
});
Available Data
Both hooks 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.state | Actor state, for authorization decisions. See state. |
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({
state: {},
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({
state: {},
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.
onBeforeConnectandcreateConnStatereject unauthenticated connections.- Action handlers enforce per-action rules.
queues.<name>.canPublishallows or denies an inbound queue publish.events.<name>.canSubscribeallows or denies an event subscription.
import { actor, event, queue, UserError } from "rivetkit";
type ConnParams = {
authToken: string;
};
type ConnState = {
userId: string;
role: "member" | "admin";
};
async function authenticate(authToken: string): Promise<ConnState | null> {
if (authToken === "admin-token") {
return { userId: "admin-1", role: "admin" };
}
if (authToken === "member-token") {
return { userId: "member-1", role: "member" };
}
return null;
}
export const chatRoom = actor({
state: { messages: [] as Array<{ userId: string; text: string }> },
onBeforeConnect: async (_c, params: ConnParams) => {
if (!params.authToken) {
throw new UserError("Forbidden", { code: "forbidden" });
}
const session = await authenticate(params.authToken);
if (!session) {
throw new UserError("Forbidden", { code: "forbidden" });
}
},
createConnState: async (_c, params: ConnParams): Promise<ConnState> => {
const session = await authenticate(params.authToken);
if (!session) {
throw new UserError("Forbidden", { code: "forbidden" });
}
return session;
},
events: {
messages: event<{ userId: string; text: string }>(),
moderationLog: event<{ entry: string }>({
canSubscribe: (c) => {
if (c.conn?.state.role === "admin") {
return true;
}
return false;
},
}),
},
queues: {
moderationJobs: queue<{ action: "ban"; userId: string }>({
canPublish: (c) => {
if (c.conn?.state.role === "admin") {
return true;
}
return false;
},
}),
},
actions: {
sendMessage: (c, text: string) => {
const role = c.conn?.state.role;
const userId = c.conn?.state.userId;
if (!userId || (role !== "member" && role !== "admin")) {
throw new UserError("Forbidden", { code: "forbidden" });
}
const message = { userId, text };
c.state.messages.push(message);
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
canPublishandcanSubscribe, returntrueonly for allowed roles and end withreturn false.
Return Value Contract
canPublish and canSubscribe must return a boolean. true allows, false denies with forbidden. Returning undefined, null, or any non-boolean throws an internal error.
canPublish applies only to queue names declared in queues, and messages for undeclared queues are ignored while the publish still reports as completed. 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 ConnState {
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>;
const jwtActor = actor({
state: {},
createConnState: async (c, params: ConnParams): Promise<ConnState> => {
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 ?? [],
};
},
actions: {
protectedAction: (c) => {
if (!c.conn.state.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 ConnState {
userId: string;
tier: string;
}
const apiActor = actor({
state: {},
createConnState: async (c, params: ConnParams): Promise<ConnState> => {
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 };
},
actions: {
premiumAction: (c) => {
if (c.conn.state.tier !== "premium") {
throw new UserError("Premium subscription required", {
code: "forbidden",
});
}
return "Premium content";
},
},
});
Authorizing Against Actor State
c.state and c.key are both available, so an actor can decide access from its own data.
import { actor, UserError } from "rivetkit";
interface ConnParams {
userId?: string;
}
const userProfile = actor({
state: {
ownerId: "user-123",
isPrivate: true,
},
onBeforeConnect: (c, params: ConnParams) => {
// Use actor state to check access permissions
if (c.state.isPrivate && params.userId !== c.state.ownerId) {
throw new UserError("Access denied to private profile", {
code: "forbidden",
});
}
},
actions: {
getProfile: (c) => ({ ownerId: c.state.ownerId }),
},
});
Role-Based Access Control
import { actor, UserError } from "rivetkit";
const ROLE_HIERARCHY = { user: 1, moderator: 2, admin: 3 };
interface ConnState {
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 (c: { conn: { state: ConnState } }) => {
const userRole = c.conn.state.role;
if (ROLE_HIERARCHY[userRole] < ROLE_HIERARCHY[requiredRole]) {
throw new UserError(`${requiredRole} role required`, {
code: "forbidden",
});
}
};
}
function requirePermission(permission: string) {
return (c: { conn: { state: ConnState } }) => {
if (!c.conn.state.permissions?.includes(permission)) {
throw new UserError(`Permission '${permission}' required`, {
code: "forbidden",
});
}
};
}
const forumActor = actor({
state: {},
createConnState: async (
c,
params: { token: string },
): Promise<ConnState> => {
const user = await validateToken(params.token);
return { role: user.role, permissions: user.permissions };
},
actions: {
deletePost: (c, postId: string) => {
requireRole("moderator")(c);
// Delete post...
},
editPost: (c, postId: string, content: string) => {
requirePermission("edit_posts")(c);
// 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({
state: {},
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. Use state instead of vars to persist them.
Caching Validated Tokens
Avoid revalidating the same token on every reconnect.
import { actor, UserError } from "rivetkit";
interface ConnParams {
authToken: string;
}
interface ConnState {
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;
}
const cachedAuthActor = actor({
state: {},
createVars: () => ({ tokenCache: {} as TokenCache }),
createConnState: async (c, params: ConnParams): Promise<ConnState> => {
const token = params.authToken;
// Check cache first
const cached = c.vars.tokenCache[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
c.vars.tokenCache[token] = {
userId: payload.sub,
role: payload.role,
expiresAt: Date.now() + 5 * 60 * 1000, // 5 minutes
};
return { userId: payload.sub, role: payload.role };
},
actions: {
getData: (c) => ({ userId: c.conn.state.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.