Secrets, environments and bindings
Read a wrangler config an agent generated and know what every block does, tell bindings from variables from secrets, keep preview and production values apart, and rotate a key you have leaked.
12 cards · 6 quiz questions · 10 min read
An agent scaffolds a Pages site for you and leaves a wrangler.jsonc in the root. It has a name, a date, and three blocks of hex strings. Somewhere in that file is the decision about which of your eight D1 databases this code can write to, and whether your API key is sitting in your git history. This article is about reading that file properly — and about the three different things that all arrive on the same env object and are constantly confused.
Three things that look alike
{
"name": "respondentvoice",
"main": "src/index.ts",
"compatibility_date": "2025-09-01",
"vars": { "API_HOST": "api.example.com", "PAGE_SIZE": 50 },
"d1_databases": [
{ "binding": "DB", "database_name": "respondentvoice-db",
"database_id": "b1f0c9e4-77aa-4d1e-9c22-0f3e6a2d55b7" }
],
"kv_namespaces": [{ "binding": "SESSIONS", "id": "9c1f...e2" }],
"r2_buckets": [{ "binding": "EXPORTS", "bucket_name": "exports" }]
}
At runtime, env.API_HOST, env.DB, env.SESSIONS and env.EXPORTS all sit side by side. They are not the same kind of thing.
- A variable is configuration text. It lives in
vars, it is committed to git, and it is readable in the dashboard. Hostnames, page sizes, feature flags. - A secret has the same shape at runtime but is stored encrypted and never appears in your config file. Cloudflare’s own framing: secrets are environment variables, the difference being that their values are not visible in Wrangler or the dashboard once set.
- A binding is not text at all. It is a live, already-authenticated handle to a resource.
That last one deserves a pause, because it is genuinely unlike the Azure side of your world.
Why there is no connection string
// No password, no host, no connection string. Nothing else to configure.
const rows = await env.DB.prepare(
"select id, submitted_at from responses where wave = ?1 limit 20"
).bind(waveId).all();
On Azure you would hold a connection string in App Service configuration, and that string is the credential — leak it and anyone can connect from anywhere. A binding works the other way round: the platform grants this deployment access to that resource, and hands your code a client that is already authorised. There is nothing to leak and nothing to rotate.
The consequence is worth internalising: reading a wrangler config is a security review. Whatever is bound is reachable by any code in that Worker, including code an agent adds next week. A binding to your production policynews database in a scratch project is a real risk even though no credential appears anywhere.
Setting a secret
# Production value for a Worker: prompts for the value, stores it encrypted
npx wrangler secret put OPENAI_API_KEY
# What exists (names only — values are not retrievable)
npx wrangler secret list
# Replace or remove
npx wrangler secret put OPENAI_API_KEY # overwrite
npx wrangler secret delete OPENAI_API_KEY
For a Pages project the equivalent is the dashboard: Settings, then Variables and Secrets, then Add, then press Encrypt before saving. Cloudflare’s Pages docs are blunt about the trade: you cannot see secrets after you set them, and you access them only programmatically on context.env. That opacity is the feature. If you can read it back, so can anything that gets into your account.
Locally, wrangler dev reads a .dev.vars file sitting next to your wrangler config, in dotenv form:
# .dev.vars — LOCAL ONLY, never committed
OPENAI_API_KEY="sk-proj-local-test-key"
SURVEY_WEBHOOK_SECRET="dev-not-the-real-one"
# .gitignore — add this BEFORE you put a real value in .dev.vars
.dev.vars*
.env*
Cloudflare’s docs say directly not to commit secrets to git and to add those patterns to .gitignore. An agent that creates .dev.vars will not reliably create the ignore rule. Check.
The environments foot-gun
Wrangler supports named environments, and inheritance is where people get hurt:
// WRONG — staging has no bindings at all
{
"name": "podsmith",
"d1_databases": [{ "binding": "DB", "database_name": "podsmith",
"database_id": "aa11...bb" }],
"env": { "staging": { "name": "podsmith-staging" } }
}
Most top-level keys are inherited by a named environment. Bindings are not. Not vars, not kv_namespaces, not d1_databases, not r2_buckets. So wrangler deploy --env staging ships a Worker where env.DB is undefined, and you find out from a production error rather than a build failure.
// RIGHT — each environment states its own bindings, pointing at its own database
{
"name": "podsmith",
"d1_databases": [{ "binding": "DB", "database_name": "podsmith",
"database_id": "aa11...bb" }],
"env": {
"staging": {
"name": "podsmith-staging",
"d1_databases": [{ "binding": "DB", "database_name": "podsmith-staging",
"database_id": "cc22...dd" }]
}
}
}
Note what the wrong version would have done if the agent had copied the binding block: staging would run migrations against your live podsmith database. The ids are opaque hex, so nothing in the file warns you. Verify them:
npx wrangler d1 list # names next to uuids — match them by eye
npx wrangler kv namespace list
On Pages, environments are not named in the config at all: a project has one production set of variables and bindings and one preview set, and every non-production branch build uses the preview set. Used well, a pull-request preview reads a scratch D1 copy with a test API key. Left empty or copied from production, an experimental branch has live credentials.
Secrets and the client bundle
This is the failure mode that costs money. Anything your build tool puts into JavaScript sent to a browser is public — visible in devtools, and visible in the built file sitting on your CDN.
// BAD — the VITE_ prefix is the instruction to inline this into the bundle
const res = await fetch("https://api.example-llm.com/v1/messages", {
method: "POST",
headers: { Authorization: `Bearer ${import.meta.env.VITE_LLM_KEY}` },
body: JSON.stringify({ input: prompt }),
});
// GOOD — the browser calls your own endpoint; the key never leaves the server
const res = await fetch("/api/summarise", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prompt }),
});
// functions/api/summarise.ts — the key lives here, as an encrypted Pages secret
export const onRequestPost: PagesFunction<{ LLM_API_KEY: string }> =
async ({ request, env }) => {
const { prompt } = await request.json<{ prompt: string }>();
const upstream = await fetch("https://api.example-llm.com/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${env.LLM_API_KEY}`, // server-side only
"content-type": "application/json",
},
body: JSON.stringify({ input: prompt }),
});
return new Response(upstream.body, { status: upstream.status });
};
Astro uses PUBLIC_ and Vite uses VITE_ for the same purpose: a prefix that means “this is safe for the browser”. The prefix is a label, not a guard. An agent can still hardcode a key, or import a server module into a client component and drag the value across with it. The only reliable check is to search the built output.
# After a build, grep dist/ for anything that looks like a key
grep -rEo 'sk-[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{30,}' dist/ | head
Note also that Workers variables are capped at 5 KB each, with 64 per Worker on the free plan and 128 on paid. A service-account JSON blob or a PEM private key may not fit — which is usually a sign the edge is the wrong place for that credential at all.
Rotating a leaked key
Order matters, and the intuitive order is wrong.
- Revoke or rotate at the provider. This is the only step that makes the old value harmless. Public pushes are scraped within minutes.
- Set the new value as a secret (
wrangler secret put, or Encrypt in the Pages dashboard) and redeploy. - Read the provider’s usage logs for the window between leak and revocation. Unexpected spend or unfamiliar regions mean it was used.
- Then clean up: remove the line, rewrite history if you must, add the
.gitignoreentry.
Making the repo private and force-pushing feel like fixes. They are not. Forks, clones, CI logs and caches all still hold the value, so treat the key as compromised regardless.
What to check in your own code
- Open every
wrangler.toml/wrangler.jsoncand readname,mainandcompatibility_date. A wrongnamedeploys over a different Worker; acompatibility_datefrom years ago pins you to old runtime defaults. - For every binding block, run
wrangler d1 list,wrangler kv namespace listandwrangler r2 bucket listand match each id to the resource you intended. Do not trust thebindinglabel. - Grep your configs for credential-shaped strings:
grep -rniE 'key|token|secret|password|sk-' wrangler.* .env* 2>/dev/null. - Confirm
.gitignorecontains.dev.vars*and.env*, and rungit log --all --full-history -- .dev.varsto see whether one was ever committed. - Check that any
[env.*]block repeats its own bindings, and that each points at a non-production resource. - In the Pages dashboard, look at the preview column of Variables and Secrets on each project. Empty or identical to production is a decision you should be making on purpose.
- Grep your built
dist/output for key-shaped strings after a production build.
An agent adds this to your wrangler config: a `vars` block containing `OPENAI_API_KEY = "sk-proj-..."`. What is wrong with it?
Sources
- Cloudflare — Wrangler configuration website Top-level keys (name, main, compatibility_date), the vars section, named environments, and the exact shape of d1_databases, kv_namespaces and r2_buckets blocks.
- Cloudflare — Secrets on Workers website wrangler secret put/delete, .dev.vars for local development, and the statement that secrets are environment variables whose values are not readable after being set.
- Cloudflare — Cloudflare Pages Functions: bindings website Preview versus production bindings and variables, encrypting a value in the dashboard, and access via context.env.
- Cloudflare — Workers platform limits website Environment variable count per Worker and the 5 KB size cap per variable.