Authentication

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


"use client";// Auth demo — framework-native request authentication via the V2 runtime's// `onRequest` hook. The runtime route (/api/copilotkit-auth) rejects any// request whose `Authorization: Bearer <demo-token>` header is missing or// wrong.//// UX shape: the demo defaults to UNAUTHENTICATED on first paint so visitors// land on a clear sign-in card. We don't render `<CopilotKit>` until the user// has signed in at least once — that sidesteps the transport 401 that would// otherwise crash `<CopilotChat>` during its initial `/info` handshake.// After the user signs in once, `<CopilotKit>` stays mounted across the// sign-out → sign-in cycle so the post-sign-out state can actually// demonstrate the runtime rejecting unauthenticated requests in the chat// surface (the whole point of the demo).//// Error surfacing: the post-sign-out 401 is captured via the AGENT-SCOPED// `<CopilotChat onError>` channel, NOT the provider-level `<CopilotKit// onError>` alone. Agent-run errors (`agent_run_failed`) are reliably// delivered to the chat-scoped subscription, whereas the provider-level// handler does not fire for them in this flow — so a demo that relies only// on `<CopilotKit onError>` never renders the rejection banner. We register// the same handler on BOTH channels: `<CopilotKit onError>` covers any// provider-level errors (e.g. the initial `/info` handshake) and// `<CopilotChat onError>` covers agent-run rejections, which is what the// sign-out path produces.import { useCallback, useEffect, useMemo, useState } from "react";import { CopilotKit, CopilotChat } from "@copilotkit/react-core/v2";import type { CopilotKitCoreErrorCode } from "@copilotkit/react-core/v2";import { AuthBanner } from "./auth-banner";import { SignInCard } from "./sign-in-card";import { useDemoAuth } from "./use-demo-auth";import { DEMO_TOKEN } from "./demo-token";interface AuthDemoErrorState {  message: string;  code: CopilotKitCoreErrorCode | string;}interface AuthErrorEvent {  error?: { message?: string } | null;  code: CopilotKitCoreErrorCode;}export default function AuthDemoPage() {  const {    isAuthenticated,    authorizationHeader,    hasEverSignedIn,    signIn,    signOut,  } = useDemoAuth();  const headers = useMemo<Record<string, string>>(    () => (authorizationHeader ? { Authorization: authorizationHeader } : {}),    [authorizationHeader],  );  const [authError, setAuthError] = useState<AuthDemoErrorState | null>(null);  // Shared error handler wired to BOTH the provider-level and chat-level  // `onError` channels (see the file header for why both are needed).  const handleAuthError = useCallback((event: AuthErrorEvent) => {    setAuthError({      message:        (event.error?.message && event.error.message.trim()) ||        (event.code          ? `Request rejected (${event.code})`          : "The request was rejected."),      code: event.code,    });  }, []);  // Clear stale errors as soon as the user re-authenticates. This is the  // ONLY thing that gates the amber error surface on auth state — the render  // condition below keys off `authError` alone. Coupling the render to a  // second `!isAuthenticated` slice (the obvious-but-wrong guard) created a  // post-sign-out race: the rejection's `onError` fires and calls  // `setAuthError`, but if that commit landed in a render where the auth  // state hadn't yet settled to false, `authError && !isAuthenticated`  // evaluated false and the banner never appeared. Driving the surface off  // `authError` and clearing it here on re-auth removes the cross-slice  // ordering dependency: a rejection always renders, and signing back in  // always wipes it.  useEffect(() => {    if (isAuthenticated) setAuthError(null);  }, [isAuthenticated]);  if (!hasEverSignedIn) {    return (      <div className="flex h-screen flex-col">        <SignInCard onSignIn={signIn} />      </div>    );  }  return (    // `useSingleEndpoint={false}` opts into the V2 multi-endpoint protocol    // (separate /info, /agents/<id>/run, etc.), which is what this demo's    // runtime route is wired up for.    <CopilotKit      runtimeUrl="/api/copilotkit-auth"      agent="auth-demo"      headers={headers}      useSingleEndpoint={false}      onError={handleAuthError}    >      <div className="flex h-screen flex-col gap-3 p-6">        <AuthBanner          authenticated={isAuthenticated}          onSignOut={signOut}          onSignIn={() => signIn(DEMO_TOKEN)}        />        <header>          <h1 className="text-lg font-semibold">Authentication</h1>        </header>        {authError && (          <div            data-testid="auth-demo-error"            className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900"          >            <strong className="font-semibold">              Runtime rejected the request:            </strong>{" "}            <span data-testid="auth-demo-error-message">              {authError.message}            </span>{" "}            <code className="ml-1 rounded bg-amber-100 px-1 py-0.5 font-mono text-xs">              {authError.code}            </code>          </div>        )}        <div className="flex-1 overflow-hidden rounded-md border border-neutral-200">          <CopilotChat            agentId="auth-demo"            className="h-full"            onError={handleAuthError}          />        </div>      </div>    </CopilotKit>  );}

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 on <CopilotKit>. CopilotKit forwards every request with that header attached.

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

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

About the explicit useSingleEndpoint

The createCopilotRuntimeHandler route below serves multi-route, the default. Both <CopilotKit> and <CopilotKitProvider> negotiate the transport when the prop is omitted, so this {false} is optional: it pins the multi-route REST routes rather than probing for them. See Provider and handler pairs.

Backend#

Wire authentication into the V2 runtime via the onRequest hook. The hook runs before any agent code and operates on the raw Request, so it's the right place to read the Authorization header, run your verifier, and either let the request through or short-circuit with a 401:

app/api/copilotkit/[[...slug]]/route.ts
import type { NextRequest } from "next/server";
import {
  CopilotRuntime,
  createCopilotRuntimeHandler,
} from "@copilotkit/runtime/v2";

const runtime = new CopilotRuntime({ agents: { default: myAgent } });

const handler = createCopilotRuntimeHandler({
  runtime,
  basePath: "/api/copilotkit",
  hooks: {
    onRequest: ({ request }) => {
      const authHeader = request.headers.get("authorization");
      if (!authHeader?.startsWith("Bearer ")) {
        throw new Response(
          JSON.stringify({ error: "unauthorized" }),
          { status: 401, headers: { "content-type": "application/json" } },
        );
      }
      const token = authHeader.slice("Bearer ".length);
      const user = verifyJwt(token); // your validation
      // attach user to request-scoped context here
    },
  },
});

export const POST = (req: NextRequest) => handler(req);
export const GET = (req: NextRequest) => handler(req);

The V1 Next.js adapter (copilotRuntimeNextJSAppRouterEndpoint) does not forward the hooks option. Use createCopilotRuntimeHandler from @copilotkit/runtime/v2 directly when you need the onRequest gate.

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:

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

RuntimeWho scopes threads to a user
CopilotRuntime with intelligenceMostly the runtime, via identifyUser — with three routes you still have to guard.
Anything else — SSE runtime, custom store, local in-memory runnerYou do. See Scope threads yourself.

The Intelligence Platform 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:

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 };
  },
});

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

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

RouteScoped to the resolved user?
agent/run, agent/connectYes
threads/listYes — and filtered by agentId, so useThreads returns the caller's threads rather than the project's
threads/messagesYes
threads/update (rename via PATCH, delete via DELETE), threads/archiveYes
Thread subscription tokenYes
threads/events, threads/stateNo — resolves the caller, then ignores it
agent/stopNo — never resolves a caller at all

Three routes are not user-scoped

threads/events and threads/state back the 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.

agent/stop never resolves a caller. It goes straight to runner.stop({ threadId }), which aborts whichever run the runtime is tracking under that thread id. Any caller who learns an active threadId can kill that run mid-flight.

Guard all three yourself. The onBeforeHandler pattern below applies on the Intelligence path too, narrowed to these routes:

onBeforeHandler: async ({ request, route }) => {
  // Switch rather than an array `includes`, so `route` narrows and
  // `route.threadId` type-checks — all three variants carry one.
  switch (route.method) {
    case "threads/events":
    case "threads/state":
    case "agent/stop":
      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.

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 agent/stop 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.

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.

See Scope Rich Threads to the signed-in user for the full runtime contract.

Scope threads yourself#

Without the Intelligence Platform 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, threads/state, and agent/stop if you are on the platform.

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:

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

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.

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.

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.

With no platform thread store, the ownership table above is your thread list. See Self-managed thread persistence.

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 the Intelligence Platform that means a correct identifyUser, plus your own guard on threads/events, threads/state, and agent/stop; off it, an ownership check on every thread route rather than only at login. See 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.