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 agent | Use |
|---|---|
Reads keys from env today and you want to stop hand-running wrangler secret put | Shape 1 |
| Is your code, and you want one revocable credential in the Worker | Shape 2 |
| Calls providers and tools whose keys should never appear in a log or a trace | Shape 3, with rules for anything a tool can spend |
| Runs model-generated or user-supplied code | Shape 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/resolveis metered. Memoize on the Durable Object as above, or rely onttlSecondsinseekritFetch, which caches in memory for 60 seconds by default. - Named environments do not share secrets.
wrangler secret put SEEKRIT_TOKEN --env stagingis a separate secret on a separate Worker. Mint a separate seekrit token bound to the staging environment for it. .dev.varsand.envare 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.