# JWTs

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](/docs/authentication).

<svg viewBox="0 0 800 470" role="img" aria-label="Sequence: the browser logs in to your backend, your backend mints a scoped JWT from the Rivet control plane, the browser connects to Rivet with that JWT, and the control plane routes it to the user's Actor." style="width:100%;max-width:800px;height:auto;display:block;margin:2.5rem auto;font-family:system-ui,sans-serif">
  <defs>
    <marker id="auth-seq-call" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="rgb(var(--site-ink, 27 25 22))"/></marker>
    <marker id="auth-seq-return" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 z" fill="rgb(var(--runtime-highlight, 183 75 35))"/></marker>
  </defs>
  <g stroke="rgb(var(--site-ink, 27 25 22))" stroke-width="1.4" fill="rgb(var(--site-paper-mid, 227 227 229))">
    <rect x="24" y="20" width="142" height="48" rx="7"/>
    <rect x="226" y="20" width="148" height="48" rx="7"/>
    <rect x="436" y="20" width="168" height="48" rx="7" fill="rgb(var(--runtime-highlight, 183 75 35) / 0.14)"/>
    <rect x="652" y="20" width="126" height="48" rx="7"/>
  </g>
  <g text-anchor="middle" fill="rgb(var(--site-ink, 27 25 22))" font-size="13" font-weight="600">
    <text x="95" y="49">Browser</text>
    <text x="300" y="49">Your backend</text>
    <text x="520" y="49">Rivet control plane</text>
    <text x="715" y="49">User Actor</text>
  </g>
  <g stroke="rgb(var(--site-ink-faint, 138 132 120))" stroke-width="1.3" stroke-dasharray="5 5">
    <line x1="95" y1="68" x2="95" y2="440"/>
    <line x1="300" y1="68" x2="300" y2="440"/>
    <line x1="520" y1="68" x2="520" y2="440"/>
    <line x1="715" y1="68" x2="715" y2="440"/>
  </g>
  <g font-size="12" fill="rgb(var(--site-ink-soft, 86 82 74))" text-anchor="middle">
    <text x="197" y="105">POST /login</text>
    <text x="410" y="151">issueToken()</text>
    <text x="410" y="167" font-size="10" fill="rgb(var(--site-ink-faint, 138 132 120))">grant: actor_gateway read on user:alice</text>
    <text x="410" y="211">JWT, expires in 1h</text>
    <text x="197" y="255">session + JWT</text>
    <text x="410" y="305">connect with JWT</text>
    <text x="617" y="349">verify + check grants</text>
    <text x="410" y="401">actions &amp; events</text>
  </g>
  <g stroke="rgb(var(--site-ink, 27 25 22))" stroke-width="1.4">
    <line x1="95" y1="116" x2="298" y2="116" marker-end="url(#auth-seq-call)"/>
    <line x1="300" y1="178" x2="518" y2="178" marker-end="url(#auth-seq-call)"/>
    <line x1="95" y1="316" x2="518" y2="316" marker-end="url(#auth-seq-call)"/>
    <line x1="520" y1="360" x2="713" y2="360" marker-end="url(#auth-seq-call)"/>
  </g>
  <g stroke="rgb(var(--runtime-highlight, 183 75 35))" stroke-width="1.4" stroke-dasharray="5 4">
    <line x1="520" y1="222" x2="302" y2="222" marker-end="url(#auth-seq-return)"/>
    <line x1="300" y1="266" x2="97" y2="266" marker-end="url(#auth-seq-return)"/>
    <line x1="713" y1="412" x2="97" y2="412" marker-start="url(#auth-seq-return)" marker-end="url(#auth-seq-return)"/>
  </g>
</svg>

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

server.ts:

```ts
import { Hono } from "hono";
import { createClient } from "rivetkit/client";
import { registry } from "./registry";

// The issuing credential stays on the backend. It is never sent to a browser.
const client = createClient<typeof registry>({
	endpoint: process.env.RIVET_ENDPOINT!,
	namespace: process.env.RIVET_NAMESPACE!,
	token: process.env.RIVET_ADMIN_TOKEN!,
});

// Replace this with your own session check.
async function authenticateUser(request: Request): Promise<string | null> {
	return request.headers.get("x-demo-user");
}

const app = new Hono();

app.post("/token", async (c) => {
	const userId = await authenticateUser(c.req.raw);
	if (!userId) return c.json({ error: "unauthorized" }, 401);

	// Scoped to this one actor. The default permission is gateway read.
	const profile = client.userProfile.getOrCreate(["user", userId]);
	const { token, expiresAt } = await profile.issueToken({
		subject: userId,
		expiresIn: 900,
	});

	return c.json({ actorId: await profile.resolve(), token, expiresAt }, 200, {
		"Cache-Control": "no-store",
	});
});

export default app;
```

registry.ts:

```ts
import { actor, setup } from "rivetkit";

export const userProfile = actor({
	state: { displayName: "", visits: 0 },

	actions: {
		recordVisit: (c) => {
			c.state.visits += 1;
			return c.state.visits;
		},
		setDisplayName: (c, displayName: string) => {
			c.state.displayName = displayName;
		},
	},
});

export const registry = setup({ use: { userProfile } });
```

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

client.ts:

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

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

| Resource | Gates |
| --- | --- |
| `actor_gateway` | Connecting to an actor by ID: actions, events, and raw HTTP or WebSocket handlers. |
| `actor` | Resolving or creating actors by key, and the actors API. |
| `actor_kv` | Reading an actor's raw KV, which the [inspector](/actors/docs/debugging) needs. |
| `namespace`, `runner`, `runner_config`, `datacenter` | Control-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.

examples/docs/general-jwt/grants.ts:

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

| Error | Status | Cause |
| --- | --- | --- |
| `auth.invalid_token` | 401 | Malformed, unsigned, or signed by a key this cluster does not know. |
| `auth.token_expired` | 401 | Past `exp`. Refresh and retry. |
| `auth.insufficient_permissions` | 403 | Valid token, but no grant covers this resource, target, and operation. |
| `auth.issuance_disabled` | 400 | `auth.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](/docs/deploy/self-host/control-plane/configuration#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](/actors/docs/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`](https://github.com/rivet-dev/rivet/tree/main/examples/jwt-counter) is a runnable backend, client, and smoke test covering issuance, scoped access, and renewal.
