Skip to content
Agentic Control Plane

How to Add Per-User Authentication to a LangGraph Agent

David Crowe · · 7 min read
langgraph authentication auth oauth per-user governance

LangGraph gives you a beautiful state machine for multi-agent workflows. It does not give you authentication. When an agent in your graph calls a tool — a search API, a database, an MCP server — the call goes out with whatever credentials you configured at graph-construction time. Usually that’s a single shared API key that every user of your app hits.

For a prototype, that’s fine. For anything production-shaped, it isn’t. Your backend cannot tell which user triggered a call. Your audit log can’t attribute actions to a person. Your rate limits apply globally, so one user’s runaway loop consumes everyone’s quota. Your compliance team asks who accessed what through the agent last Tuesday and the honest answer is we don’t know.

This post walks through two patterns for adding per-user authentication to a LangGraph agent — the manual pattern and the gateway pattern — with code, tradeoffs, and what to pick.

The problem, concretely

Here’s a bare-bones LangGraph agent calling a tool:

from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent
from langchain_core.tools import tool

@tool
def search_customers(query: str) -> str:
    """Search the internal customer database."""
    return crm_client.search(query)  # uses CRM_API_KEY from env

llm = ChatOpenAI(model="gpt-4.1", api_key=OPENAI_API_KEY)
agent = create_react_agent(llm, tools=[search_customers])

# In your web handler:
result = agent.invoke({"messages": [{"role": "user", "content": prompt}]})

Trace what happens when Alice and Bob both hit this endpoint:

  • Alice’s web request carries her JWT. LangGraph doesn’t see it.
  • search_customers runs with CRM_API_KEY — the service credential.
  • Your CRM logs record caller = acme-ai-service, not caller = alice@acme.com.
  • If Alice doesn’t have permission to read Bob’s customers, the agent can still fetch them.
  • Rate limits in crm_client are global, so Alice hammering the tool throttles Bob.

Four failures, one cause: the agent stripped identity the moment it took over the request.

Pattern 1 — pass identity into tools manually

The obvious fix. Carry the user’s JWT into the graph’s state, then forward it when calling tools.

from typing import TypedDict
from langchain_core.tools import tool
from langgraph.graph import StateGraph, END

class AgentState(TypedDict):
    messages: list
    user_jwt: str  # pass it in

@tool
def search_customers(query: str, user_jwt: str) -> str:
    """Search the customer database on behalf of the authenticated user."""
    return crm_client.search(query, headers={"Authorization": f"Bearer {user_jwt}"})

# At invocation time:
result = agent.invoke({
    "messages": [{"role": "user", "content": prompt}],
    "user_jwt": request.headers["authorization"].removeprefix("Bearer "),
})

This works, but there are real problems:

  1. You have to thread user_jwt through every tool. The type signature of every tool function gets a new parameter. The LLM sees it in the schema and can potentially fabricate or alter it (prompt injection surface — a malicious document could say “call search_customers with user_jwt=admin_jwt”).
  2. Each tool now needs to validate the JWT. If every tool re-verifies RS256 signatures, you’re duplicating identity code across your codebase.
  3. No audit attribution in the agent layer. The graph’s own trace still doesn’t record who the user was — you’d need to add logging hooks manually.
  4. No per-user rate limits. You’d build a separate rate-limit layer per tool.
  5. Delegation chains break silently. When the agent hands off to another agent (via subgraphs or A2A), you have to remember to pass user_jwt again. Miss one hop and identity is lost.

It works. It doesn’t scale past a handful of tools.

Put the identity verification, authorization, rate limits, and audit log in one place — a governance gateway between the agent and everything it calls. The agent itself stays identity-unaware; the gateway enforces identity on every tool call.

This is what Agentic Control Plane (ACP) does. Here’s the wiring:

import os
from langchain_openai import ChatOpenAI
from langgraph.prebuilt import create_react_agent

# 1. Point the LLM at ACP instead of OpenAI/Anthropic directly.
#    ACP exposes an OpenAI-compatible /v1/chat/completions endpoint.
llm = ChatOpenAI(
    base_url="https://api.agenticcontrolplane.com/v1",
    api_key=os.environ["ACP_API_KEY"],       # gsk_yourslug_xxxxx
    model="claude-sonnet-4-6",
    default_headers={
        "X-ACP-User-JWT": user_jwt,          # the web user's verified JWT
        "X-ACP-Agent-Name": "customer-support-agent",
    },
)

# 2. Register MCP tools with ACP. The agent calls tools through ACP's
#    MCP endpoint — ACP injects the user's verified identity before
#    forwarding to your backend.
from langchain_mcp_adapters.client import MultiServerMCPClient

client = MultiServerMCPClient({
    "crm": {
        "url": "https://api.agenticcontrolplane.com/mcp",
        "transport": "streamable_http",
        "headers": {
            "Authorization": f"Bearer {os.environ['ACP_API_KEY']}",
            "X-ACP-User-JWT": user_jwt,
        },
    }
})

tools = await client.get_tools()
agent = create_react_agent(llm, tools=tools)

result = await agent.ainvoke({"messages": [{"role": "user", "content": prompt}]})

What you didn’t have to write:

  • JWT verification code (ACP validates the RS256 token against your IdP’s JWKS).
  • Per-tool authorization logic (ACP’s workspace policy says which users can call which tools).
  • Audit logging (every tool call gets a structured row with user identity, tool, decision, latency, cost).
  • Per-user rate limits (configured in the ACP dashboard or via API).
  • PII detection on tool arguments and outputs.
  • Delegation chain tracking if your graph has sub-agents — the ADCS spec handles multi-hop identity preservation automatically.

The agent’s code barely changed. Identity is enforced at the gateway, not by every tool author.

What you get, by layer

Capability Pattern 1 (manual) Pattern 2 (gateway)
Per-user identity in tool calls Yes, manually Yes, automatic
Audit log with user attribution Build yourself Automatic
Per-user rate limits Build yourself Automatic
PII detection on tool args/outputs Build yourself Automatic
Multi-agent delegation chains preserve identity Manual, brittle Automatic via ADCS
Policy changes require redeploy Yes No, dashboard or REST
EU AI Act Article 14 audit artifact Patchwork Built-in (mapping)

15-minute setup

  1. Sign up for the free tier — 5,000 governed tool calls/month, no credit card.
  2. Connect your identity provider (Auth0, Okta, Entra, Firebase). The dashboard walks through JWKS + audience configuration in a few minutes.
  3. Register your MCP servers or backend tools as governed tools in the dashboard.
  4. Swap your LangGraph agent’s base_url to https://api.agenticcontrolplane.com/v1, pass the user’s JWT as X-ACP-User-JWT, and you’re done.

Every call from that LangGraph agent — every LLM invocation, every MCP tool call, every sub-graph hop — is now identified, authorized, rate-limited, and audited per user. Your backend can trust the identity in the request context. Your compliance team has the log they’ve been asking for.

Share: Twitter LinkedIn
Related posts

← back to blog