# Authentication

> Pass user auth context from your frontend to the agent so it can scope tools, data, and decisions to the signed-in user.


<!-- interactive demo: auth -->


You have a chat surface or a hook driving an agent and you want every agent run to know *who* the request came from. By the end of this guide, your frontend will forward a token, the runtime will pass it through, and your agent code will read the resulting user info on every turn.

## When to use this

- **Multi-tenant apps** where the agent reads or writes per-user data.
- **Tool gating** where some tools should only run for authorised users.
- **Audit and billing** where every run needs an identity to attribute it to.
- **Session-aware UX** where the agent's behaviour depends on the user's role or permissions.

If you don't need any of those, skip auth entirely. The agent runs anonymously and the frontend never has to care about tokens.





## Frontend

Pass your token via the `headers` prop. CopilotKit attaches it to every runtime request, and the runtime forwards the `Authorization` header on to your agent — whether that's a LangGraph deployment or a self-hosted AG-UI endpoint.

```tsx title="frontend/src/app/page.tsx"
import { CopilotKit } from "@copilotkit/react-core/v2";

<CopilotKit
  runtimeUrl="/api/copilotkit"
  headers={{
    Authorization: `Bearer ${userToken}`,
  }}
>
  <YourApp />
</CopilotKit>
```

<Callout type="warning" title="`properties` is not an auth channel">
Older versions of this guide passed the token as `properties={{ authorization: userToken }}`. The runtime delivers `properties` to the agent as AG-UI `forwardedProps` — run payload data, not a request header — so nothing turns them into a Bearer credential. Use `headers` for auth. Headers the server configured on the agent itself still win on collision, so a service-to-service token can't be overridden from the browser.
</Callout>

## Backend

LangGraph supports two deployment modes. The frontend code above is the same in both, but the backend wiring differs in where the resolved user identity lands. Pick the tab that matches where your agent runs.

<Tabs items={['LangGraph Platform', 'Self-hosted (FastAPI)']}>
<Tab value="LangGraph Platform">

On LangGraph Platform (and on `langgraph dev`), authentication is a managed service. You declare an `@auth.authenticate` handler, and the server runs it on every request before the graph starts. The forwarded `Authorization` header arrives as the handler's `authorization` argument, and the handler's return value becomes available to every node in the run.

```python title="backend/auth.py"
from langgraph_sdk import Auth

auth = Auth()

@auth.authenticate
async def authenticate(authorization: str | None):
    if not authorization or not authorization.startswith("Bearer "):
        raise Auth.exceptions.HTTPException(status_code=401, detail="Unauthorized")

    token = authorization.replace("Bearer ", "")
    user_info = validate_your_token(token)  # your validation logic

    return {
        "identity": user_info["user_id"],
        "role": user_info.get("role"),
        "permissions": user_info.get("permissions", []),
    }
```

The return value of the handler shows up in every node's `config["configurable"]["langgraph_auth_user"]`. From there, scoping tool access or filtering data is straightforward:

```python title="backend/agent.py"
from langchain_core.runnables import RunnableConfig

async def my_agent_node(state: AgentState, config: RunnableConfig):
    user_info = config["configurable"]["langgraph_auth_user"]
    user_id = user_info["identity"]
    user_role = user_info.get("role")
    # agent logic with user context
    return state
```

For full handler details, see the [LangGraph Platform Authentication documentation](https://docs.langchain.com/langsmith/auth#authentication).

</Tab>
<Tab value="Self-hosted (FastAPI)">

When you self-host the agent behind FastAPI, there's no managed auth handler to plug into — validation is your job, and the natural place for it is the endpoint that serves the AG-UI stream. `add_langgraph_fastapi_endpoint` mounts that endpoint for you, but it takes one pre-built agent and gives you no per-request hook, so replace it with the equivalent route of your own: a FastAPI dependency verifies the `Authorization` header the runtime forwarded, and the resolved user is baked into a per-request agent's `config`.

```python title="backend/main.py"
from typing import Optional

from ag_ui.core.types import RunAgentInput
from ag_ui.encoder import EventEncoder
from copilotkit import LangGraphAGUIAgent
from fastapi import Depends, FastAPI, Header, HTTPException, Request
from fastapi.responses import StreamingResponse

from src.agent import graph

app = FastAPI()

def current_user(authorization: Optional[str] = Header(default=None)) -> dict:
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Missing bearer token")
    return validate_your_token(authorization.removeprefix("Bearer ").strip())  # your validation

@app.post("/")
async def run_agent(
    input_data: RunAgentInput,
    request: Request,
    user: dict = Depends(current_user),
):
    encoder = EventEncoder(accept=request.headers.get("accept"))

    # One agent per request: the verified identity rides on this run only, and
    # each request gets its own isolated streaming state.
    agent = LangGraphAGUIAgent(
        name="sample_agent",
        graph=graph,
        config={"configurable": {"auth_user": user}},
    )

    async def event_generator():
        async for event in agent.run(input_data):
            yield encoder.encode(event)

    return StreamingResponse(event_generator(), media_type=encoder.get_content_type())
```

Unauthenticated requests never reach the graph — they get a 401 from the dependency. Authenticated ones arrive with an already-verified user on the config, so nodes read identity instead of re-validating a raw token:

```python title="backend/src/agent.py"
from langchain_core.runnables import RunnableConfig

async def my_agent_node(state: AgentState, config: RunnableConfig):
    user = config["configurable"]["auth_user"]
    user_id = user["user_id"]
    user_role = user.get("role")
    # agent logic with user context
    return state
```

<Callout type="info" title="If you only need the 401 gate">
When nodes don't need the identity — you just want unauthenticated traffic rejected — keep `add_langgraph_fastapi_endpoint` and hang the dependency off the app: `FastAPI(dependencies=[Depends(current_user)])`. The gate applies, but nothing lands on the run config, so `config["configurable"]` stays empty of user context.
</Callout>

<Callout type="warning" title="`CopilotKitRemoteEndpoint` no longer works here">
Guides written for CopilotKit v1 wrapped the graph in `CopilotKitRemoteEndpoint(agents=lambda context: [...])`. That path is retired: `copilotkit` no longer exports `LangGraphAgent` (`ImportError`), and the current `LangGraphAGUIAgent` exposes `run()` rather than the `execute()` that `CopilotKitRemoteEndpoint` calls — giving `AgentExecutionException: 'LangGraphAGUIAgent' object has no attribute 'execute'`. Use the endpoint above instead.
</Callout>

</Tab>
</Tabs>







## Tool gating

The most common reason to wire auth is so individual tools can decline to run. Read the resolved user inside the tool's handler and bail if the role doesn't match:

```python
def delete_record(record_id: str, *, user: User):
    if "admin" not in user.permissions:
        raise PermissionError("admin role required")
    # do the delete
```

This composes with [Human in the loop](/human-in-the-loop): gate on auth first, surface a confirmation card next, execute last.

## Thread authorization

Verifying *who* the caller is doesn't yet stop them reaching *someone else's conversation*. How much of that you have to build depends on which runtime you're running.

| Runtime | Who scopes threads to a user |
| --- | --- |
| `CopilotRuntime` with `intelligence` | Mostly the runtime, via [`identifyUser`](#copilotkit-intelligence-scopes-most-thread-routes) — with three routes you still have to guard. |
| Anything else — SSE runtime, custom store, local in-memory runner | You do. See [Scope threads yourself](#scope-threads-yourself). |

### CopilotKit Intelligence scopes most thread routes

The Intelligence runtime requires an `identifyUser` callback — construction throws without one (or without at least one Channel). It runs on the server, once per request, and the id it returns is the scope the runtime hands to the platform. (`intelligence` below is a `CopilotKitIntelligence` instance; [Connect your runtime to Intelligence](/intelligence/quickstart) covers building it.)

```ts title="app/api/copilotkit/[[...slug]]/route.ts"
const runtime = new CopilotRuntime({
  agents: { default: agent },
  intelligence,
  identifyUser: async (request) => {
    const session = await verifyAppSession(request); // Your server-side auth.
    if (!session?.user) throw new Error("Unauthorized"); // Backstop; see below.

    return { id: session.user.id, name: session.user.name };
  },
});
```

<Callout type="warn" title="`identifyUser` is not an authentication gate">
  Most routes resolve the caller through `identifyUser`, but **not all of them do** — and the
  ones that don't never invoke your callback at all. Rejecting unauthenticated requests is
  `onRequest`'s job: it runs before routing, on every route, without exception. Treat
  `identifyUser` as the thing that *names* an already-authenticated caller, never as the thing
  that decides whether a caller gets in.
</Callout>

Where a route does resolve the caller, what matters next is whether that id is actually *carried to the platform* as a scope:

| Route | Scoped to the resolved user? |
| --- | --- |
| `agent/run`, `agent/connect` | Yes |
| `threads/list` | Yes — and filtered by `agentId`, so `useThreads` returns the caller's threads rather than the project's |
| `threads/messages` | Yes |
| `threads/update` (rename via `PATCH`, delete via `DELETE`), `threads/archive` | Yes |
| Thread subscription token | Yes |
| `threads/events`, `threads/state` | **No** — resolves the caller, then ignores it |
| `agent/stop` | Yes — the thread is fetched as the caller before the run is aborted |

<Callout type="warn" title="Two routes are not user-scoped">
  **`threads/events` and `threads/state`** back the [inspector](/inspector). Both resolve the
  caller and then discard the result, reading the thread by id alone — the runtime calls the
  platform's project-authenticated `_inspect` endpoints, which take no user parameter. Any
  caller can read the full event log and current agent state of any thread in the project,
  given its `threadId`.

  Guard both yourself. The `onBeforeHandler` pattern below applies on the CopilotKit Intelligence
  path too, narrowed to these routes:

  ```ts
  onBeforeHandler: async ({ request, route }) => {
    // Switch rather than an array `includes`, so `route` narrows and
    // `route.threadId` type-checks — both variants carry one.
    switch (route.method) {
      case "threads/events":
      case "threads/state":
        break;
      default:
        return;
    }

    // None of these is scoped platform-side; check your own ownership record.
    const user = await verifyRequest(request);
    if (!(await userOwnsThread(user.id, route.threadId))) {
      throw new Response("Not found", { status: 404 });
    }
  },
  ```

  Without an ownership record of your own, reject these routes outright rather than leaving
  them open.
</Callout>

For everything in the Yes rows there is no ownership table to build. What you own is `identifyUser` itself:

- **Derive the id from a server-verified credential.** The callback receives the raw `Request`; whatever it returns is trusted from there on. Reading a user id straight out of a header or request body hands every caller the ability to name themselves.
- **Return a stable id.** It is the key threads hang off. Change it — swapping an email for a subject claim, say — and that user's existing threads stop resolving.
- **Send the `401` from `onRequest`.** Beyond the status codes being wrong — an `identifyUser` that throws surfaces as a `500`, a malformed id as a `400` — routes like `transcribe` and `agent/suggest` never call it, so a check placed only here isn't reached on every request. Authenticate in `onRequest`, which runs on every route and can throw a `Response` directly, and keep the throw inside `identifyUser` as a backstop.

<Callout type="warn">
  A static `identifyUser` value is suitable only for a single-user demo. In a multi-user
  application every request must resolve the authenticated user, or those users share one
  thread scope.
</Callout>

See [Scope AG-UI Streams to the signed-in user](/threads-lifecycle#scope-rich-threads-to-the-signed-in-user) for the full runtime contract.

### Scope threads yourself

Without CopilotKit Intelligence there is no server-side binding between a `threadId` and a user. A `threadId` is just an opaque id travelling in a request: if user A learns user B's, every thread route accepts it. The rest of this section is the pattern for a custom store, a plain SSE runtime, or the local in-memory runner — and it's also what you narrow to `threads/events` and `threads/state` if you *are* on the platform.

The default `InMemoryAgentRunner` is the plainest case. It holds threads in a
process-global map keyed by `threadId` with no owner field, so every
[thread route](/backend/runtime-endpoints#thread-routes) answers for any id it
is handed: `GET /threads` lists every thread in the process, and
`GET /threads/:threadId/messages` returns that thread's history to whoever asks.
Anything extending it, such as `AgentCoreRunner`, inherits the same store.
`SqliteAgentRunner` does not serve them at all: its read routes answer `422`.
A runner swap is therefore a fix for durability, not for thread authorization.

#### Own the mapping

Whatever stores your threads, keep a record of who each one belongs to. The minimum is a table your runtime can query:

```sql
create table thread_owners (
  thread_id text primary key,
  user_id   text not null
);
create index on thread_owners (user_id);
```

Write a row when a conversation is first created — see [minting a thread with your own API](/threads-lifecycle#creating-a-thread-with-your-own-api-on-the-first-message) for where that hooks into the chat lifecycle.

#### Enforce it in `onBeforeHandler`

`onRequest` runs before routing, so it can't see which thread is being addressed. `onBeforeHandler` runs after, and receives a `route` that names the operation and — for thread-scoped routes — the `threadId`:

```ts
const handler = createCopilotRuntimeHandler({
  runtime,
  basePath: "/api/copilotkit",
  hooks: {
    onRequest: async ({ request }) => {
      // Authenticate first: reject anonymous callers outright.
      const user = await verifyRequest(request);
      if (!user) throw new Response("Unauthorized", { status: 401 });
    },

    onBeforeHandler: async ({ request, route }) => {
      const user = await verifyRequest(request);

      // Routes that name a thread directly.
      if ("threadId" in route) {
        if (!(await userOwnsThread(user.id, route.threadId))) {
          throw new Response("Forbidden", { status: 403 });
        }
        return;
      }

      // agent/run and agent/connect carry the thread in the body instead.
      if (route.method === "agent/run" || route.method === "agent/connect") {
        // Clone: the handler still needs to read the original body.
        const { threadId } = await request.clone().json();
        if (threadId && !(await userOwnsThread(user.id, threadId))) {
          throw new Response("Forbidden", { status: 403 });
        }
      }
    },
  },
});
```

The routes that carry a `threadId` on `route` are `agent/stop`, `threads/update`, `threads/archive`, `threads/messages`, `threads/events`, and `threads/state`.

<Callout type="warn" title="A thread id is not a secret">
  Treat `threadId` as a public identifier, like a database primary key in a URL. It travels
  through the browser, appears in logs, and is trivially enumerable if you mint sequential
  ids. Authorization has to be an explicit ownership check — never "they knew the id, so they
  must own it". Minting UUIDs makes guessing impractical but is not itself a control.
</Callout>

#### Filter the thread list

`threads/list` has no `threadId` to check, so `onBeforeHandler` has nothing to authorize against. Off the platform the route returns whatever the configured store holds — the local in-memory runner filters by `agentId` only, and a custom store returns exactly what you wrote. Build the list from your own ownership table instead, and drive the chat with the selected id.

Filter server-side. Hiding rows in the UI leaves the underlying route open.

`threads/clear` is the other route with no `threadId` on `route`. On the
in-memory runner it wipes every thread in the process and returns `204`, so the
only decision `onBeforeHandler` can make is whether to allow the call. With
more than one user on the deployment, reject it unless the caller is an
operator.

<Callout type="info">
  With no platform thread store, the ownership table above *is* your thread list.
</Callout>

## Security checklist

- **Always validate** the token on the backend. Never trust the frontend's claim.
- **Scope every read and write** to the resolved user. Auth context only matters if you actually use it to filter data.
- **Authenticate in `onRequest`.** It is the only hook that runs on every route. `identifyUser` names a caller; it does not gate one, and some routes never invoke it.
- **Bind threads to a user.** On CopilotKit Intelligence that means a correct `identifyUser`, plus your own guard on `threads/events` and `threads/state`; off it, an ownership check on every thread route rather than only at login. See [Thread authorization](#thread-authorization).
- **Don't log raw tokens.** Log the resolved user id (or `anonymous`) instead.
- **Use HTTPS in production.** The Bearer token is sensitive.
- **Refresh strategy.** Your frontend is responsible for rotating expired tokens before they reach the agent. CopilotKit doesn't refresh on your behalf.

<IntegrationGrid path="auth" />
