# Authentication
URL: /docs/cloud/authorization

How the browser and your server prove who they are to a project, and the access rules a project enforces.

> For AI agents: a documentation index is available at [llms.txt](/llms.txt). Use `.md` for canonical markdown pages; `.mdx` is kept as a backwards-compatible alias on supported URL paths.

The browser talks to the project directly, so your backend is only involved to say who the user is. Every `AssistantCloud` client is created in one of three modes.

| Mode                | Runs in     | Configuration                     | Identity                                             |
| ------------------- | ----------- | --------------------------------- | ---------------------------------------------------- |
| Anonymous           | the browser | `{ baseUrl, anonymous: true }`    | a generated visitor, kept by the browser for 30 days |
| Auth provider token | the browser | `{ baseUrl, authToken }`          | your user, from a JWT your provider signs            |
| API key             | your server | `{ apiKey, userId, workspaceId }` | any user and workspace your server names             |

`baseUrl` is the project's frontend API URL for the browser modes, `https://proj-<id>.assistant-api.com`, the project id with its underscore written as a hyphen. The API key mode talks to `https://backend.assistant-api.com` and must use that host. A credential presented on the other host answers `403`.

## Anonymous sessions

```
import { AssistantCloud } from "@assistant-ui/react";

const cloud = new AssistantCloud({
  baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!,
  anonymous: true,
});
```

On first use the client asks the project for an anonymous identity: a `usr_anon_…` user that is also its own workspace, an access token, and a refresh token that lasts 30 days. The refresh token is kept in `localStorage` under the base URL, so the same browser resumes the same threads on its next visit, and every refresh extends it by another 30 days. A different browser, a private window, or cleared storage is a new visitor; so is every launch on React Native and Ink, which have no `localStorage`.

On React, setting `NEXT_PUBLIC_ASSISTANT_BASE_URL` is enough: the runtimes create this client when you pass no `cloud`. Anonymous sessions are on for a new project and can be turned off in **Settings › Access** once every user signs in.

### Claiming anonymous threads after sign in

When a visitor signs in, move what they wrote as a visitor into their account. Read the browser's refresh token with `readAnonymousRefreshToken`, send it to your server, and claim from there with an API key client scoped to the signed in user:

Choose one:

**Browser**

```
import { readAnonymousRefreshToken } from "@assistant-ui/react";

export async function claimAnonymousThreads(baseUrl: string) {
  const refreshToken = readAnonymousRefreshToken(baseUrl);
  if (!refreshToken) return { moved: 0 };

  const response = await fetch("/api/threads/claim", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ refresh_token: refreshToken }),
  });
  if (!response.ok) throw new Error("Failed to claim anonymous threads");
  return (await response.json()) as { moved: number };
}
```

**Server**

```
import { AssistantCloud } from "assistant-cloud";
import { auth } from "@clerk/nextjs/server";

export async function POST(request: Request) {
  const { userId } = await auth();
  if (!userId) return new Response("Unauthorized", { status: 401 });

  const { refresh_token } = (await request.json()) as { refresh_token: string };
  const cloud = new AssistantCloud({
    apiKey: process.env.ASSISTANT_API_KEY!,
    userId,
    workspaceId: userId,
  });
  const { moved } = await cloud.threads.claim({ refresh_token });
  return Response.json({ moved });
}
```

The response says how many threads moved. A claim by an anonymous caller, or with an expired token, is refused.

## Your auth provider's tokens

```
import { AssistantCloud } from "@assistant-ui/react";

const cloud = new AssistantCloud({
  baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!,
  authToken: () => getTokenFromYourProvider(),
});
```

`authToken` returns a JWT your identity provider signed. The project verifies it against an **auth rule** from **Settings › Access**:

| Field           | Meaning                                                                            |
| --------------- | ---------------------------------------------------------------------------------- |
| Issuer          | The token's `iss`. The rule is matched on it.                                      |
| JWKS URL        | Where the project fetches the provider's public keys: a public `https://` URL.     |
| Audience        | The `aud` the token must carry, or none.                                           |
| Workspace claim | The claim that names the workspace, `sub` by default, so each user gets their own. |

Tokens must be signed with RS256 and carry a `kid` header; `sub` becomes the user id. On a valid token the project answers with a short lived token of its own in the `Authorization` response header, and the client uses it for the following requests until it expires, so your provider is not asked on every call. The client calls `authToken` again when it needs a fresh token; return `null` to say that nobody is signed in.

Keep the client in a `useMemo` keyed on the token getter, so the cached token is not thrown away on every render.

Choose one:

**Clerk**

Create a JWT template named `assistant-ui` with `{ "aud": "assistant-ui" }`, then an auth rule with the template's issuer and JWKS URL and the audience `assistant-ui`.

```
import { useMemo } from "react";
import { useAuth } from "@clerk/nextjs";
import { AssistantCloud } from "@assistant-ui/react";

function useCloud() {
  const { getToken } = useAuth();
  return useMemo(
    () =>
      new AssistantCloud({
        baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!,
        authToken: () => getToken({ template: "assistant-ui" }),
      }),
    [getToken],
  );
}
```

**Auth0**

Create the rule with your Auth0 domain as issuer, its `/.well-known/jwks.json` as the JWKS URL, and your API audience.

```
import { useMemo } from "react";
import { useAuth0 } from "@auth0/auth0-react";
import { AssistantCloud } from "@assistant-ui/react";

function useCloud() {
  const { getAccessTokenSilently } = useAuth0();
  return useMemo(
    () =>
      new AssistantCloud({
        baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!,
        authToken: () => getAccessTokenSilently(),
      }),
    [getAccessTokenSilently],
  );
}
```

**Supabase**

A JWKS rule needs asymmetric signing keys, so switch the Supabase project from the legacy HS256 secret to an RS256 key first. Then create the rule with the project's issuer and JWKS URL and return the session's access token.

```
const cloud = new AssistantCloud({
  baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!,
  authToken: async () => {
    const { data } = await supabase.auth.getSession();
    return data.session?.access_token ?? null;
  },
});
```

**Firebase**

Firebase id tokens are RS256 with published keys. Create the rule with the project's issuer and JWKS URL and return the current user's id token.

```
const cloud = new AssistantCloud({
  baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!,
  authToken: async () => (await auth.currentUser?.getIdToken()) ?? null,
});
```

### A token endpoint

When your provider's tokens cannot be verified by a JWKS rule, mint the project's own tokens from your server. They last five minutes, and the client asks `authToken` again before one expires.

```
import { AssistantCloud } from "assistant-cloud";
import { auth } from "@clerk/nextjs/server";

export const POST = async () => {
  const { userId, orgId } = await auth();
  if (!userId) return new Response("Unauthorized", { status: 401 });

  const cloud = new AssistantCloud({
    apiKey: process.env.ASSISTANT_API_KEY!,
    userId,
    workspaceId: orgId ? `${orgId}_${userId}` : userId,
  });
  const { token } = await cloud.auth.tokens.create();
  return new Response(token);
};
```

```
const cloud = new AssistantCloud({
  baseUrl: process.env.NEXT_PUBLIC_ASSISTANT_BASE_URL!,
  authToken: () =>
    fetch("/api/assistant-ui-token", { method: "POST" }).then((r) => r.text()),
});
```

## API keys

An API key, `sk_aui_proj_…`, is created in **Settings › API keys** with a name and an optional expiry, and shown once. It authenticates your server to the backend API:

```
import { AssistantCloud } from "assistant-cloud";

const cloud = new AssistantCloud({
  apiKey: process.env.ASSISTANT_API_KEY!,
  userId,
  workspaceId,
});
```

The key acts as the user and workspace you name, sent as the `Aui-User-Id` and `Aui-Workspace-Id` headers, and sees that workspace's threads. Every route the browser uses accepts a key; minting tokens, receiving traces, the project read API and MCP accept nothing else. The dashboard shows when each key was last used, and a deleted or expired key is refused with `403`.

A key is what makes a server a client in its own right: a bot, a backend agent or a batch job creates threads, stores messages and reports runs like a browser does. See [Servers and bots](/docs/cloud/servers).

## Allowed origins

**Settings › Access** lists the browser origins the frontend API answers, one per line, up to 32:

```
https://app.example.com
https://*.preview.example.com
http://localhost:3000
```

An empty list, the default, allows every origin. Otherwise an origin outside the list gets no CORS headers and the browser reports a failed fetch. Entries are `https://` origins or `http://localhost`; a leading `*.` matches subdomains at any depth but not the bare domain. Changes reach the API within a minute.