# Permissions

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](/docs/authentication) for that, and [JWTs](/docs/jwt) 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.

index.ts:

```ts
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 } });
```

client.ts:

```ts
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.

examples/docs/actors-permissions/on-before-connect.ts:

```ts
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](/actors/docs/connections) for the full lifecycle.

examples/docs/actors-permissions/create-conn-state.ts:

```ts
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](/actors/docs/connections#extracting-data-from-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](/actors/docs/state). |
| `c.key` | The actor's key. See [keys](/actors/docs/keys). |

Prefer `params` over `c.request.headers`. It works for both HTTP and WebSocket connections, while headers do not.

## Passing Credentials from the Client

Connection:

```ts
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();
```

Stateless-Action:

```ts
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!");
```

HTTP-Headers:

```ts
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](/actors/docs/errors).

Connection:

```ts
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");
	}
});
```

Stateless-Action:

```ts
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.

- `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.

examples/docs/actors-permissions/chat-room.ts:

```ts
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.

examples/docs/actors-permissions/jwt.ts:

```ts
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

examples/docs/actors-permissions/external-auth-provider.ts:

```ts
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.

examples/docs/actors-permissions/using-state.ts:

```ts
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

examples/docs/actors-permissions/role-based-access-control.ts:

```ts
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.

examples/docs/actors-permissions/rate-limiting.ts:

```ts
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](/actors/docs/state#ephemeral-variables). Use `state` instead of `vars` to persist them.

### Caching Validated Tokens

Avoid revalidating the same token on every reconnect.

examples/docs/actors-permissions/caching-tokens.ts:

```ts
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](/docs/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.
