seekrit
← all posts

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 holdsThe function holdsA leak yields
Syncevery valueevery valuethe value
Resolve per requestone tokenevery value, in memorythe value
Placeholdersone token{{seekrit:NAME}} stringsa string that works only toward allowlisted hosts and operations
Sandbox with deny-allone token, outside the sandboxnothingnothing

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/openai calls /v1/responses. A rule pinned to /v1/chat/completions refuses every model call. Use /v1/** or pin /v1/responses on purpose.
  • Preview deployments get production's token unless you say otherwise. Scope SEEKRIT_TOKEN per Vercel environment, and mint the preview one against a seekrit branch environment, so a PR build cannot resolve production.