LangGraph Tutorial (TypeScript): adding authentication for intermediate developers

By Cyprian AaronsUpdated 2026-04-22
langgraphadding-authentication-for-intermediate-developerstypescript

This tutorial shows how to add authentication to a LangGraph TypeScript app so only verified users can start or continue a graph run. You need this when your agent sits behind an API, serves multiple tenants, or handles sensitive workflows like banking, claims, or internal ops.

What You'll Need

  • Node.js 18+
  • TypeScript 5+
  • langgraph package
  • zod for request validation
  • A valid auth token source:
    • JWT from your identity provider, or
    • a static bearer token for local testing
  • An existing LangGraph app or a new TypeScript project

Install the packages:

npm install langgraph zod
npm install -D typescript tsx @types/node

Step-by-Step

  1. Create a small auth layer first. Keep it separate from the graph so you can reuse it in HTTP routes, queues, and background jobs. For this tutorial, we’ll validate a bearer token and attach a user object to the request context.
// auth.ts
import { z } from "zod";

const AuthHeaderSchema = z.string().regex(/^Bearer\s.+$/);

export type UserContext = {
  userId: string;
  role: "customer" | "agent" | "admin";
};

export function authenticate(authHeader?: string): UserContext {
  const parsed = AuthHeaderSchema.safeParse(authHeader);
  if (!parsed.success) {
    throw new Error("Missing or invalid Authorization header");
  }

  const token = parsed.data.slice("Bearer ".length);
  if (token !== process.env.API_TOKEN) {
    throw new Error("Unauthorized");
  }

  return { userId: "user_123", role: "customer" };
}
  1. Define your graph state with an authenticated user in the context. The important part is that every node can read config.configurable.user, which lets you enforce access checks inside the graph itself.
// graph.ts
import { Annotation, StateGraph } from "langgraph";
import type { UserContext } from "./auth.js";

const GraphState = Annotation.Root({
  message: Annotation<string>(),
});

type GraphConfig = {
  configurable: {
    user: UserContext;
  };
};

async function greetNode(state: typeof GraphState.State, config: GraphConfig) {
  const { user } = config.configurable;
  return {
    message: `Hello ${user.userId}, you said: ${state.message}`,
  };
}

const builder = new StateGraph(GraphState)
  .addNode("greet", greetNode)
  .addEdge("__start__", "greet")
  .addEdge("greet", "__end__");

export const app = builder.compile();
  1. Add authorization checks inside nodes for role-based access. Authentication says who the caller is; authorization decides what they can do. If you expose admin-only tools later, this pattern keeps the rule close to the action.
// secure-node.ts
import type { UserContext } from "./auth.js";

export async function adminOnlyNode(
  input: { action: string },
  user: UserContext,
) {
  if (user.role !== "admin") {
    throw new Error("Forbidden");
  }

  return {
    result: `Admin action completed: ${input.action}`,
  };
}
  1. Wire everything into an executable entry point. This example reads an auth header, authenticates the caller, then passes that identity into LangGraph via configurable.
// index.ts
import { authenticate } from "./auth.js";
import { app } from "./graph.js";

async function main() {
  process.env.API_TOKEN ??= "dev-token";

  const user = authenticate("Bearer dev-token");

  const result = await app.invoke(
    { message: "I need my account balance" },
    {
      configurable: { user },
    },
  );

  console.log(result);
}

main().catch((err) => {
  console.error(err.message);
  process.exit(1);
});
  1. If you’re serving this over HTTP, authenticate before invoking the graph and return clean status codes. Don’t let unauthenticated requests reach your graph nodes; that makes debugging harder and increases attack surface.
// server.ts
import http from "node:http";
import { authenticate } from "./auth.js";
import { app } from "./graph.js";

process.env.API_TOKEN ??= "dev-token";

http.createServer(async (req, res) => {
  try {
    const user = authenticate(req.headers.authorization);

    const result = await app.invoke(
      { message: "show me my policy status" },
      { configurable: { user } },
    );

    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(result));
  } catch (err) {
    const message = err instanceof Error ? err.message : "Internal Server Error";
    const status = message === "Unauthorized" || message.includes("Authorization")
      ? 401
      : message === "Forbidden"
        ? 403
        : 500;

    res.writeHead(status, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ error: message }));
  }
}).listen(3000);

Testing It

Run the entry point with tsx index.ts and confirm it prints a graph response for the authenticated user. Then start the server with tsx server.ts and call it with and without an Authorization header.

Use these checks:

  • curl http://localhost:3000 should return 401
  • curl -H 'Authorization: Bearer dev-token' http://localhost:3000 should return 200
  • Change API_TOKEN and verify old tokens fail immediately

If you add role-based nodes later, test both allowed and forbidden paths. You want authentication failures to happen before graph execution and authorization failures to happen at the exact node that requires privilege.

Next Steps

  • Replace the static token check with JWT verification using your IdP’s public keys.
  • Add per-user rate limits before calling app.invoke.
  • Store authenticated identity in your trace metadata so LangSmith or your logs can correlate runs by tenant and user.

Keep learning

By Cyprian Aarons, AI Consultant at Topiax.

Want the complete 8-step roadmap?

Grab the free AI Agent Starter Kit — architecture templates, compliance checklists, and a 7-email deep-dive course.

Get the Starter Kit

Related Guides