seekrit
← all posts

Securing AI agents on Cloudflare Workers

Agents

An agent built with the Agents SDK is a Durable Object. It reads credentials the way any Worker does: this.env.OPENAI_API_KEY, set with wrangler secret put for production and a .dev.vars file locally. Workers AI needs no key, which is why the starter templates run with none. The keys arrive when the agent starts calling anything else: a frontier model, Stripe, GitHub, Slack, your database.

At that point the shape is one wrangler secret put per key, per Worker, per environment, and every key sits in the same env object as every other. This post covers four ways to handle that with seekrit, from a one-time push into the Worker's bindings to a sandbox that never holds a key.

What is different about a Worker

Three things about the platform decide which shapes are available.

There is no process to wrap. seekrit run -- node agent.js is the usual zero-code integration, and it has nothing to attach to here. A Worker's environment is its bindings, populated by Cloudflare before your code runs. seekrit run -- wrangler dev does not help either: wrangler dev builds the Worker's env from .dev.vars and vars, not from the shell that launched it.

Agent state syncs to clients. The Agents SDK keeps this.state in the Durable Object's SQLite and pushes it to every connected client over WebSocket. A credential that ends up in state through setState is sent to the browser. Keep values in env or in a private field, never in state.

The read path is already on Cloudflare. seekrit's API is a Worker, and it caches resolve responses at the edge, keyed by principal. A Worker that resolves at startup is making a same-network call whose response is ciphertext plus a key wrapped to its own token. Secrets at the edge covers why that response is cacheable when a decrypting server's is not.

Shape 1: sync into the Worker's bindings

If nothing in the agent's code should change, push the environment into the Worker's secret bindings. seekrit stays the source of truth and each secret becomes the binding wrangler secret put would have created:

printf '%s' "$CLOUDFLARE_API_TOKEN" | seekrit sync connect \
  --name acme-workers --provider cloudflare-workers \
  --account-id 0123456789abcdef0123456789abcdef

seekrit sync enable --connection acme-workers \
  --provider cloudflare-workers --script my-agent \
  --app support-agent --env production --acknowledge-decryption

Secrets land on the Worker's current version immediately, with no redeploy. A Wrangler environment is its own Worker, so --env staging deploys my-agent-staging; name that script for the staging binding. For secrets shared by several Workers, the same command with --provider cloudflare-secrets-store writes to the account's Secrets Store, and each Worker binds the names it needs.

Sync is the one place seekrit decrypts on the server, for the duration of a push, and enabling it requires someone who can already read the environment to grant that and acknowledge it. Every key is still a binding in env, readable by all of the agent's code, including a tool that was talked into reading it. The Cloudflare sync guide has the token permissions and the batch semantics.

Shape 2: one secret, resolved in the agent

Give the Worker one secret, SEEKRIT_TOKEN, and resolve the rest with the JS SDK. The SDK is WebCrypto and fetch with no Node built-ins, so it runs in a Worker unchanged:

npx wrangler secret put SEEKRIT_TOKEN
// wrangler.jsonc: fail the deploy if the one secret is missing
{ "secrets": { "required": ["SEEKRIT_TOKEN"] } }
import { Agent } from "agents";
import { createOpenAI } from "@ai-sdk/openai";
import { generateText } from "ai";
import { Seekrit } from "@seekrit/sdk";

export class SupportAgent extends Agent<Env> {
  #secrets?: Promise<Record<string, string>>;

  secrets() {
    this.#secrets ??= new Seekrit({ token: this.env.SEEKRIT_TOKEN }).resolve();
    return this.#secrets;
  }

  async onRequest(request: Request) {
    const { OPENAI_API_KEY } = await this.secrets();
    const openai = createOpenAI({ apiKey: OPENAI_API_KEY });
    const { text } = await generateText({
      model: openai("gpt-5.6-terra"),
      prompt: await request.text(),
    });
    return Response.json({ text });
  }
}

The resolve is memoized on the Durable Object instance, so it happens once per instance lifetime rather than once per request, and the values live in a private field, not in this.state. Locally, .dev.vars holds the same one line, so development and production read secrets the same way. Adding a key to the agent means putting it in the seekrit environment; nothing on the Cloudflare side changes.

What this buys over shape 1: the Worker's configuration holds one revocable credential instead of a dozen, and rotation is a restart or an eviction away. What it does not change: once resolved, the values are plaintext in the isolate, and any code in the agent can read them.

Shape 3: the agent holds a placeholder

For the provider key, the agent does not need the value. It needs a request to reach api.openai.com with the key attached. @seekrit/sdk/fetch does the attaching:

import { seekritFetch } from "@seekrit/sdk/fetch";

const openai = createOpenAI({
  apiKey: "{{seekrit:OPENAI_API_KEY}}",
  fetch: seekritFetch({
    token: this.env.SEEKRIT_TOKEN,
    allow: { "api.openai.com": ["OPENAI_API_KEY"] },
  }),
});

The string {{seekrit:OPENAI_API_KEY}} is what the agent's code, logs, traces, and state can see. The real value exists inside one outbound request and nowhere else. The allowlist is default-deny, so the same placeholder pointed at another host is answered with a 403 and never sent.

This is the same substitution seekrit-proxy does, running in the agent's isolate rather than a separate process. That makes it a weaker boundary: code in the same isolate could read the value from memory. It is still the right rung for keys the agent's own code uses, because it keeps them out of the places credentials leak from in an agent, which are model context, tool results, and trace exporters. The in-process guide sets out the trade.

Tool keys are where the allowlist earns its place. A Stripe key that may only read charges:

seekritFetch({
  token: this.env.SEEKRIT_TOKEN,
  rules: [
    { host: "api.openai.com", methods: ["POST"], paths: ["/v1/**"], allow: ["OPENAI_API_KEY"] },
    { host: "api.stripe.com", methods: ["GET"], paths: ["/v1/charges", "/v1/charges/*"], allow: ["STRIPE_KEY"] },
  ],
});

A refund is a POST, so it is refused before it leaves the Worker, with a header naming the rule that decided.

Shape 4: the credential never enters the sandbox

When the agent runs code it did not write, the isolate is no longer the trust boundary, and neither shape 2 nor shape 3 is enough. Cloudflare is unusual here: both of its sandboxes have a hook that runs outside the container, so the credential can stay in the Worker with no proxy to run.

Cloudflare Sandbox has outbound handlers, registered per host:

import { Sandbox } from "@cloudflare/sandbox";
import { Seekrit } from "@seekrit/sdk";

export class AgentSandbox extends Sandbox {}

AgentSandbox.outboundByHost = {
  "api.openai.com": async (request: Request, env: Env) => {
    const secrets = await new Seekrit({ token: env.SEEKRIT_TOKEN }).resolve();
    const authed = new Request(request);
    authed.headers.set("authorization", `Bearer ${secrets.OPENAI_API_KEY}`);
    return fetch(authed);
  },
};

The container runs python agent.py with no key and no seekrit token. The handler attaches the key on the way past, and only for requests to that host. Cloudflare's own documentation says not to put live API keys into the sandbox, and this is the shape that follows that advice.

Cloudflare Computer routes every backend's egress through one Fetcher you supply. @seekrit/sdk/cloudflare-computer builds it, with the same rules as shape 3 and two extra checks: the operation is allowlisted before any placeholder is looked at, and the rules can come from a signed policy bundle published from the dashboard and verified against signers pinned in the Worker. A workspace exec gets OPENAI_API_KEY={{seekrit:OPENAI_API_KEY}}, and a printenv inside it prints that. The Computer guide has the wiring and the refusal codes.

Choosing

The agentUse
Reads keys from env today and you want to stop hand-running wrangler secret putShape 1
Is your code, and you want one revocable credential in the WorkerShape 2
Calls providers and tools whose keys should never appear in a log or a traceShape 3, with rules for anything a tool can spend
Runs model-generated or user-supplied codeShape 4

Shapes 2 through 4 stack. A Worker can hold SEEKRIT_TOKEN, use a placeholder for its own model calls, and run a sandbox whose outbound handler resolves the same environment.

Gotchas

  • Resolve per instance, not per request. /v1/resolve is metered. Memoize on the Durable Object as above, or rely on ttlSeconds in seekritFetch, which caches in memory for 60 seconds by default.
  • Named environments do not share secrets. wrangler secret put SEEKRIT_TOKEN --env staging is a separate secret on a separate Worker. Mint a separate seekrit token bound to the staging environment for it.
  • .dev.vars and .env are still files. With shape 2 the only line in them is the token. With shape 1 they hold every key locally, which is the file a coding agent reads.
  • A Durable Object can live a long time. Rotating a secret in seekrit does not reach a value memoized on an instance that is still alive. Re-resolve on a timer, or in onStart, if rotation needs to land faster than eviction.

Setup is three commands: put the keys in an environment, mint a token bound to it, set that token as the Worker's one secret. The values are encrypted before they leave your machine, and the API that serves them to the Worker cannot read them.