Skip to main content
Communication & Networking

Authentication

Authenticate Rivet Actor connections, validate credentials before clients connect, and carry trusted identity into actor actions.

Do You Need Authentication?

Rivet Cloud

Actors are private by default on Rivet Cloud. Only requests with the publishable token can interact with actors.

  • Backend-only actors: If your publishable token is only included in your backend, then authentication is not necessary.
  • Frontend-accessible actors: If your publishable token is included in your frontend, then implementing authentication is recommended.

Self-Hosted

Control plane authentication is required by default on self-hosted Rivet. Configure an admin token on the control plane and issue scoped tokens for clients.

See Authentication for credentials and Scoped Tokens for permissions.

Issue access tokens

Keep the control plane’s admin credential on your backend. Issue scoped tokens for clients, granting only the resources and operations they need in their namespace. Actor traffic uses actor_gateway permissions; Actor creation and lookup use actor permissions.

Control plane grants decide which Actors a client can reach. The connection hooks below can also check your application’s user identity and permissions.

Authentication Connections

Authentication is configured through onBeforeConnect, which validates credentials and rejects the connection by throwing. To use the caller’s identity in actions, store it in c.vars keyed by conn.id from onConnect, then look it up with c.conn.id.

Access Control

After a connection is authenticated, use Access Control to enforce authorization:

  • Check permissions in action handlers.
  • Use events.<name>.canSubscribe to gate event subscriptions.

onBeforeConnect

The onBeforeConnect hook validates credentials before allowing a connection. Throw an error to reject the connection.

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 Auth Data

Authentication hooks have access to several properties:

PropertyDescription
paramsCustom data passed by the client when connecting (see connection params)
c.requestThe underlying HTTP request object
c.request.headersRequest headers for tokens, API keys (does not work for .connect())
c.keyThe actor’s key (see keys)

It’s recommended to use params instead of c.request.headers whenever possible since it works for both HTTP & WebSocket connections.

Client Usage

Passing Credentials

Pass authentication data when connecting. Use getParams when you need a fresh JWT for every connection or reconnect:

Handling Errors

Authentication errors use the same system as regular errors. See errors for more details.

Examples

JWT

Validate JSON Web Tokens and extract user claims:

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 };
		},
	},
});

External Auth Provider

Validate credentials against an external authentication 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";
		},
	},
});

Using Actor Data In Authorization

Read the actor’s own data from SQLite, loaded into c.vars on start, and the actor’s key via c.key to make authorization decisions:

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

Create helper functions for common authorization patterns:

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

Use c.vars to track connection attempts and rate limit by user:

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 }),
	},
});

The limits in this example are ephemeral. If you wish to persist rate limits, store them in SQLite instead.

Caching Tokens

Cache validated tokens in c.vars to avoid redundant validation on repeated connections and between onBeforeConnect and onConnect. See ephemeral variables for more details.

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 }),
	},
});
Edit this page Last updated November 10, 2026