This is a follow-up to Deploying a LangGraph Agent to Heroku. Once your agent is deployed, you’ll want to protect it from unauthorized access. This guide shows how to add API key authentication without needing a LangGraph Cloud license.
LangGraph Cloud offers built-in authentication, but it requires a paid license. For small deployments or prototypes, you might want a simpler solution that:
Validates API keys before forwarding requests to the agent
Works with the existing LangGraph SDK
Supports streaming responses (Server-Sent Events)
Keeps the API key on your backend, never exposing it to browsers
We’ll create a lightweight Python reverse proxy using Starlette that:
Sits in front of the LangGraph server
Validates incoming API keys
Forwards authenticated requests to LangGraph
Properly handles streaming responses
The architecture looks like this:
Browser → Next.js Backend → Auth Proxy → LangGraph Server
↑
Validates API key
The proxy runs on the same Heroku dyno as LangGraph, so there’s no additional infrastructure. All communication between the proxy and LangGraph happens over (the Docker container’s) localhost.
Create agent/auth_proxy.py:
"""
Simple authentication proxy for LangGraph API.
Validates API key from Authorization header before forwarding requests.
"""
import os
import httpx
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response, StreamingResponse
from starlette.routing import Route
API_KEY = os.environ.get("LANGGRAPH_API_KEY", "")
LANGGRAPH_URL = os.environ.get("LANGGRAPH_URL", "http://localhost:8000")
# Persistent client for connection pooling
client = httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0))
async def proxy(request: Request) -> Response:
if not API_KEY:
return Response("API_KEY not configured", status_code=500)
# Accept both X-Api-Key header (LangGraph SDK) and Authorization: Bearer
x_api_key = request.headers.get("X-Api-Key", "")
auth_header = request.headers.get("Authorization", "")
expected_bearer = f"Bearer {API_KEY}"
if x_api_key != API_KEY and auth_header != expected_bearer:
return Response("Unauthorized", status_code=401)
path = request.url.path
query = request.url.query
url = f"{LANGGRAPH_URL}{path}"
if query:
url = f"{url}?{query}"
body = await request.body()
headers = dict(request.headers)
headers.pop("host", None)
headers.pop("authorization", None)
headers.pop("content-length", None)
# Check if this is a streaming endpoint
is_stream = "/stream" in path
if is_stream:
req = client.build_request(
method=request.method,
url=url,
headers=headers,
content=body,
)
response = await client.send(req, stream=True)
async def stream_response():
try:
async for chunk in response.aiter_bytes():
yield chunk
finally:
await response.aclose()
return StreamingResponse(
stream_response(),
status_code=response.status_code,
headers=dict(response.headers),
)
else:
response = await client.request(
method=request.method,
url=url,
headers=headers,
content=body,
)
return Response(
content=response.content,
status_code=response.status_code,
headers=dict(response.headers),
)
app = Starlette(
routes=[
Route("/{path:path}", proxy, methods=["GET", "POST", "PUT", "DELETE", "PATCH"]),
Route("/", proxy, methods=["GET", "POST", "PUT", "DELETE", "PATCH"]),
]
)
Key features of this proxy:
Dual header support: Accepts both
X-Api-Key(what LangGraph SDK sends) andAuthorization: Bearer(standard pattern). This ensures compatibility with different clients.Streaming support: Endpoints containing
/streamin the path are handled withStreamingResponsefor Server-Sent Events. This is critical for the chat interface - without it, you’d only see responses after the full generation completes.Connection pooling: The
httpx.AsyncClientis created at module level, not per-request. This reuses TCP connections and significantly reduces latency.Header stripping: We remove
host,authorization, andcontent-lengthbefore forwarding. The host header would be wrong (it’s the proxy’s host, not LangGraph’s), authorization is our key (not needed internally), and content-length gets recalculated by httpx.
The LANGGRAPH_API_KEY is our own creation – it’s the shared secret between our frontend and the agent.
Replace the simple entrypoint from the deployment guide with this version that starts both the LangGraph server and the auth proxy. The original entrypoint just mapped environment variables and called LangGraph directly. This version runs LangGraph in the background and puts the auth proxy in front of it.
Modify agent/heroku-entrypoint.sh:
#!/bin/sh
# Map Heroku env var names to LangGraph expected names
export DATABASE_URI="$DATABASE_URL"
# Only add ssl_cert_reqs for SSL Redis connections (Heroku uses rediss://)
case "$REDIS_URL" in
rediss://*)
export REDIS_URI="${REDIS_URL}?ssl_cert_reqs=none"
;;
*)
export REDIS_URI="$REDIS_URL"
;;
esac
# Save the original PORT for the auth proxy
AUTH_PROXY_PORT="$PORT"
# Start LangGraph on internal port 8000
export PORT=8000
/storage/entrypoint.sh &
LANGGRAPH_PID=$!
# Wait for LangGraph to be ready
echo "Waiting for LangGraph to start..."
sleep 5
# Start auth proxy on the original Heroku port
echo "Starting auth proxy on port $AUTH_PROXY_PORT..."
exec uvicorn auth_proxy:app --host 0.0.0.0 --port "$AUTH_PROXY_PORT"
This script:
(See part one of this tutorial for details on points 1 and 2):
Maps Heroku’s environment variables to LangGraph’s expected names
Conditionally adds SSL cert skipping only for
rediss://URLs (production), notredis://(local)Starts LangGraph on internal port 8000 in the background
Waits 5 seconds for LangGraph to initialize
Starts the auth proxy on Heroku’s assigned
$PORTusingexec(so signals are properly forwarded)
The 5-second sleep is a simple approach. For more robust deployments, you could implement a health check loop that polls http://localhost:8000/info until it responds.
Update agent/pyproject.toml to include the required packages:
[project]
name = "your-agent"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"httpx>=0.27.0",
"langchain>=1.2.0",
"langchain-openai>=0.3.35",
"starlette>=0.38.0",
"uvicorn>=0.30.0",
# ... your other dependencies
]
[project.optional-dependencies]
dev = [
"langgraph-cli[inmem]>=0.4.3",
]
[tool.setuptools]
py-modules = ["agent", "auth_proxy"]
The key additions are:
httpx: Async HTTP client for proxying requests with connection pooling
starlette: Lightweight ASGI framework for the proxy
uvicorn: ASGI server to run the proxy
auth_proxy in
py-modules: Makes the proxy module importable by uvicorn
Note: langgraph-cli is only needed for local development as it comes with the base image from LangGraph anyway.
Heroku config vars:
heroku config:set LANGGRAPH_API_KEY=your-secret-api-key -a your-app-name
The LANGGRAPH_API_KEY is what any client will need to send along as an api-key.
You may generate a strong hash like so:
openssl rand -hex 32
Test that authentication is working:
# Should return 401 Unauthorized
curl https://your-app-name.herokuapp.com/
# Should return 200 OK
curl -H "x-api-key: your-secret-api-key" https://your-app-name.herokuapp.com/ok
After implementing authentication, your project structure should look like this:
your-project/
├── heroku.yml
├── frontend/
│ ├── (optional - if you have a monorepo)
└── agent/
├── agent.py
├── auth_proxy.py # New: authentication proxy
├── langgraph.json
├── pyproject.toml # Updated: added httpx, starlette, uvicorn
├── Dockerfile
└── heroku-entrypoint.sh # Updated: starts both processes
The auth proxy can’t reach the LangGraph server. Check:
LangGraph is running on port 8000: run
heroku logs --tail -a your-appand look for “Starting Postgres runtime”The 5-second sleep might not be enough - increase if needed
Check that
LANGGRAPH_URLdefaults to
http://localhost:8000
(not https)
The LANGGRAPH_API_KEY environment variable isn’t set. Set via the Heroku GUI or run:
heroku config:set LANGGRAPH_API_KEY=your-secret-key -a your-app-name
Make sure your auth proxy handles streaming correctly. The /stream path detection is case-sensitive. Also check:
The httpx client timeout is long enough (300 seconds for long generations)
You’re using
StreamingResponsenotResponsefor stream endpoints
Check that:
The API key matches exactly (no extra whitespace or newlines)
The header name is correct (
X-Api-KeyorAuthorization: Bearer)Environment variables are set in both frontend and backend
The frontend isn’t caching an old API key (restart the Next.js dev server)
The entrypoint conditionally adds ssl_cert_reqs=none only for rediss:// URLs. Local Redis uses redis:// so it won’t have SSL issues. If you’re seeing SSL errors locally, check that your REDIS_URLstarts with redis:// not rediss://.
Make sure auth_proxy is in py-modules in your pyproject.toml:
[tool.setuptools]
py-modules = ["agent", "auth_proxy"]
Heroku expects one process per dyno that binds to $PORT. We work around this by:
Starting LangGraph in the background on port 8000
Running the auth proxy in the foreground on
$PORT
When Heroku sends a SIGTERM to shut down the dyno, the auth proxy (running with exec) receives it directly. The backgrounded LangGraph process will be terminated when the container stops.
This is a simple API key authentication suitable for:
Internal tools
Prototypes
Small deployments with trusted clients
For production applications with end users, consider:
Rate limiting (add middleware to the Starlette app)
Request logging
Key rotation mechanisms
OAuth/JWT for user-level authentication
IP allowlisting if you know your frontend’s IP range
Adding authentication to a self-hosted LangGraph deployment requires:
An auth proxy that validates API keys before forwarding to LangGraph
Streaming support for Server-Sent Events endpoints
Dual header support for compatibility with LangGraph SDK
Consistent environment variables across frontend and backend
The proxy adds minimal latency (it runs on the same dyno) and keeps your LangGraph agent secure without requiring a LangGraph Cloud license.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.