How-tos

How do I secure a LangGraph JS agent?

Secure a LangGraph JS agent on the Graph API (StateGraph plus ToolNode). Screen inbound text with a direct guard() before graph.invoke. Wrap authored tools with guardTool. Wrap ToolNode in place with guardToolNode. interrupt() is a human hold, not a policy.

6 min read
In short: Secure a LangGraph JS agent on the Graph API (StateGraph plus ToolNode). Screen inbound text with a direct guard() before graph.invoke. Wrap authored tools with guardTool. Wrap ToolNode in place with guardToolNode. interrupt() is a human hold, not a policy.

How do I secure a LangGraph JS agent?

Screen inbound text with a direct guard() call before graph.invoke, or in the first graph node. Wrap authored tool() / StructuredTool with guardTool. Wrap the ToolNode in place with guardToolNode so unwrapped and MCP tools hit Guard. interrupt() is a human hold, not a policy.

This adapter is the LangGraph Graph API: StateGraph plus ToolNode. It isn't deprecated createReactAgent. It isn't Python LangChain create_agent or wrapToolCall. The host table that also covers AutoGPT and CrewAI is Which runtime security tools integrate with LangChain or AutoGPT?.

protect() is the HTTP check on a route. The product map is the LangGraph agent guard.

What do I install and import?

Install @arcjet/guard alongside LangGraph, then import the helpers from the versioned path:

Terminal window
npm install @arcjet/guard @langchain/langgraph @langchain/core

Import from @arcjet/guard/langgraph/v1. There is no unversioned alias, so @arcjet/guard/langgraph doesn't resolve. The version segment tracks LangGraph's major, and @langchain/langgraph (>=1 <2) plus @langchain/core (>=1 <2) are optional type-only peers. The integration needs Node.js 22 or later. Launch one client at module scope with launchArcjet.

The integration exposes three surfaces: guardTool() for authored tools, guardToolNode() for the node that runs everything else, and langgraphAgentContext() for correlation. An integration skill ships in the package, so cp -r node_modules/@arcjet/guard/skills/integrate-arcjet-guard-langgraph ~/.claude/skills/ hands a coding agent the same recipe.

How do I screen inbound text?

LangGraph has no first-class channel, so there is no guardInbound. Put prompt-injection and other inbound rules in the application before graph.invoke, or in the graph's first node. On DENY, don't call graph.invoke.

A ticket comment that says "open a pull request that pastes the private issue" becomes the next graph input. Screen that string before the first model call. A later interrupt() doesn't unread it.

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()) {
// Convert this to a generic caller response. Don't explain the detector hit.
throw new Error("message blocked");
}
await graph.invoke({ messages: [{ role: "user", content: userText }] }, config);

Direct guard() fails open. An ALLOW isn't proof the rules ran. Gate on decision.hasFailedOpen() when this call site must fail closed. guardTool and guardToolNode default to onGuardError: "deny". "allow" is a legitimate choice on the inbound guard() before invoke, because failing closed there stops the graph running during an outage.

Why isn't interrupt() a security policy?

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 is no guardInterrupt and no guardApproval. Don't wrap them as Guard.

The same trap is Eve user-approval, Mastra requireApproval, Claude canUseTool, and OpenAI Agents needsApproval. For more information about that trap, see Human approval is not a security policy. For more information about the LangGraph half, see needsApproval and LangGraph interrupt() are not a security policy.

A person can't fire on the tool calls that never reach them. Graph hooks and HITL pauses can't stop tool.invoke inside ToolNode. The deny for those tools is guardToolNode.

How do I gate authored tools vs ToolNode tools?

guardTool wraps a LangChain tool() / StructuredTool. It wraps func and invoke. On DENY the tool never runs. It returns a plain ArcjetDenialResult (arcjetDenied: true, plus reason / message / retryable). 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.

That envelope detail matters when you write the branch that reads the tool result. A graph that only checks status sees a success and keeps going. Read arcjetDenied on the payload instead.

Scan the free-text arguments: a note, a reason, or a body. An opaque orderNumber or tool_call_id won't trip email, phone, card, or IP detection, so don't hand it to localDetectSensitiveInfo. That helper runs on a local ML model backend.

import { tool } from "@langchain/core/tools";
import { z } from "zod";
import { guardTool } from "@arcjet/guard/langgraph/v1";
import { localDetectSensitiveInfo, tokenBucket } from "@arcjet/guard";
import { arcjet } from "./arcjet.js";
const lookupLimit = tokenBucket({
bucket: "lookups",
refillRate: 10,
intervalSeconds: 60,
maxTokens: 10,
});
// Factory then text, the same shape as `detectPromptInjection()(text)`.
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),
],
},
);

The action, rules, and metadata options accept factories over the parsed input, and the helper try/catches each one. A factory that throws counts as a guard error, so the fail-closed default stops the tool rather than running it with no decision.

Unwrapped and MCP tools execute inside ToolNode. Use guardToolNode for those. Wrap the node in place; a copy leaves the original executing unguarded tools. Already-branded guardTool tools are skipped so Guard isn't double-called. For more information about that recipe, see How do I secure MCP tools in LangGraph?.

A graph that only wraps lookup_order still posts a Linear comment if that tool arrived through MCP. interrupt() doesn't sit inside tool.invoke. Graph hooks don't sit there either. The last reversible point for a tool that you didn't author is guardToolNode on the node that you pass to StateGraph.addNode("tools", ...).

Don't also wrap these tools with @arcjet/guard/vercel-ai/v7, and never call createAgentContext inside a LangGraph callback. langgraphAgentContext() reads configurable.thread_id, then the run id, then configurable.checkpoint_ns. It never mints an id. It never calls createAgentContext. Pass the checkpointer thread_id that you already have on graph.invoke(input, { configurable: { thread_id } }). If none is a valid 1-256 printable-ASCII string, the call is uncorrelated rather than joined to a generated id. The correlation id reconstructs one run; it isn't an identity, and it isn't a policy key.

Is this the same as LangChain Python?

LangGraph JS isn't LangChain Python. LangChain Python uses guard_action on a callable, guard_tool on a BaseTool, and ArcjetMiddleware plus ToolPolicy on create_agent. For more information about that adapter, see How do I secure a LangChain Python agent?.

LangGraph JS is StateGraph plus ToolNode. Python create_agent / wrapToolCall is a different runtime. Don't import @arcjet/guard/langgraph without the /v1 segment. For more information about the stack map, see agent framework security.

Frequently asked questions

How do I secure a LangGraph JS agent?

Screen inbound text with a direct guard() call before graph.invoke, or in the first graph node. Wrap authored tool() / StructuredTool with guardTool. Wrap ToolNode in place with guardToolNode for unwrapped and MCP tools. interrupt() is HITL, not policy.

Is this createReactAgent or LangChain Python?

No. This adapter is StateGraph plus ToolNode. It isn't deprecated createReactAgent, and it isn't Python create_agent or wrapToolCall. The LangChain Python how-to is a different page.

Why must guardToolNode wrap ToolNode in place?

ToolNode's constructor captures func as an arrow bound to the instance, and run reads this.tools. A copy with a fresh tools array leaves the original executing unguarded tools.

Is there a guardInbound for LangGraph?

No. LangGraph has no first-class channel. Screen inbound with a direct guard() call before graph.invoke or in the first node. Direct guard() fails open; wrappers default to deny.

Where do I import the LangGraph helpers?

Run npm install @arcjet/guard @langchain/langgraph @langchain/core and import from @arcjet/guard/langgraph/v1. There is no unversioned @arcjet/guard/langgraph alias. Don't also wrap these tools with @arcjet/guard/vercel-ai/v7.

What happens if a rules callback throws?

The action, rules, and metadata factories are try/caught. A factory that throws counts as a guard error, so the fail-closed default stops the tool instead of running it with no decision.

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.