Skip to main content
Lifecycle & Config

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.

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:

PropertyDescription
paramsData the client passed when connecting. See connection params.
c.requestThe underlying HTTP request.
c.request.headersRequest headers. Not available for .connect().
c.stateActor state, for authorization decisions. See state.
c.keyThe 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

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.

Permission Surfaces

Authorization is explicit per surface. Nothing is checked implicitly.

  • onBeforeConnect and createConnState reject unauthenticated connections.
  • Action handlers enforce per-action rules.
  • queues.<name>.canPublish allows or denies an inbound queue publish.
  • events.<name>.canSubscribe allows 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

  1. Keep connection hooks strict and reject invalid credentials.
  2. In each action, allow the expected roles explicitly and throw forbidden otherwise.
  3. In canPublish and canSubscribe, return true only for allowed roles and end with return 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.