2. LangGraph Architecture Primer: Understanding the Machine Before You Secure It

2. LangGraph Architecture Primer: Understanding the Machine Before You Secure It

Part 2 of the LangGraph Agent Security series


In my last post, I made the case that LangGraph agents represent a genuinely different security surface than anything I’d worked with before as a researcher. Autonomous, multi-step, connected to real-world tools, capable of taking irreversible actions.

But I realized I’d skipped something important. I’d talked about LangGraph without fully explaining how it actually works under the hood. And if there’s one thing I’ve learned going down this security rabbit hole, it’s that you can’t protect something you don’t understand mechanically. Vulnerabilities don’t exist in the abstract — they live in specific components, at specific boundaries, during specific phases of execution.

So before we get into attack surfaces and threat categories, I want to spend some time on the architecture itself. This is the post I wish I’d had when I first picked up LangGraph. I’ll try to explain each component clearly and then, at the end of each section, flag the security implications that took me too long to notice.


The Core Abstraction: Graphs, Not Chains

The central design insight of LangGraph is that agent behavior is better represented as a directed graph than as a linear sequence of operations. A chain says: do step 1, then step 2, then step 3, done. A graph says: do step 1, then decide where to go next based on what you observed, and maybe come back to step 1 later if you need to.

This matters because interesting agent behavior is almost never linear. An agent that needs to research a topic, verify what it found, decide if it needs more information, and then write a report — that’s a loop, not a chain. A chain can’t naturally express “go back and try again.”

Every LangGraph application is built from three primitives, and I want to be precise about each because the security implications fall directly out of understanding them:

Nodes

A node is a function. It receives the current state of the graph, does some work, and returns an update to that state. The work could be anything: calling an LLM, invoking an external tool, validating a piece of data, making a routing decision.

Nodes are where computation happens. They’re also where the consequences of anything that’s gone wrong upstream become real — if state has been poisoned by an earlier injection, a node that acts on that state is where the damage materializes.

Edges

An edge is a connection between nodes. A normal edge is unconditional: when node A finishes, always go to node B. A conditional edge evaluates a function to decide where to go next — it looks at the current state and routes execution to different nodes based on what it finds.

Conditional edges are what give LangGraph agents their adaptive quality. They’re also, from a security perspective, decision points where a manipulated state or compromised LLM output can redirect the entire execution flow down a path nobody intended.

State

State is the shared data structure that flows through the entire graph. Every node reads from it and writes to it. It accumulates context as execution progresses — the original user request, intermediate results, retrieved documents, tool outputs, conversation history.

State is typed as a schema at graph construction time, typically using Python’s TypedDict. And state is, in my view, the single most important security surface in LangGraph. It carries everything the agent knows and everything it will act on. If you can poison the state early in an execution, you can influence every subsequent node.


How a Graph Actually Executes

Let me show you a minimal but complete example, because the code makes the concepts concrete in a way that prose doesn’t:

from langgraph.graph import StateGraph, END
from typing import TypedDict

# 1. Define what state looks like
class AgentState(TypedDict):
    messages: list
    tool_results: list
    next_action: str
    completed: bool

# 2. Define the nodes as functions
def call_llm(state: AgentState) -> AgentState:
    response = llm.invoke(state["messages"])
    return {
        "messages": state["messages"] + [response],
        "next_action": response.tool_calls[0].name if response.tool_calls else "done"
    }

def execute_tool(state: AgentState) -> AgentState:
    result = tool_registry[state["next_action"]].invoke(state)
    return {"tool_results": state["tool_results"] + [result]}

# 3. Build the graph
graph = StateGraph(AgentState)
graph.add_node("llm", call_llm)
graph.add_node("tool", execute_tool)

# 4. Wire up the edges
graph.set_entry_point("llm")
graph.add_conditional_edges(
    "llm",
    lambda state: "tool" if state["next_action"] != "done" else END
)
graph.add_edge("tool", "llm")  # This is the loop: tool → llm → tool → ...

# 5. Compile into a runnable
app = graph.compile()

This is the classic ReAct loop — Reason, Act, Observe, repeat. The LLM decides it needs a tool, the tool executes, the result flows back into the LLM’s context, and it decides again. The cycle continues until the LLM determines it’s done.

When I first wrote something like this, I felt very clever. Then I noticed: there’s no maximum iteration guard anywhere in this code. The loop is completely unbounded. If the LLM never decides it’s done — because it’s been manipulated, confused, or stuck — this runs forever, burning through API tokens and potentially taking real-world actions on every iteration.

That small oversight is actually a pretty good illustration of how LangGraph security works in general. The primitives are powerful and flexible, but they don’t protect you from yourself. The defaults are optimized for capability, not for safety.


Checkpointing: The Feature I Didn’t Fully Appreciate Until It Worried Me

One of LangGraph’s most impressive features is its checkpointing system. After each node executes, LangGraph can save a complete snapshot of the current state to a persistent store. This enables several things:

  • Resumability: a long-running agent that crashes or is interrupted can pick up exactly where it left off
  • Time travel: developers can inspect the full execution history and rewind to any prior checkpoint — genuinely useful for debugging
  • Human-in-the-loop: execution can pause at a defined interrupt point and wait indefinitely for human input before continuing

Here’s what using a checkpointer looks like in practice:

from langgraph.checkpoint.postgres import PostgresSaver

checkpointer = PostgresSaver.from_conn_string("postgresql://...")
app = graph.compile(checkpointer=checkpointer)

# The thread_id scopes all state to a specific session
config = {"configurable": {"thread_id": "user-session-abc123"}}
result = app.invoke({"messages": [user_message]}, config=config)

The thread_id here is doing a lot of work. It’s the key that scopes all state and execution history to a particular session. Think of it as the session identifier for the entire agent execution.

When I first grasped the full implications of this, I wrote in my notes: the checkpoint store is basically a complete surveillance record of everything the agent has ever done.

It contains every input the agent received, every tool result it processed, every LLM response it generated, and every intermediate state it passed through — across every session, for every user, going back as far as the retention policy allows. If you’re operating in a regulated domain with data residency requirements or GDPR obligations, this is something you need to think about very carefully.

But the more immediate security concern is simpler: if thread IDs are predictable, or reused across users, or insufficiently validated, an attacker who can guess a thread ID can access — or inject into — another user’s agent session. The checkpoint store is a high-value target precisely because it’s so comprehensive.


Human-in-the-Loop: The Most Powerful Safety Control You Can Misuse

LangGraph has first-class support for inserting human review gates into agent execution. The mechanism is clean: you specify which nodes to interrupt before at compile time, and execution pauses when it reaches them.

app = graph.compile(
    checkpointer=checkpointer,
    interrupt_before=["execute_payment"]  # Pause before this node runs
)

When the graph reaches execute_payment, it saves the current state and halts. The state is persisted. A human reviewer — who gets notified through whatever channel you’ve built — can inspect what the agent is about to do, approve it, modify it, or reject it. When they’ve decided, they resume execution by invoking the graph again with the same thread_id.

This is, genuinely, one of the most important security mechanisms in the entire framework. For any irreversible action — sending a message, deleting a record, executing a financial transaction — a human interrupt gate gives you a recovery point that no amount of input validation or output filtering can provide. Those controls catch known bad patterns. A human reviewer can catch things nobody anticipated.

But here’s the thing I had to learn: interrupt points are only protective if they’re placed correctly.

The most common mistake I’ve seen (and made) is placing an interrupt at the wrong stage. An interrupt that fires after the sensitive action has already executed is useless. If you interrupt after the email was sent to check whether it should have been sent, you’ve learned something but prevented nothing.

The placement has to be before the irreversible action, and the state presented to the human reviewer has to be complete enough for them to make a genuinely informed decision. A review interface that shows a truncated summary of what the agent is about to do, without the full context of how it got there, provides an illusion of oversight rather than actual oversight.


Tools: Where the Agent Gets Its Hands

Tools are what make agents actually useful — and what make them actually dangerous. Without tools, a LangGraph agent is just a sophisticated text generator in a loop. With tools, it can query databases, send emails, browse the web, execute code, call external APIs, and interact with any system you connect it to.

In LangGraph, tools are typically Python functions decorated with @tool from LangChain. The decorator generates a JSON schema from the function signature and docstring, which gets passed to the LLM so it knows what tools are available and how to call them:

from langchain_core.tools import tool

@tool
def send_email(to: str, subject: str, body: str) -> str:
    """Send an email to the specified recipient."""
    return email_client.send(to=to, subject=subject, body=body)

@tool
def query_database(sql: str) -> list:
    """Execute a SQL query and return results."""
    return db.execute(sql)

The LLM sees the function name, the docstring, and the parameter types. Based on that, it decides when to call the tool and what arguments to pass.

This is where I had one of my more uncomfortable realizations: the LLM constructs tool arguments from natural language context, not from a validated structured source. The arguments it passes to query_database are assembled from whatever is in the agent’s context window at that moment — which might include user messages, retrieved documents, API responses, or anything else the agent has processed.

If any of that context contains adversarial content, the LLM might pass that content directly into tool arguments. SQL in the query_database call. A malicious URL in an HTTP tool. An attacker-controlled email address in send_email. The LLM isn’t performing input validation — it’s performing instruction-following, and those are very different things.

LangGraph provides a ToolNode helper that makes tool execution cleaner:

from langgraph.prebuilt import ToolNode

tools = [send_email, query_database, search_web]
tool_node = ToolNode(tools)

The ToolNode is convenient. It also does no additional validation beyond whatever you’ve implemented inside the tool functions themselves. No sandboxing. No argument range validation. No policy enforcement about which tools can be called in which contexts. What you implement is what you get — which means you need to implement a lot.


Multi-Agent Architectures: Power and the Price You Pay For It

LangGraph supports multi-agent architectures where separate graphs — each with their own tools, state, and LLM — act as specialized sub-agents coordinated by a supervisor:

                    ┌─────────────────┐
      User Input ──►│   Supervisor    │
                    │     Agent       │
                    └────────┬────────┘
                             │  delegates tasks
              ┌──────────────┼──────────────┐
              ▼              ▼              ▼
       ┌────────────┐ ┌────────────┐ ┌────────────┐
       │  Research  │ │   Coder    │ │   Writer   │
       │   Agent    │ │   Agent    │ │   Agent    │
       └────────────┘ └────────────┘ └────────────┘

Each sub-agent is itself a compiled StateGraph. Sub-agents receive tasks from the supervisor, do their work, and return results that flow back into the supervisor’s state. The supervisor synthesizes those results and decides what to do next.

This pattern unlocks genuinely impressive capabilities — parallelism, specialization, the ability to have different agents with different permission sets handling different parts of a complex task.

It also introduces a threat category that doesn’t exist in single-agent designs: inter-agent trust.

Here’s the specific concern: the supervisor agent typically has broader permissions than the sub-agents. It might be able to send emails, modify records, and make decisions — while a research sub-agent can only browse the web and summarize content. The research sub-agent, by design, has limited blast radius.

But if the research sub-agent processes external web content and gets compromised by an indirect prompt injection — the webpage it visited contained a malicious instruction — then its output carries that compromised intent. That output flows directly into the supervisor’s state. The supervisor reads it and, trusting it as a message from a known sub-agent, may act on it with the supervisor’s broader permissions.

The attacker exploited the sub-agent’s low-trust external access to gain leverage over the supervisor’s high-trust actions. This is privilege escalation through the trust hierarchy, and it’s specific to multi-agent architectures. Understanding it early would have changed some of my design decisions considerably.


The Execution Lifecycle: A Map of Where Things Go Wrong

This is the part I find most useful to have written down explicitly. Here is the complete execution lifecycle of a LangGraph agent invocation, annotated with where security-relevant events occur. Every single one of these stages is both a place where you can put a defensive control and a place where that control’s absence creates a gap.

1. INPUT INGESTION
   User message enters graph state
   ► Where things go wrong: direct prompt injection,
     malformed or oversized input

2. LLM INFERENCE
   Model reads full state, decides next action
   ► Where things go wrong: indirect injection from state content,
     context window poisoning, jailbreak attempts

3. CONDITIONAL ROUTING
   Graph decides which node to visit next
   ► Where things go wrong: manipulated state causing
     unintended routing to privileged nodes

4. TOOL EXECUTION
   Tool invoked with LLM-constructed arguments
   ► Where things go wrong: SQL injection, SSRF, path traversal,
     command injection via LLM-generated arguments

5. STATE UPDATE
   Tool result written back into shared state
   ► Where things go wrong: malicious tool output persisted
     into state, influencing all future nodes

6. CHECKPOINTING
   State snapshot written to persistent store
   ► Where things go wrong: checkpoint store compromise,
     cross-session leakage, replay attacks using old checkpoints

7. LOOP / BRANCH
   Returns to LLM inference or advances
   ► Where things go wrong: infinite loops, runaway cost,
     denial of service via loop induction

8. INTERRUPT (if configured)
   Execution pauses for human review
   ► Where things go wrong: interrupt placed too late,
     bypassed through conditional logic, spoofed approval

9. OUTPUT DELIVERY
   Final state returned to caller
   ► Where things go wrong: sensitive data exfiltration,
     credential leakage, output encoding attacks

When I first mapped this out, I found it clarifying in an uncomfortable way. Nine distinct stages. Nine distinct attack surfaces. Each one requiring its own consideration, its own controls.

The encouraging version of this is that nine stages means nine places to put defenses. Defense in depth — the principle that multiple independent controls are stronger than one good control — applies naturally here. An injection that gets past the input validator might get caught by the output guardrail. A manipulated tool argument might get blocked by the tool’s own validation. The graph is long enough that there are multiple opportunities to stop something that started going wrong.

The discouraging version is that you actually have to build all of those defenses. They don’t come free with the framework.


What I’ve Taken Away From All This

A few weeks into thinking seriously about LangGraph architecture from a security perspective, here’s where I’ve landed:

The framework’s design philosophy is actually security-friendly. Explicit state. Explicit edges. Explicit interrupt points. Clear separation between the graph structure (which you control) and the LLM’s decisions (which you influence but can’t fully control). This is better than a lot of agentic frameworks where the execution model is more opaque.

But the defaults are not secure. Unbounded loops. No argument validation on tool calls. No sandboxing. No output scanning. ToolNode that trusts the LLM’s judgment entirely. These are reasonable defaults for getting something working quickly; they’re not reasonable defaults for production systems handling sensitive data.

The security work is mostly yours to do. LangGraph gives you the primitives to build securely. It does not build security for you. Every control I’ll describe in the rest of this series — input validation, tool hardening, state protection, output guardrails, monitoring — is something you have to deliberately implement. The framework won’t do it for you.

That’s not a criticism, exactly. Flexibility and explicit control are the things LangGraph was designed for, and they’re genuinely valuable. But I think a lot of teams — including, early on, mine — pick up LangGraph thinking about what they can build with it, and not enough about what securing that thing will require.

Understanding the architecture is the prerequisite. Now we can start talking about what actually goes wrong.


Coming Up Next

In the next post I’ll start mapping the attack surface systematically — every entry point, every data source, every channel through which an adversary can reach the LLM or the tools. It’s more comprehensive (and more concerning) than most people expect when they first think it through.

After that we’ll get into specific threat categories: prompt injection in its various forms, goal hijacking, data exfiltration, denial of service. The fun stuff.


This is Part 2 of an ongoing series on LangGraph agent security. Part 1: What Is LangGraph and Why Should You Care About Securing It?

Next: 3. Mapping the Attack Surface — every channel through which adversarial content can reach a LangGraph agent, thirteen attack surfaces mapped.