StateGraph plus ToolNode. Pin @langchain/langgraph with core, keep keys out of checkpoints, screen text before invoke, don't treat interrupt() as a gate, wrap authored tools, wrap ToolNode in place for MCP, read arcjetDenied on the payload, and run the same scanners in CI that you run in the editor.What is the LangGraph security guide?
Use this guide on LangGraph JS 1.4.x (@langchain/langgraph >=1 <2, @langchain/core >=1 <2).
LangGraph models agents as graphs: nodes, edges, and state that persists across turns. A typical pattern is an LLM node that proposes tool calls and a ToolNode that executes them. Checkpoints can replay messages and tool arguments later. That persistence makes prompt injection and unguarded tools especially costly—a bad turn can survive in state.
You need two separate controls. Screen user text before graph.invoke, or in the graph's first node, then gate tool calls before they run—including MCP tools that only appear inside ToolNode. LangGraph interrupt() pauses for human confirmation; that pause isn't a policy gate on its own.
Arcjet Guard is a runtime policy layer for AI agents. You define rules—prompt-injection detectors, rate limits, PII checks, allowlists—and Guard allows or denies inbound text and tool calls before side effects run. The examples below use Guard for both controls.
This is the Graph API: StateGraph plus ToolNode. It isn't deprecated createReactAgent, LangChain JS createAgent / wrapToolCall, or Python create_agent. Python graphs that go through create_agent belong in the LangChain security guide.
Work through these eight topics in order. Each one is a control you can verify, not a slogan.
- Patch and lock dependencies. Stay on LangGraph 1.x.
- Keep API keys off the client and out of checkpoint payloads.
- Screen user text before
graph.invoke, or in the first graph node. - Don't treat
interrupt()as a policy gate. - Wrap authored
tool()/StructuredToolhandlers. - Wrap
ToolNodein place so MCP and unwrapped tools hit the policy gate. - Read
arcjetDeniedon the payload.ToolMessage.statusstayssuccess. - Catch issues in the editor and in CI before they ship.
For shorter wiring recipes, see How do I secure a LangGraph JS agent? and How do I secure MCP tools in LangGraph?. For the Guard helpers used in the examples, see the LangGraph agent guard.
How do you keep LangGraph dependencies safe?
Apply patches on a schedule you actually keep, and out of band for a critical advisory. Commit a lockfile so every environment installs the same graph, and turn on Dependabot, Renovate, or an equivalent bot for npm. Run npm audit (or your package manager's audit) in CI and fail on known high-severity issues you haven't waived.
LangGraph 1.0 is an LTS line. Pin @langchain/langgraph and @langchain/core together so a core major can't drift under the graph package. Checkpoint packages (@langchain/langgraph-checkpoint-postgres, SQLite) write tool arguments and messages—an added checkpoint backend is a data-residency review, not only a dependency bump. Treat install scripts and added network access as review events. See trivial packages.
How should LangGraph secrets be handled?
Model keys such as OPENAI_API_KEY belong on the server, loaded at process start. Don't put them in graph state, a checkpoint blob, or a configurable field the client can set.
Checkpoints persist messages. A note that contains a card number is still there after the turn ends. Redact before you checkpoint, or exclude that field from state. See redacting sensitive data from logs and storing secrets in environment variables.
How do you screen inbound LangGraph text?
LangGraph has no first-class inbound channel, so screen in your route handler, job, or webhook before the graph runs—or in the first node if you prefer the check to live inside the graph. Either way, evaluate untrusted text before messages enter state that checkpoints may persist.
With Guard, call arcjet.guard() with prompt-injection rules on that string. Direct guard() fails open: an ALLOW isn't proof the rules ran, so gate on decision.hasFailedOpen() when this call site must fail closed. On deny, don't call invoke(). guardTool and guardToolNode default to onGuardError: "deny".
import { detectPromptInjection } from "@arcjet/guard";import { langgraphAgentContext } from "@arcjet/guard/langgraph/v1";import { arcjet } from "./arcjet.js";
const config = { configurable: { thread_id: conversationId } };const inbound = detectPromptInjection();const decision = await arcjet.guard({ label: "message.received", rules: [inbound(userText)], ...langgraphAgentContext(config),});
if (decision.conclusion === "DENY" || decision.hasFailedOpen()) { throw new Error("message blocked");}
await graph.invoke({ messages: [{ role: "user", content: userText }] }, config);Why isn't interrupt() a security policy?
LangGraph can pause the graph and wait for a person before tools run. That is human-in-the-loop: good for workflows where someone must review context, but not the same as a remote policy that evaluates every tool call automatically.
interrupt() and interrupt_before=["tools"] pause the graph for a person. That is human-in-the-loop. It isn't a remote allow or deny. There's no guardInterrupt and no guardApproval.
Graph hooks and HITL pauses can't stop tool.invoke inside ToolNode. A person can't fire on the calls that never reach them. See needsApproval and LangGraph interrupt() are not a security policy.
How do you gate authored tools vs ToolNode tools?
LangGraph splits tools into two execution paths. Tools you authored with tool() run through guardTool. Everything else—including MCP tools—runs inside ToolNode. Wrap that node in place so unguarded tools can't slip through.
guardTool wraps a LangChain tool() / StructuredTool. It wraps func and invoke. On deny the tool never runs. It returns a plain ArcjetDenialResult. It doesn't throw.
ToolNode wraps that object into a real ToolMessage whose status is success. The denial rides in the payload (arcjetDenied: true), not the envelope. A graph that only checks status sees a success and keeps going. Read arcjetDenied on the payload instead. Don't fabricate a ToolMessage to force status: "error". That crashes the graph reducer.
MCP and unwrapped tools execute inside ToolNode. Wrap the node in place with guardToolNode. A copy leaves the original unguarded. Already-branded guardTool tools are skipped so Guard isn't double-called. See How do I secure MCP tools in LangGraph?.
import { ToolNode } from "@langchain/langgraph/prebuilt";import { tokenBucket } from "@arcjet/guard";import { guardToolNode } from "@arcjet/guard/langgraph/v1";import { arcjet } from "./arcjet.js";
const mcpLimit = tokenBucket({ bucket: "mcp", refillRate: 20, intervalSeconds: 60, maxTokens: 20,});
const toolNode = new ToolNode(mcpTools);guardToolNode(arcjet, toolNode, { action: ({ toolName }) => `${toolName}.invoked`, rules: ({ toolName }) => [mcpLimit({ key: toolName, requested: 1 })],});import { tool } from "@langchain/core/tools";import { z } from "zod";import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";import { guardTool } from "@arcjet/guard/langgraph/v1";import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({ bucket: "lookups", refillRate: 10, intervalSeconds: 60, maxTokens: 10,});const detectPii = localDetectSensitiveInfo();
export const lookupOrder = guardTool( arcjet, tool( async ({ orderNumber, note }) => ({ orderNumber, note, status: "shipped", }), { name: "lookup_order", description: "Look up an order by number", schema: z.object({ orderNumber: z.string(), note: z.string(), }), }, ), { action: "order.looked-up", rules: (input) => [ lookupLimit({ key: input.orderNumber, requested: 1 }), detectPii(input.note), ], },);Scan the free-text note. An opaque orderNumber or tool_call_id doesn't trip email, phone, card, or IP detection.
How does the editor and CI catch LangGraph security bugs?
Turn on TypeScript strict, ESLint, and secret scanning in the editor. Trunk, Semgrep, TruffleHog, and Gitleaks all have editor plugins and CI jobs. Run the same scanners in CI that you run locally—an editor warning that isn't a CI failure will be ignored.
Fail the build on secrets in the diff and on known vulnerable dependencies. Review the graph reducer and every ToolNode construction—a second, unwrapped ToolNode is the usual miss. None of these replace a review of which tools you wrapped. They catch the mistakes that are cheap to find automatically.
What do you do after this guide?
Confirm each item against one sensitive graph: a prompt that asks for a pull request, an authored tool that you wrapped, and an MCP tool that only guardToolNode can see. Then apply AI agent runtime security for sequence, budget, and identity controls that aren't LangGraph-specific.
Frequently asked questions
What is the LangGraph security guide?
Use this eight-item guide on LangGraph JS 1.4.x StateGraph plus ToolNode. Pin @langchain/langgraph with core, keep keys out of checkpoints, screen text before invoke, don't treat interrupt() as a gate, wrap authored tools, wrap ToolNode in place for MCP, read arcjetDenied on the payload, and run the same scanners in CI that you run in the editor.
Does this cover Python LangGraph?
Python graphs that go through create_agent belong in the LangChain security guide. This adapter is @arcjet/guard/langgraph/v1 for the JS Graph API.
Why does a denied tool look like success?
ToolNode wraps the ArcjetDenialResult in a ToolMessage whose status is success. The denial rides in the payload. A graph that only checks status keeps going.
Can I copy ToolNode and wrap the copy?
No. A copy leaves the original unguarded. Wrap the node in place with guardToolNode.
AI runtime security in your code
Protect your AI agent workflows with Arcjet
Get allow, deny, and redact on agent actions before the side effect.