Skip to main content
Authentication

JWTs

Mint short-lived, grant-scoped JWTs on your backend so a browser can reach one Rivet Actor without holding a secret, and renew them automatically from RivetKit.

A Rivet JWT is a short-lived credential you mint on your backend and hand to an untrusted client. It carries grants saying exactly what the holder may reach, and the control plane verifies it before routing any request.

Use one whenever a browser or mobile app talks to Rivet directly. For which credential to use where, see Authentication.

BrowserYour backendRivet control planeUser ActorPOST /loginissueToken()grant: actor_gateway read on user:aliceJWT, expires in 1hsession + JWTconnect with JWTverify + check grantsactions & events

Quickstart

Issue a token on your backend

Authenticate the user however you already do, then call issueToken on the actor they own. It defaults to gateway read access scoped to that one actor, so there is nothing to spell out. Return only the token to the client.

Use the token in RivetKit

Pass getToken to createClient. RivetKit calls it whenever it needs a credential, caches the result, and calls it again when the token expires.

import { createClient } from "rivetkit/client";
import type { registry } from "./registry";

// Calls your own backend, never the Rivet control plane directly.
async function fetchToken(): Promise<{ actorId: string; token: string }> {
	const response = await fetch("/token", {
		method: "POST",
		cache: "no-store",
	});
	if (!response.ok) throw new Error("could not get a Rivet token");
	return (await response.json()) as { actorId: string; token: string };
}

const { actorId } = await fetchToken();

const client = createClient<typeof registry>({
	endpoint: "https://api.rivet.dev",
	namespace: "production",

	// Called whenever RivetKit needs a credential, including after one expires.
	getToken: async () => (await fetchToken()).token,
});

const profile = client.userProfile.getForId(actorId);
const conn = profile.connect();

await conn.recordVisit();

Verify the scope

Point the same token at a different actor. The control plane rejects it before your code runs:

curl -i -H "Authorization: Bearer $TOKEN" \
  "$RIVET_ENDPOINT/gateway/$SOME_OTHER_ACTOR_ID/"
HTTP/1.1 403 Forbidden
x-rivet-error: auth.insufficient_permissions

Issuing needs a credential that already holds the grants being handed out, so it only works from a server-side client. expiresIn is in seconds and defaults to the control plane’s lifetime, capped at 24 hours. subject is your user identifier, opaque to Rivet, and shows up in token inspection. issuedAt and expiresAt come back as Unix milliseconds.

Grants

actor.issueToken covers the common case. Pass permissions to widen what the holder may do to that actor; every grant stays scoped to its resolved ID. For namespace-wide operations such as creating actors, use client.auth.issueToken with an explicit grant list, which adds nothing on its own.

ResourceGates
actor_gatewayConnecting to an actor by ID: actions, events, and raw HTTP or WebSocket handlers.
actorResolving or creating actors by key, and the actors API.
actor_kvReading an actor’s raw KV, which the inspector needs.
namespace, runner, runner_config, datacenterControl-plane management APIs.

A grant is a resource, a target of "any" or { id }, and operations drawn from create, read, update, delete, and list. A grant set is capped at 32 grants, 5 operations each, and 2048 bytes encoded.

import { createClient } from "rivetkit/client";
import type { registry } from "./quickstart/registry";

const client = createClient<typeof registry>();
const userId = "user_alice";
const profile = client.userProfile.getOrCreate(["user", userId]);

// Reach exactly one actor. This is the default, so `permissions` can be
// omitted entirely. The holder cannot create actors or discover others.
export const oneActor = () => profile.issueToken({ subject: userId });

// Widen what the holder may do to that same actor. Every grant stays scoped
// to its resolved ID.
export const oneActorWithKv = () =>
	profile.issueToken({
		subject: userId,
		permissions: {
			actor_gateway: ["read"],
			actor_kv: ["read"],
		},
	});

// Namespace-wide operations such as creating actors need an explicit grant
// list. Nothing is added automatically.
export const anyActorInNamespace = () =>
	client.auth.issueToken({
		subject: userId,
		grants: [
			{
				resource: "actor",
				target: "any",
				operations: ["create", "read"],
			},
			{ resource: "actor_gateway", target: "any", operations: ["read"] },
		],
	});

Resolving Versus Connecting

These are different grants, and confusing them is the most common mistake. getOrCreate(key) and get(key) route through the query path, which checks actor with create and read. Connecting to an actor you already have the ID for checks actor_gateway with read on that ID.

That distinction is what makes tight scoping possible. A token holding only actor_gateway read on one ID can reach that actor and nothing else. Resolve the ID on your backend, where you still hold the admin token, then grant against it.

Expiration and Renewal

Expiry is enforced mid-flight, not just at connection time. When a token’s exp passes, the control plane cancels in-flight requests and closes open WebSockets with code 1008 and auth.token_expired.

With getToken wired up this is invisible: RivetKit catches the close, calls getToken({ forceRefresh: true }), and reconnects. With a static token the connection dies and does not come back, so always use getToken for connections that outlive the token.

There is no revocation, so a leaked token is valid until it expires. Keep durations short and let renewal do the work.

Inspecting a Token

GET /auth/tokens/inspect, called with the token itself, returns the namespace, subject, grants, and timestamps it actually carries. Use it when a request is rejected and you want to see what the holder was granted.

Errors

ErrorStatusCause
auth.invalid_token401Malformed, unsigned, or signed by a key this cluster does not know.
auth.token_expired401Past exp. Refresh and retry.
auth.insufficient_permissions403Valid token, but no grant covers this resource, target, and operation.
auth.issuance_disabled400auth.jwt.issuance_enabled is off, so issuance will not mint.

Over HTTP the code arrives in the JSON body and, through the gateway, in the x-rivet-error response header. On a WebSocket it arrives in the close reason.

Configuration

Issuance and verification are on by default when a self-hosted control plane has an admin token. Tokens are signed with EdDSA, the issuer is derived from the leader datacenter’s public URL, and signing keys rotate every seven days with no operator action. Tune auth.jwt.default_duration, auth.jwt.max_duration, and auth.jwt.audience if the defaults do not suit you.

Not Your Application’s JWTs

This page is about credentials Rivet issues and verifies. Tokens from Clerk, Auth0, Supabase, or your own issuer are never seen by Rivet. Pass them as connection parameters and verify them inside the actor. See Permissions.

The two compose: a Rivet JWT decides which actor a client may reach, and your own token decides who the user is once they are there.

examples/jwt-counter is a runnable backend, client, and smoke test covering issuance, scoped access, and renewal.