Securing AI agents on Vercel
Agents
An agent on Vercel is usually a Next.js route handler: a POST that calls
generateText or streamText with tools, deployed as a Vercel Function. Its
credentials come from the project's environment variables, which Vercel injects
at build and run time from its own copy. Every provider key and every tool key
is a row in Settings → Environment Variables, per environment, and all of
them are in process.env for every request the function handles.
An earlier post covered syncing environment variables into that copy from one source of truth. This one is about agents, where the question is less how the keys get there and more what the agent holds while it runs, and where that goes.
What Vercel gives you, and what it does not
AI Gateway removes the model key. Set no OPENAI_API_KEY at all: on a
Vercel deployment, the AI SDK authenticates to AI Gateway with the deployment's
OIDC token, and a plain "openai/gpt-5.6-terra" model string routes through
it. Locally, or off Vercel, it wants AI_GATEWAY_API_KEY, which is a
credential like any other and belongs in seekrit with the rest.
Everything else is still a key. Tools are where an agent gets dangerous, and the Stripe, GitHub, Slack, and database credentials those tools use are ordinary environment variables that AI Gateway does nothing about.
There is no process to wrap. seekrit run -- next build works on a
builder you control, and Vercel's builders are not that. Locally,
seekrit run -- next dev is still the zero-code path.
Option 1: sync every key into the project
If the agent reads process.env and you do not want to touch it, push the
seekrit environment into the Vercel project:
seekrit sync enable --connection acme --app support-agent --env production \
--project prj_abc --target production --acknowledge-decryption
seekrit stays the source of truth, Vercel holds a copy of every value, and the function sees them the way it does today. This is the one case where seekrit decrypts on the server, for the duration of a push, and enabling it requires a grant from someone who can already read the environment. The sync post covers targets, preview branches, and what Vercel can see.
Option 2: one variable in Vercel, resolve per request
Set exactly one environment variable on the project, SEEKRIT_TOKEN, and
resolve the rest inside the handler:
// app/api/agent/route.ts
import { createOpenAI } from "@ai-sdk/openai";
import { generateText, tool } from "ai";
import { Seekrit } from "@seekrit/sdk";
import { z } from "zod";
export async function POST(req: Request) {
const secrets = await new Seekrit().resolve(); // token from SEEKRIT_TOKEN
const openai = createOpenAI({ apiKey: secrets.OPENAI_API_KEY });
const { text } = await generateText({
model: openai("gpt-5.6-terra"),
prompt: await req.text(),
tools: {
lookupCharge: tool({
description: "Look up a Stripe charge",
inputSchema: z.object({ id: z.string() }),
execute: ({ id }) => stripeGet(`/v1/charges/${id}`, secrets.STRIPE_KEY),
}),
},
});
return Response.json({ text });
}
Resolve once at the top of the handler. On Fluid compute a function instance serves many requests, so the SDK's in-memory cache means most of them do not pay the round trip, and the seekrit API caches resolve responses at the edge for the ones that do. Vercel's dashboard now shows one variable per environment, and revoking that one token cuts the agent off from everything.
In a React Server Component rather than a route handler, @seekrit/sdk/react
does the same with one resolve per render and, with experimental.taint on,
makes passing a value to a client component a render-time error. The
React guide covers it.
The limit of this option: secrets.STRIPE_KEY is a plaintext string in the
function, closed over by a tool, and anything the model persuades the function
to do with it is done.
Option 3: the function holds placeholders
Replace the values with placeholders and let the HTTP layer substitute them:
import { seekritFetch } from "@seekrit/sdk/fetch";
const fetch = seekritFetch({
rules: [
{ host: "api.openai.com", methods: ["POST"], paths: ["/v1/**"], allow: ["OPENAI_API_KEY"] },
{ host: "api.stripe.com", methods: ["GET"], paths: ["/v1/charges/*"], allow: ["STRIPE_KEY"] },
],
});
const openai = createOpenAI({ apiKey: "{{seekrit:OPENAI_API_KEY}}", fetch });
const lookupCharge = tool({
description: "Look up a Stripe charge",
inputSchema: z.object({ id: z.string() }),
execute: async ({ id }) => {
const res = await fetch(`https://api.stripe.com/v1/charges/${id}`, {
headers: { authorization: "Bearer {{seekrit:STRIPE_KEY}}" },
});
return res.json();
},
});
The function's source, its environment, its logs, and its traces contain
{{seekrit:STRIPE_KEY}}. The value exists inside one outbound request. The
rules bound where each placeholder may travel and what may be done with it:
the Stripe key here can read a charge and cannot issue a refund, because a
refund is a POST and the rule allows GET. A tool call the model was talked
into that points the placeholder anywhere else gets a 403 that names the
secret and never leaves the function.
This runs in the function's process, so it is a weaker boundary than a separate proxy. Code in the same process can read the value from memory. It is the right shape for tool keys in a function you wrote, because it removes them from every place an agent leaks from. In-process injection has the full trade-off.
The browser: a placeholder and a route
Some agent UIs stream from the browser to a provider directly, which puts a key in the client bundle. The alternative is a same-origin route that substitutes on the way out:
// app/api/openai/[...path]/route.ts
import { seekritRoute } from "@seekrit/sdk/route";
import { auth } from "@/auth";
export const { GET, POST } = seekritRoute({
upstream: "https://api.openai.com",
allow: { "api.openai.com": ["OPENAI_API_KEY"] },
authorize: async (request) => (await auth(request)) !== null,
});
// components/chat.tsx, a client component
const openai = createOpenAI({ baseURL: "/api/openai/v1", apiKey: "{{seekrit:OPENAI_API_KEY}}" });
The bundle ships a placeholder. authorize is required, because this route is
on the public internet under your cookies and without a check it is an open
credential proxy. The handler also checks method and path on every request,
drops the session cookie before forwarding, and strips set-cookie from the
response. Details in the
React guide.
Agent-generated code: Vercel Sandbox with no egress
An agent that writes and runs code needs a sandbox, and Vercel Sandbox has the
feature that makes "the sandbox never holds a key" enforceable rather than
advisory: networkPolicy: 'deny-all'. Create the microVM with no egress, hand
it placeholders and one base URL, and run seekrit-proxy
somewhere the sandbox can reach and nowhere else:
const sandbox = await Sandbox.create({
networkPolicy: "deny-all",
env: {
OPENAI_API_KEY: "{{seekrit:OPENAI_API_KEY}}",
OPENAI_BASE_URL: "https://proxy.internal.example/openai",
},
});
With ordinary egress, a base URL pointing at a proxy is a suggestion the
generated code can ignore by calling api.openai.com itself. With none, the
proxy is the only way out, and the proxy's allowlist decides which hosts,
methods, and paths the placeholder may reach. The microVM holds no seekrit
token, so a compromise yields a string and a URL. deny-all also blocks
package installs, so bake dependencies into the image. The
Vercel Sandbox guide has both this
shape and the simpler inject-at-create one for code you trust.
Which one
| Vercel holds | The function holds | A leak yields | |
|---|---|---|---|
| Sync | every value | every value | the value |
| Resolve per request | one token | every value, in memory | the value |
| Placeholders | one token | {{seekrit:NAME}} strings | a string that works only toward allowlisted hosts and operations |
Sandbox with deny-all | one token, outside the sandbox | nothing | nothing |
Start with the second row: one environment variable on the project and a
resolve() at the top of the handler. Move the tool keys to the third row
when a tool can spend money or change state. Use the fourth when the agent
runs code you did not write.
Two things to check
@ai-sdk/openaicalls/v1/responses. A rule pinned to/v1/chat/completionsrefuses every model call. Use/v1/**or pin/v1/responseson purpose.- Preview deployments get production's token unless you say otherwise.
Scope
SEEKRIT_TOKENper Vercel environment, and mint the preview one against a seekrit branch environment, so a PR build cannot resolve production.