Managing Secrets in Cloudflare Workers with Wrangler
This guide is part of Environment Variables and Secrets at the Edge. It shows how to get a credential into a Cloudflare Worker so that it reaches the isolate at runtime, never reaches your Git history, and can be rotated without a window in which requests fail.
A Worker has no filesystem, no process.env in the Node sense, and no ambient machine identity to borrow from. Every value it needs from the outside world arrives as a binding on the env object handed to fetch. Wrangler is the tool that decides which values get baked into your deployment as readable configuration and which get uploaded into a write-only store. Getting that split right is most of the work; the rest is typing the Env interface and rehearsing rotation before you need it.
The problem
The failure modes are dull and expensive. Someone pastes an API key into wrangler.toml under [vars] because it is the fastest way to make the deploy work, and six weeks later a contractor clones the repository and now holds a production credential. Or the key is uploaded correctly with wrangler secret put, but nobody wrote it down anywhere else, and when the on-call engineer needs to rotate it they discover the value cannot be read back out of Cloudflare. Or staging and production share one secret name, someone runs wrangler secret put without --env, and the staging value silently overwrites production.
There is also a quieter one: wrangler dev works, the deployed Worker throws TypeError: Cannot read properties of undefined, and the difference turns out to be a .dev.vars file that exists on the developer’s laptop and nowhere else.
Root cause: secrets are write-only bindings resolved per environment
Cloudflare stores Worker secrets encrypted, attached to a specific Worker in a specific environment, and exposes them to exactly one consumer: the running isolate. The control plane deliberately offers no read path. wrangler secret list returns names and nothing else. This is a security property, not an oversight — a leaked API token can enumerate which secrets exist but cannot exfiltrate their values — and it drives everything downstream. Because you cannot read a secret back, Cloudflare cannot be your system of record; your password manager or secrets vault has to be, and every rotation is a write to two places.
The second half of the constraint is that bindings are scoped to an environment. A Worker deployed with --env production and one deployed with --env staging are, from the platform’s perspective, different Workers with different binding sets. A secret uploaded without --env lands on the top-level Worker, which may or may not be the thing serving your production traffic depending on how your wrangler.toml is arranged. Environment omission is the single most common cause of “the secret is set but the Worker says it is undefined.”
Plain [vars] entries behave differently again: they are part of the deployment payload, stored in your repository, visible in the Cloudflare dashboard, and readable by anyone with access to either. They are configuration, not credentials — a public API base URL, a feature-flag default, a region label.
Step 1 — Split configuration from credentials in wrangler.toml
Decide the split before writing any code. If the value would be harmless printed in a log line or a client-side bundle, it is configuration and belongs in [vars]. If its disclosure would let someone act as you, it is a secret and must never appear in the file.
# wrangler.toml
name = "edge-gateway"
main = "src/index.ts"
compatibility_date = "2026-06-01"
# Plaintext configuration. Committed, visible in the dashboard, never sensitive.
[vars]
API_BASE = "https://api.example.com"
LOG_LEVEL = "info"
SESSION_COOKIE = "__Host-session"
[env.staging]
name = "edge-gateway-staging"
[env.staging.vars]
API_BASE = "https://api.staging.example.com"
LOG_LEVEL = "debug"
[env.production]
name = "edge-gateway"
[env.production.vars]
API_BASE = "https://api.example.com"
LOG_LEVEL = "warn"
# Secrets are deliberately absent. They are uploaded out of band:
# wrangler secret put UPSTREAM_TOKEN --env production
Note that [env.staging.vars] does not inherit from the top-level [vars]. Each environment’s var block replaces it wholesale, so every key a given environment needs must be listed under that environment. Half-populated environment blocks are a common source of undefined at runtime.
Step 2 — Upload secrets per environment
wrangler secret put reads the value from an interactive prompt or from stdin, encrypts it, and attaches it to one Worker environment. The --env flag is not optional in any project that has more than one environment.
# Interactive: Wrangler prompts, the value never touches your shell history.
wrangler secret put UPSTREAM_TOKEN --env production
# Non-interactive: pipe it in. Note the leading space to keep it out of history
# in shells configured with HISTCONTROL=ignorespace.
printf '%s' "$NEW_TOKEN" | wrangler secret put UPSTREAM_TOKEN --env staging
# Names only — values are never returned.
wrangler secret list --env production
# Remove a retired secret once nothing reads it.
wrangler secret delete UPSTREAM_TOKEN_OLD --env production
Uploading a secret takes effect on its own; it does not require a redeploy of your code, and it applies to the currently deployed version of that environment within seconds. That property is what makes the rotation procedure in Step 5 workable.
Step 3 — Type the Env interface so a missing binding fails at build time
The env object is any unless you describe it. Declaring the shape turns a class of production undefined errors into TypeScript errors, and it documents for the next reader exactly which bindings the Worker expects.
// src/env.ts
export interface Env {
// Plaintext vars from wrangler.toml.
API_BASE: string;
LOG_LEVEL: "debug" | "info" | "warn";
SESSION_COOKIE: string;
// Secrets uploaded with `wrangler secret put`. Same type, different origin.
UPSTREAM_TOKEN: string;
JWT_SIGNING_KEY: string;
/** Present only during a rotation overlap window. */
JWT_SIGNING_KEY_NEXT?: string;
// Bindings to other resources.
SESSIONS: KVNamespace;
}
export type RequiredSecret = "UPSTREAM_TOKEN" | "JWT_SIGNING_KEY";
/**
* Fail loudly at isolate boot rather than at the first request that needs the
* value. A Worker that cannot possibly work should say so once, not per request.
*/
export function assertSecrets(env: Env, names: readonly RequiredSecret[]): void {
const missing = names.filter((n) => typeof env[n] !== "string" || env[n].length === 0);
if (missing.length > 0) {
// Names only — never interpolate a value into an error message.
throw new Error(`Missing required secret bindings: ${missing.join(", ")}`);
}
}
Step 4 — Read bindings from env, never from module scope
This is the rule that trips up developers arriving from Node. In a Worker, env is passed into fetch on every invocation. It is not available while the module body is evaluating, so a top-level const token = env.UPSTREAM_TOKEN cannot work, and a top-level process.env.UPSTREAM_TOKEN is either undefined or, worse, inlined at build time by a bundler and frozen into the artifact.
// src/index.ts
import { assertSecrets, type Env } from "./env";
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
assertSecrets(env, ["UPSTREAM_TOKEN", "JWT_SIGNING_KEY"]);
const url = new URL(request.url);
const upstream = new URL(url.pathname + url.search, env.API_BASE);
const headers = new Headers(request.headers);
// Strip anything a client might have tried to spoof before we set our own.
headers.delete("authorization");
headers.set("authorization", `Bearer ${env.UPSTREAM_TOKEN}`);
const res = await fetch(new Request(upstream, { ...request, headers }));
if (env.LOG_LEVEL === "debug") {
// Log the shape of the credential, never the credential.
console.log(JSON.stringify({
event: "upstream.call",
status: res.status,
tokenLength: env.UPSTREAM_TOKEN.length,
path: url.pathname,
}));
}
ctx.waitUntil(Promise.resolve());
return res;
},
};
Passing env down explicitly, rather than stashing it in a module-level variable on first request, keeps the Worker safe when one isolate serves several environments during a gradual deployment and makes every function trivially testable with a fake object.
Step 5 — Rotate with an overlap window
Because a secret cannot be read back and takes effect immediately on upload, a naive rotation is a hard cutover: for the moment between the new value landing and the upstream accepting it, requests fail. The fix is to accept two values at once. Provision the replacement credential upstream while the old one still works, upload it under a second binding name, let the Worker try both, then retire the old one.
// src/signing.ts
import type { Env } from "./env";
/**
* During rotation the Worker signs with the current key but verifies against
* both, so tokens minted seconds before the swap remain valid.
*/
export function signingKeys(env: Env): { sign: string; verify: string[] } {
const primary = env.JWT_SIGNING_KEY;
const next = env.JWT_SIGNING_KEY_NEXT;
return next
? { sign: next, verify: [next, primary] }
: { sign: primary, verify: [primary] };
}
The operational sequence is four commands spread over however long your longest token lives:
# 1. Upload the replacement alongside the incumbent. Nothing changes yet.
wrangler secret put JWT_SIGNING_KEY_NEXT --env production
# 2. Deploy the code from signingKeys() above: sign with NEXT, verify with both.
wrangler deploy --env production
# 3. Wait out the longest-lived artifact signed by the old key, then promote.
wrangler secret put JWT_SIGNING_KEY --env production # paste the NEXT value
# 4. Remove the overlap binding and redeploy the single-key path.
wrangler secret delete JWT_SIGNING_KEY_NEXT --env production
Step 6 — Local development with .dev.vars
wrangler dev cannot pull production secrets down — that read path does not exist — so local runs need their own values. Wrangler reads them from a .dev.vars file next to wrangler.toml, in dotenv format, and injects each line as a binding on env exactly as the platform would.
# .dev.vars — MUST be in .gitignore
UPSTREAM_TOKEN=dev-token-not-real
JWT_SIGNING_KEY=local-development-signing-key
LOG_LEVEL=debug
Commit a .dev.vars.example with the key names and obviously fake values so a new contributor knows what to populate, and add .dev.vars and .dev.vars.* to .gitignore in the same commit that creates the example. Use throwaway sandbox credentials locally; the moment a real production token lands in .dev.vars you have reintroduced the problem the secret store exists to solve, just on a laptop instead of in Git.
Step 7 — Deploy from CI without exposing values
CI needs one credential — a Cloudflare API token scoped to Workers Scripts: Edit — supplied as CLOUDFLARE_API_TOKEN in the runner’s environment. Wrangler picks it up automatically. Application secrets should be uploaded by a human or by a vault integration, not stored in your CI provider, but where you must set them from CI, pipe them in rather than passing them as arguments.
#!/usr/bin/env bash
set -euo pipefail
# CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID come from the CI secret store.
# Wrangler reads them from the environment; never echo them.
# Fail early if the deploy credential is absent rather than half-deploying.
: "${CLOUDFLARE_API_TOKEN:?CLOUDFLARE_API_TOKEN is not set}"
npx wrangler deploy --env production
# Confirm the expected bindings exist. Prints names only.
npx wrangler secret list --env production
Scope the token to the narrowest permission set that works and give each pipeline its own, so revoking one does not break the others. Never grant a deploy token account-wide read access; it does not need it, and a leaked broad token is a much larger incident than a leaked narrow one.
Local versus production divergence
| Behaviour | wrangler dev |
Deployed Worker |
|---|---|---|
| Secret source | .dev.vars on disk |
Encrypted store, per environment |
| Missing binding | Silently undefined unless asserted |
Silently undefined unless asserted |
--env selection |
Defaults to top-level unless passed | Whichever environment was deployed |
| Reading a value back | Trivial, it is a local file | Impossible by design |
| Value change | Restart wrangler dev |
Effective within seconds, no redeploy |
[vars] inheritance |
Same rule: no inheritance into [env.X.vars] |
Same rule |
| Credential blast radius | One laptop | One environment of one Worker |
The two rows that cause real incidents are the third and the fourth. Forgetting --env puts a value on a Worker nobody is serving from, and the absence of a read path means you cannot diagnose that by comparing values — only by comparing wrangler secret list --env <name> output between environments.
Validating with Vitest
Because the Worker takes env as a parameter, tests supply a plain object. No emulator, no network, no real credential.
// src/worker.test.ts
import { describe, expect, it, vi, beforeEach } from "vitest";
import worker from "./index";
import { assertSecrets, signingKeys, type Env } from "./env";
function fakeEnv(overrides: Partial<Env> = {}): Env {
return {
API_BASE: "https://api.test.local",
LOG_LEVEL: "warn",
SESSION_COOKIE: "__Host-session",
UPSTREAM_TOKEN: "test-upstream-token",
JWT_SIGNING_KEY: "key-v1",
SESSIONS: {} as KVNamespace,
...overrides,
};
}
const ctx = { waitUntil: () => {}, passThroughOnException: () => {} } as unknown as ExecutionContext;
describe("secret bindings", () => {
beforeEach(() => vi.restoreAllMocks());
it("throws with the binding name when a required secret is absent", () => {
const env = fakeEnv({ UPSTREAM_TOKEN: "" });
expect(() => assertSecrets(env, ["UPSTREAM_TOKEN"])).toThrow(/UPSTREAM_TOKEN/);
});
it("never puts the secret value into the error message", () => {
const env = fakeEnv({ JWT_SIGNING_KEY: "" });
try {
assertSecrets(env, ["UPSTREAM_TOKEN", "JWT_SIGNING_KEY"]);
} catch (err) {
expect((err as Error).message).not.toContain("test-upstream-token");
}
});
it("attaches the upstream token as a bearer credential", async () => {
const seen: Request[] = [];
vi.stubGlobal("fetch", async (req: Request) => {
seen.push(req);
return new Response("ok", { status: 200 });
});
await worker.fetch(new Request("https://edge.test/v1/orders"), fakeEnv(), ctx);
expect(seen[0].headers.get("authorization")).toBe("Bearer test-upstream-token");
expect(new URL(seen[0].url).host).toBe("api.test.local");
});
it("strips a client-supplied authorization header before signing", async () => {
const seen: Request[] = [];
vi.stubGlobal("fetch", async (req: Request) => {
seen.push(req);
return new Response("ok");
});
const spoofed = new Request("https://edge.test/v1/orders", {
headers: { authorization: "Bearer attacker-supplied" },
});
await worker.fetch(spoofed, fakeEnv(), ctx);
expect(seen[0].headers.get("authorization")).toBe("Bearer test-upstream-token");
});
it("verifies against both keys during a rotation overlap", () => {
const rotating = signingKeys(fakeEnv({ JWT_SIGNING_KEY_NEXT: "key-v2" }));
expect(rotating).toEqual({ sign: "key-v2", verify: ["key-v2", "key-v1"] });
const settled = signingKeys(fakeEnv());
expect(settled).toEqual({ sign: "key-v1", verify: ["key-v1"] });
});
});
The fifth test is the one worth writing before you need it. Rotation is the procedure people rehearse least and execute under the most pressure; a unit test that pins the dual-verify behaviour means the code path is known-good before the incident that requires it.
Common pitfalls and resolutions
env.SECRET_NAME is undefined in production but fine locally — the secret exists in .dev.vars and was never uploaded, or was uploaded without --env. Run wrangler secret list --env production and compare names against your Env interface.
Staging overwrote production — wrangler secret put without --env writes to the top-level Worker. Make --env mandatory in your team’s runbook and in any script that wraps Wrangler.
A secret is in Git history — rotating the credential is the fix; rewriting history is optional cleanup that does not undo the exposure. Treat any value that reached a remote as compromised.
[vars] value is missing in one environment only — environment var blocks replace, not merge. List every key in every [env.X.vars] block.
Rotation caused a burst of 401s — a hard swap with no overlap. Add the _NEXT binding and dual verification described in Step 5.
CI deploy fails with an authentication error — the API token is missing, expired, or lacks Workers Scripts: Edit. Check that CLOUDFLARE_API_TOKEN is exposed to the job, not just defined in the repository settings.
A credential appeared in a log line — something interpolated env into a structured log. Log derived facts such as length or a truncated hash, never the value, and never spread env into a log payload.
Production deployment checklist
Frequently Asked Questions
Can I read a Cloudflare Worker secret back after uploading it?
No. The platform stores secrets write-only and offers no read path; wrangler secret list returns names only. That is a deliberate security property, and it means Cloudflare cannot be your system of record. Keep the authoritative copy in a password manager or vault so rotation is always possible.
What is the difference between vars and secrets in wrangler.toml?
Vars are plaintext configuration committed to your repository, shipped with the deployment payload and visible in the dashboard. Secrets are uploaded separately, stored encrypted and never displayed. Both arrive on the same env object at runtime, so the distinction is enforced only at upload time.
Do I need to redeploy after changing a secret?
No. Uploading a secret takes effect against the currently deployed version of that environment within seconds, without a code deploy. That is what makes an overlap-based rotation practical, because you can promote the new value without shipping code at the same moment.
Why is my secret undefined even though wrangler secret put succeeded?
Almost always because the upload omitted the env flag and landed on the top-level Worker rather than the environment serving traffic. Run wrangler secret list with the env flag for the environment in question and compare the names against your Env interface.
How do I supply secrets to wrangler dev locally?
Create a dot dev dot vars file beside wrangler.toml in dotenv format; Wrangler injects each line as a binding exactly as the platform would. Add it to gitignore, commit an example file documenting the key names, and use throwaway sandbox credentials rather than production values.
How should CI authenticate to deploy a Worker?
Expose a Cloudflare API token as CLOUDFLARE_API_TOKEN in the job environment, scoped to Workers Scripts Edit and unique per pipeline. Wrangler reads it automatically. Application credentials should be uploaded by a human or a vault integration rather than stored in your CI provider.