Authenticating requests
Keep the login flow you already have. Skein only needs the server-side step that turns an incoming request into a stable principal. Its auth.path uses LangGraph's Auth class, so the adapter is one function:
cookie or bearer token → your provider's server SDK → { identity, permissions }The examples below were checked against the providers' official server documentation on 2026-09-17 (Better Auth 1.7, Auth.js next-auth 5.0 beta 32, @clerk/backend 3.16, @supabase/supabase-js 2.116, firebase-admin 14.3, and jose 6.2). Provider APIs move independently of Skein, so follow the linked provider documentation if your installed major differs.
| If your app already uses… | Start here |
|---|---|
| Better Auth sessions | Better Auth — pass Skein's request headers through |
| Auth.js / NextAuth sessions | Auth.js — wrap your session reader |
| Clerk users and organizations | Clerk — authenticate the whole request |
| Supabase Auth | Supabase — verify its access token |
| Firebase Auth | Firebase — verify its ID token |
| Auth0, Okta, Cognito, WorkOS, or another issuer | OIDC/JWT |
Nothing here replaces your sign-in page, callbacks, or session storage. Those stay with the provider you already chose; this page is only the bridge at the API boundary.
Wire the provider into Skein
Choose one authenticate-request.ts implementation below, then apply your authorization policy in auth.ts. The Better Auth section shows a co-located version instead, because its adapter must reuse the exact Better Auth instance your application already exports.
// auth.ts
import { Auth } from "@langchain/langgraph-sdk/auth";
import { authenticateRequest } from "./authenticate-request.js";
const tenantLabel = (identity: string) =>
encodeURIComponent(identity).replace(/\./g, "%2E").replace(/\*/g, "%2A");
export const auth = new Auth()
.authenticate(authenticateRequest)
// A filter hides other owners' rows on reads and stamps ownership onto writes. Runs authorize
// through their thread, and crons fall back to this handler when no crons handler is registered.
.on("threads", ({ user }) => ({ owner: user.identity }))
// Store items have no metadata to filter. Root their namespace instead.
.on("store", ({ user, value }) => {
value.namespace = [tenantLabel(user.identity), ...(value.namespace ?? []).slice(1)];
});Point langgraph.json at that export:
{
"auth": { "path": "./src/auth.ts:auth", "disable_studio_auth": true },
}Set disable_studio_auth to false only when you intentionally want LangGraph Studio traffic to bypass your provider during development. Authentication answers who is calling; the .on(...) handlers answer what they may access. Keep organization membership, roles, and other application policy in those handlers rather than in Skein config.
Bearer-token providers can share this small helper:
// auth-helpers.ts
import { HTTPException } from "@langchain/langgraph-sdk/auth";
export function requireBearerToken(request: Request): string {
const match = request.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i);
if (!match?.[1]) throw new HTTPException(401, { message: "Missing bearer token" });
return match[1];
}Better Auth
Better Auth's server API accepts the same Headers object Skein gives the authentication callback. This works with its normal session cookie; it also accepts Authorization: Bearer ... when you have enabled Better Auth's Bearer plugin.
// src/auth.ts — add `skeinAuth` beside the Better Auth instance your app already uses
import { Auth as LangGraphAuth, HTTPException } from "@langchain/langgraph-sdk/auth";
import { betterAuth } from "better-auth";
export const auth = betterAuth({
// Keep your existing database, providers, plugins, and session options here.
});
const tenantLabel = (identity: string) =>
encodeURIComponent(identity).replace(/\./g, "%2E").replace(/\*/g, "%2A");
export const skeinAuth = new LangGraphAuth()
.authenticate(async (request) => {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) throw new HTTPException(401, { message: "Unauthorized" });
return {
identity: session.user.id,
display_name: session.user.name,
email: session.user.email,
permissions: [],
};
})
.on("threads", ({ user }) => ({ owner: user.identity }))
.on("store", ({ user, value }) => {
value.namespace = [tenantLabel(user.identity), ...(value.namespace ?? []).slice(1)];
});This reuses your existing Better Auth instance and session store; do not create a second auth database for Skein. Let Better Auth's own client and endpoints continue to handle login, logout, and cookie refresh—Skein only reads the session presented on each request. See Better Auth's server-side session API.
For this variant, point langgraph.json at "./src/auth.ts:skeinAuth" so it does not confuse the Better Auth instance with LangGraph's Auth instance.
Clerk
Use Clerk's request-level verifier rather than decoding the session JWT yourself. Set authorizedParties to the origins that are allowed to send Clerk credentials to this server.
// authenticate-request.ts
import { createClerkClient } from "@clerk/backend";
import { HTTPException } from "@langchain/langgraph-sdk/auth";
const clerk = createClerkClient({
secretKey: process.env.CLERK_SECRET_KEY,
publishableKey: process.env.CLERK_PUBLISHABLE_KEY,
});
export async function authenticateRequest(request: Request) {
const requestState = await clerk.authenticateRequest(request, {
authorizedParties: [process.env.APP_ORIGIN!],
});
if (!requestState.isAuthenticated) {
throw new HTTPException(401, { message: "Unauthorized" });
}
const principal = requestState.toAuth();
if (!principal.userId) throw new HTTPException(401, { message: "Unauthorized" });
return {
identity: principal.userId,
org_id: principal.orgId,
permissions: principal.orgPermissions ?? [],
};
}Clerk documents the full authenticateRequest() contract, including networkless verification with CLERK_JWT_KEY and separate machine-token modes.
Supabase Auth
For an API server, accept the user's Supabase access token as a bearer token and verify its claims. getClaims(token) uses the project's cached JWKS when possible; unlike getSession(), it does not trust client-side session storage.
// authenticate-request.ts
import { HTTPException } from "@langchain/langgraph-sdk/auth";
import { createClient } from "@supabase/supabase-js";
import { requireBearerToken } from "./auth-helpers.js";
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_PUBLISHABLE_KEY!);
export async function authenticateRequest(request: Request) {
const { data, error } = await supabase.auth.getClaims(requireBearerToken(request));
if (error || !data || typeof data.claims.sub !== "string") {
throw new HTTPException(401, { message: "Unauthorized" });
}
const scope = data.claims.scope;
return {
identity: data.claims.sub,
permissions: typeof scope === "string" ? scope.split(" ").filter(Boolean) : [],
};
}See Supabase's getClaims() reference. If your project still uses an HS256 signing secret, Supabase may call its Auth server to verify each token; do not decode the JWT without verifying it.
Firebase Auth
The browser obtains a Firebase ID token after sign-in and sends it as a bearer token. Verify that ID token with the Admin SDK; do not send or accept a Firebase custom token here.
// authenticate-request.ts
import { applicationDefault, getApps, initializeApp } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";
import { HTTPException } from "@langchain/langgraph-sdk/auth";
import { requireBearerToken } from "./auth-helpers.js";
const firebaseApp = getApps()[0] ?? initializeApp({ credential: applicationDefault() });
const firebaseAuth = getAuth(firebaseApp);
export async function authenticateRequest(request: Request) {
try {
const token = await firebaseAuth.verifyIdToken(requireBearerToken(request));
return { identity: token.uid, permissions: [] };
} catch {
throw new HTTPException(401, { message: "Unauthorized" });
}
}Firebase's ID-token verification guide explains service-account setup and the optional revocation check. Map only custom claims you created for authorization; do not turn every JWT claim into a permission.
Auth0, Okta, Cognito, WorkOS, or another OIDC issuer
For a standards-based issuer, verify the access-token signature, issuer, audience, and expiry from its JWKS. The jose package works across Node, Bun, and Deno:
// authenticate-request.ts
import { HTTPException } from "@langchain/langgraph-sdk/auth";
import { createRemoteJWKSet, errors, jwtVerify } from "jose";
import { requireBearerToken } from "./auth-helpers.js";
const issuer = process.env.OIDC_ISSUER!; // exact `iss`
const audience = process.env.OIDC_AUDIENCE!; // this Skein API's identifier
const issuerBase = issuer.endsWith("/") ? issuer : `${issuer}/`;
const jwks = createRemoteJWKSet(new URL(".well-known/jwks.json", issuerBase));
export async function authenticateRequest(request: Request) {
try {
const { payload } = await jwtVerify(requireBearerToken(request), jwks, {
issuer,
audience,
});
if (!payload.sub) throw new HTTPException(401, { message: "Unauthorized" });
return {
identity: payload.sub,
permissions:
typeof payload.scope === "string" ? payload.scope.split(" ").filter(Boolean) : [],
};
} catch (error) {
if (error instanceof HTTPException) throw error;
if (error instanceof errors.JOSEError) {
throw new HTTPException(401, { message: "Unauthorized" });
}
throw error;
}
}Use an access token whose audience is this API, not an ID token meant for a browser client. Auth0, for example, makes that distinction explicit in its token guide. Some issuers publish a discovery document at /.well-known/openid-configuration; use its jwks_uri when it differs from the conventional path above.
Authorization: decide what callers can access
Once a provider has proved who the caller is, stop thinking about Clerk, Firebase, or JWTs. Every provider now reaches the same LangGraph authorization handlers with a normalized user:
user.identityis the stable user ID.user.permissionsis the trusted permission list you returned during authentication.- Extra verified fields such as Clerk's
org_idremain available onuser.
An authorization handler has three useful answers:
| Return value | Meaning |
|---|---|
false | Deny with 403 |
true, null, or no return | Allow without an ownership filter |
{ owner: user.identity } | Allow and scope the resource; the filter also stamps newly created rows |
Rewrite value.namespace | Scope long-term store access |
Handlers match from most specific to broadest: threads:delete → threads → *:delete → *. That lets one sensitive action be stricter without repeating the policy for every route.
A practical per-user policy
This is a good default for an app where every person owns their own conversations:
export const auth = new Auth()
.authenticate(authenticateRequest)
.on("threads:delete", ({ user }) =>
user.permissions.includes("threads:delete") ? { owner: user.identity } : false,
)
.on("threads", ({ user }) =>
user.permissions.includes("skein:admin") ? true : { owner: user.identity },
)
.on("assistants", ({ user }) => user.permissions.includes("assistants:read"))
.on("store", ({ user, value }) => {
value.namespace = [tenantLabel(user.identity), ...(value.namespace ?? []).slice(1)];
});Here an administrator can see all threads, ordinary users see only their own, deletion needs an additional permission, and assistant discovery is explicitly gated. Returning an ownership filter for threads:delete is important: returning only true would let any caller with that permission delete any user's thread.
Thread policy automatically covers runs because runs belong to a thread. Crons have their own resource, but fall back to the threads handler when you do not register a crons handler. Add an explicit handler when scheduling is more privileged than chatting:
auth.on("crons", ({ user }) =>
user.permissions.includes("crons:manage") ? { owner: user.identity } : false,
);Share data inside an organization
If threads belong to a Clerk organization, Auth0 organization, or your own workspace rather than one person, authenticate the organization ID as a trusted field and filter on it consistently:
const organizationId = (user: { org_id?: unknown }) =>
typeof user.org_id === "string" ? user.org_id : undefined;
auth
.on("threads", ({ user }) => {
const orgId = organizationId(user);
return orgId ? { org_id: orgId } : false;
})
.on("store", ({ user, value }) => {
const orgId = organizationId(user);
if (!orgId) throw new HTTPException(403, { message: "Choose an organization" });
value.namespace = [tenantLabel(orgId), ...(value.namespace ?? []).slice(1)];
});Do not accept org_id, owner, or permissions from request metadata or graph input. They must come from provider-verified claims. Decide explicitly what a user with no active organization should do; the example denies access instead of silently falling back to a personal tenant.
Know which resources can be filtered
| Resource | What authorization can do |
|---|---|
threads | Deny or return metadata filters; the same policy protects their runs |
crons | Deny or return metadata filters; falls back to threads when no cron handler exists |
assistants | Gate access only; graph-backed assistants are shared, so ownership filters do not apply |
store | Rewrite value.namespace; returning a metadata filter does not isolate store items |
The last row is the easy one to miss: authentication alone does not make long-term memory multi-tenant. Keep the namespace rewrite even if every other resource is owner-filtered. Calls to getStore() from inside a graph do not pass through HTTP authorization, so build their namespace from config.configurable.langgraph_auth_user_id (or another server-injected verified field), never from model output.
Auth.js / NextAuth
Auth.js already knows how to verify its own session. You only need a small wrapper that accepts two functions:
readVerifiedSession(request)returns your provider's verified session, ornull.toPrincipal(session)returns the user shape Skein needs.identitymust be a stable user ID; everything else is application-defined context for authorization handlers.
The wrapper returns exactly the function accepted by .authenticate(...): (request: Request) => Promise<SkeinPrincipal>.
import { HTTPException } from "@langchain/langgraph-sdk/auth";
type SkeinPrincipal = {
identity: string;
permissions: string[];
[attribute: string]: unknown;
};
type ReadVerifiedSession<TSession> = (request: Request) => Promise<TSession | null>;
type ToPrincipal<TSession> = (session: TSession) => SkeinPrincipal;
function createSessionAuthenticator<TSession>(
readVerifiedSession: ReadVerifiedSession<TSession>,
toPrincipal: ToPrincipal<TSession>,
): (request: Request) => Promise<SkeinPrincipal> {
return async (request) => {
const session = await readVerifiedSession(request);
if (!session) throw new HTTPException(401, { message: "Unauthorized" });
const principal = toPrincipal(session);
if (!principal.identity) throw new HTTPException(401, { message: "Unauthorized" });
return principal;
};
}For example, in the same module where a Next.js app already configures Auth.js, use the exported auth() session reader and map your session fields:
import { Auth as LangGraphAuth } from "@langchain/langgraph-sdk/auth";
import NextAuth, { type DefaultSession } from "next-auth";
import GitHub from "next-auth/providers/github";
declare module "next-auth" {
interface Session {
user: {
id: string;
permissions?: string[];
} & DefaultSession["user"];
}
}
const { auth: readAuthJsSession, handlers } = NextAuth({
providers: [GitHub],
callbacks: {
session({ session, token }) {
// This example uses Auth.js's JWT session strategy. For database sessions, use `user.id`.
if (!token.sub) throw new Error("Auth.js token is missing a user ID");
session.user.id = token.sub;
return session;
},
},
});
export { handlers };
export const skeinAuth = new LangGraphAuth()
.authenticate(
createSessionAuthenticator(
async () => readAuthJsSession(),
(session) => ({
identity: session.user.id,
display_name: session.user.name,
email: session.user.email,
permissions: session.user.permissions ?? [],
}),
),
)
.on("threads", ({ user }) => ({ owner: user.identity }));This is intentionally code you own: change only the session reader and field mapping when your framework or session shape differs. Auth.js documents how to expose a stable user ID for JWT and database sessions and how to keep custom session fields type-safe with module augmentation.
If Skein runs as a separate server, the same wrapper still applies, but readVerifiedSession(request) should call a trusted session-validation endpoint. Alternatively, exchange the application session for a short-lived signed access token and use the OIDC/JWT recipe. Do not feed an Auth.js database-session cookie to a JWT decoder: that cookie is a lookup key, not a JWT.
Browser cookies and CORS
Same-origin cookies need no CORS configuration. Across origins, all three pieces must agree:
- The browser request includes credentials.
- The auth provider issued a cookie valid for the Skein origin/domain and appropriate
SameSiteandSecureattributes. - Skein allows the exact frontend origin and credentials—never
*with credentials:
{
"http": {
"cors": {
"allow_origins": ["https://app.example.com"],
"allow_credentials": true,
},
},
}Bearer tokens do not require credentialed cookies, but the browser still needs the authorization header allowed by its CORS preflight. Prefer same-origin mounting when your framework already hosts the frontend.
What Skein does after authentication
The returned object becomes user in every .on(...) callback. Skein also stamps it into graph run config as langgraph_auth_user, langgraph_auth_user_id, and langgraph_auth_permissions; clients cannot spoof those keys. Thread filters apply to runs as well. Long-term store access is separate and must be rooted by namespace, as in the shared policy above.
For the full request lifecycle, route-to-permission map, and store-scoping traps, see Authentication + authorization.