ToolNode execution, authorize checkpoint access, and test policy enforcement after retries and human-approval resumes.How do you secure LangGraph?
To secure LangGraph for JavaScript, screen text before graph execution, wrap authored tools, and guard ToolNode for unwrapped tools. Bind checkpoint access to the authenticated caller and verify policy on resume. Graph observations and human interrupts do not replace authorization immediately before a tool reads or changes data.
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.
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 following examples 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.
Use this checklist to review your application:
- 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 do you manage LangGraph secrets?
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";
// Resolve authenticatedUser from the server session before constructing tools.const lookupLimit = tokenBucket({ bucket: "lookups", refillRate: 10, intervalSeconds: 60, maxTokens: 10,});const detectPii = localDetectSensitiveInfo({ deny: ["EMAIL", "PHONE_NUMBER", "IP_ADDRESS", "CREDIT_CARD_NUMBER"],});
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: authenticatedUser.id, 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 do 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.
How do you verify enforcement before deployment?
Use deterministic Guard responses and a handler call counter. A DENY or unavailable evaluation must leave the counter at zero; an authorized ALLOW must call the handler once. Also test another tenant's resource identifier: application authorization must still refuse access even when Guard allows.
Invoke a graph with a fake model that emits the tool call, then repeat through the checkpoint resume path. Check tools contributed to ToolNode, the wrapped tool directly, and retry behavior. Bind the checkpoint thread to its authenticated owner and keep non-idempotent work from replaying on resume.
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?
To secure LangGraph for JavaScript, screen text before graph execution, wrap authored tools, and guard ToolNode for unwrapped tools. Bind checkpoint access to the authenticated caller and verify policy on resume. Graph observations and human interrupts do not replace authorization immediately before a tool reads or changes data.
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
Screen inbound text before the graph runs, and wrap ToolNode in place so MCP and unwrapped tools cannot execute past a deny.