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>.canSubscribeto 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:
| Property | Description |
|---|---|
params | Custom data passed by the client when connecting (see connection params) |
c.request | The underlying HTTP request object |
c.request.headers | Request headers for tokens, API keys (does not work for .connect()) |
c.key | The 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:
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!");
Handling Errors
Authentication errors use the same system as regular errors. See errors for more details.
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");
}
}
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 }),
},
});