Secure the chat
Your /api/chat route spends model tokens on your key. Lock it to your page with chat sessions, a tool allowlist, a bot check and limits, so it can't be used as a free model from anywhere else.
The API key never leaves your server: the page sends messages to /api/chat, and the route calls the model. But the
route is a public URL. Anyone can call it with curl, set any Origin header, send any messages, and get answers paid
for with your key. Out of the box it only refuses requests from other sites’ pages (CSRF).
This section is how you close that: what to turn on, what each piece stops, and what no piece can stop.
What is exposed
| Who can… | Without these pages | With them |
|---|---|---|
| Read your API key | Nobody: it stays on the server | Nobody |
Call /api/chat from another site’s page |
Nobody: refused (403) |
Nobody |
Call /api/chat from a script (curl) |
Anyone | Only with a session cookie, which needs a solved Turnstile challenge |
| Chat without an agent’s instructions | Anyone | Nobody: a request must name an agent |
| Describe their own “tools” to the model | Anyone: tools come from the request | Nobody: only allowed tools reach the model |
| Spend without limit | Bounded only by your provider | Bounded per IP, per session, per day, and by a spend limit |
The layers
Each one is a few lines in your route; together they make the endpoint useless as a free model.
- Chat sessions. The page’s first request gets a random, signed, httpOnly cookie. A POST without it is refused before its body is read, and every request belongs to a session you can count.
- A tool allowlist. The server decides which tools the model sees and, for your own tools, what it is told about them. A request must name one of your agents.
- Turnstile. Starting a session needs a solved Cloudflare Turnstile challenge, invisible for nearly everyone. A script can’t mint sessions in a loop any more.
- Limits and budgets. Per IP, per session and per day in your code; a monthly spend limit at your provider as the last guard.
A complete example
The route with sessions, the allowlist and an agent required. Turnstile turns on as soon as its secret is set.
import { createChatHandler } from "@webmcp-agents/chat/server";
import { agents } from "../src/agents";
import { allowedTools } from "../src/allowed-tools";
// /api/chat, locked down. Reads the key,
// WEB_AGENTS_SESSION_SECRET and TURNSTILE_SECRET_KEY from
// process.env, on the server.
const handler = createChatHandler({
agents,
// A request must name one of them.
requireAgent: true,
// Only these tools reach the model.
tools: allowedTools,
// The page's first GET starts a session (a signed, httpOnly
// cookie); a POST without one gets 401. With Turnstile set up,
// a session needs a solved challenge.
session: {
turnstile: Boolean(process.env.TURNSTILE_SECRET_KEY),
},
});
export const GET = handler;
export const POST = handler;import { createChatHandler } from "@webmcp-agents/chat/server";
import { agents } from "@/agents";
import { allowedTools } from "@/allowed-tools";
// /api/chat, locked down. Reads the key,
// WEB_AGENTS_SESSION_SECRET and TURNSTILE_SECRET_KEY from
// process.env, on the server.
const handler = createChatHandler({
agents,
// A request must name one of them.
requireAgent: true,
// Only these tools reach the model.
tools: allowedTools,
// The page's first GET starts a session (a signed, httpOnly
// cookie); a POST without one gets 401. With Turnstile set up,
// a session needs a solved challenge.
session: {
turnstile: Boolean(process.env.TURNSTILE_SECRET_KEY),
},
});
export const GET = handler;
export const POST = handler;import { createFileRoute } from "@tanstack/react-router";
import { createChatHandler } from "@webmcp-agents/chat/server";
import { agents } from "@/agents";
import { allowedTools } from "@/allowed-tools";
// /api/chat, locked down. Reads the key,
// WEB_AGENTS_SESSION_SECRET and TURNSTILE_SECRET_KEY from
// process.env, on the server.
const handler = createChatHandler({
agents,
// A request must name one of them.
requireAgent: true,
// Only these tools reach the model.
tools: allowedTools,
// The page's first GET starts a session (a signed, httpOnly
// cookie); a POST without one gets 401. With Turnstile set up,
// a session needs a solved challenge.
session: {
turnstile: Boolean(process.env.TURNSTILE_SECRET_KEY),
},
});
export const Route = createFileRoute("/api/chat")({
server: {
handlers: {
GET: ({ request }) => handler(request),
POST: ({ request }) => handler(request),
},
},
});import type { APIRoute } from "astro";
import { getSecret } from "astro:env/server";
import { createChatHandler } from "@webmcp-agents/chat/server";
import { agents } from "@/agents";
import { allowedTools } from "@/allowed-tools";
export const prerender = false;
const handler = createChatHandler({
agents,
// A request must name one of them.
requireAgent: true,
// Only these tools reach the model.
tools: allowedTools,
// The page's first GET starts a session (a signed, httpOnly
// cookie); a POST without one gets 401. With Turnstile set up,
// a session needs a solved challenge.
session: {
turnstile: Boolean(getSecret("TURNSTILE_SECRET_KEY")),
},
// getSecret reads .env.local in development and the server's
// environment in production.
env: {
ANTHROPIC_API_KEY: getSecret("ANTHROPIC_API_KEY"),
WEB_AGENTS_MODEL: getSecret("WEB_AGENTS_MODEL"),
WEB_AGENTS_SESSION_SECRET: getSecret("WEB_AGENTS_SESSION_SECRET"),
TURNSTILE_SECRET_KEY: getSecret("TURNSTILE_SECRET_KEY"),
},
});
export const GET: APIRoute = ({ request }) => handler(request);
export const POST: APIRoute = ({ request }) => handler(request);The allowed tools are one list the route imports: your catalog (pinned) and the ids of the tools your components define inline.
import { tools } from "./tools";
// What /api/chat lets the model see. The catalog's tools are
// pinned: the model gets these descriptions, whatever a request
// says. Tools defined inline in a component go in by id. Any other
// tool a request lists never reaches the model.
export const allowedTools = [
...Object.values(tools),
"add_to_cart",
"remove_from_cart",
"clear_cart",
"place_order",
"subscribe_newsletter",
];
The page needs no change for sessions: the chat’s first request starts one. For Turnstile it needs the widget’s site key: see Turnstile.
What no setting can do
Nothing can prove a request came from your page. The page runs on the visitor’s machine, so whatever it can do, a script can do too: load the page, keep the cookie, solve the challenge in a real browser. What these layers do is make that expensive (a real browser per session), bounded (limits per session, per IP, per day) and pointless (no instructions of its own, no tools of its own, a model told to stay on your site). The spend limit at your provider bounds the worst case.
Requiring sign-in is the other lever, and it is a business decision: a signed-in user is someone you can rate-limit and ban by account. None of these pages need it.
Before you go live
-
WEB_AGENTS_SESSION_SECRETset on the server, the same value on every instance (openssl rand -base64 32). -
toolslists every tool the agents need, and nothing else. -
requireAgent: truewhen the page has agents. - Turnstile: the secret on the server, the site key on the page (both, or neither).
- Per-IP and per-session limits, and a daily budget, in a shared store (Redis), not in memory.
- A monthly spend limit on the key’s workspace at your provider, with an alert below it.
- The smoke test in Deploy: a POST without the cookie gets
401.