Skip to content

Storing an API Key as a Cloudflare Worker Secret

9 min read · updated August 11, 2026

A provider key in source control is a key you must assume is compromised, because git history does not forget and a repository is copied to every laptop that clones it. Wrangler has a first-class place to put one, and it takes a single command.

Setting the secret

The command is npx wrangler secret put <KEY>. It prompts for the value, uploads it, and — this part matters for deploy pipelines — Cloudflare documents it as creating a new version of the Worker and deploying it immediately.

npx wrangler secret put OPENAI_API_KEY
# ✔ Enter a secret value: ****************************

npx wrangler secret list
npx wrangler secret delete OPENAI_API_KEY

In CI, where nothing can answer a prompt, pipe the value in on stdin:

echo "$OPENAI_API_KEY" | npx wrangler secret put OPENAI_API_KEY

Because secret put deploys, it is not the right command inside a gradual rollout. Cloudflare documents wrangler versions secret put and wrangler versions secret delete for that case: they attach the secret to a new version without promoting it, so the change moves through your rollout the same way code does.

Reading it at runtime

Secrets arrive on the same env object as every other binding. There is no client library and no fetch — by the time your handler runs, the value is a string on a property.

export interface Env {
  OPENAI_API_KEY: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    if (!env.OPENAI_API_KEY) {
      // Fail loudly at the boundary rather than sending "Bearer undefined".
      return new Response("misconfigured: OPENAI_API_KEY is not set", { status: 500 });
    }

    const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${env.OPENAI_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "gpt-4.1-mini",
        messages: [{ role: "user", content: "ping" }],
      }),
    });

    return new Response(await upstream.text(), { status: upstream.status });
  },
};

Cloudflare also documents importing env globally from cloudflare:workers, which is convenient in a module that is not a handler. Either way, the explicit presence check above is worth keeping: an unset secret produces the string undefined in an Authorization header and a 401 from the provider, which reads like a bad key rather than a missing one and costs an hour.

Never log the value, and be careful with the near-misses: logging the whole env object, echoing an error object that contains the request headers, or forwarding an upstream error body that quotes your key back at you will all put it in your logs. If you do, rotate it; a secret that has been written to a log store is no longer a secret.

Local development without the key in git

wrangler dev does not read your deployed secrets. Cloudflare documents storing local values in a .dev.vars or .env file in the same directory as your Wrangler configuration, using one or the other rather than both, and not committing them.

# .dev.vars  — add this filename to .gitignore
OPENAI_API_KEY="sk-proj-local-development-key"
  1. Add .dev.vars and .env to .gitignore before you create either file. Doing it afterwards means one commit where the key existed, and that commit is enough.
  2. Commit a .dev.vars.example with the names and empty values, so a new contributor knows what to set without being handed a key.
  3. Use a separate, low-limit key for development. The local key and the production key being the same key is how a runaway test loop ends up in the production rate limit.

Why vars is the wrong place

Wrangler configuration also has a vars block, and it works — the value shows up on env identically, which is exactly why people use it by mistake. Cloudflare’s documentation is explicit: do not use vars to store sensitive information; use secrets instead.

The difference is not encryption at runtime, it is where the value lives and who can read it. A vars entry is plain text in your Wrangler configuration file, which is in your repository, which is on every laptop and in every CI log that prints the config. A secret is stored server-side, and Cloudflare documents that secret values are not visible in Wrangler or the dashboard after you define them.

That last property is the one people find inconvenient and should not: you cannot read a secret back. There is no “show value” button, so the value must exist somewhere else — your password manager or your CI secret store — from the moment you create it. If your only copy was the terminal you typed it into, your only recovery is issuing a new key.

For a key shared by several Workers, Cloudflare documents Secrets Store as an account-level alternative, bound like any other binding. It is documented as beta at the time of writing, which is the relevant fact when deciding whether to depend on it.

Secret handling surfaces have moved recently — the addition of .env alongside .dev.vars, and Secrets Store — so check the current page before scripting around them. Cloudflare, Workers secrets

What a secret does not protect you from

It is worth being precise about what moving a key into a secret buys, because people over-trust the word. A Worker secret removes two specific exposures: the key is no longer in your repository, and it can no longer be read back out of Wrangler or the dashboard. Those are the two ways provider keys most commonly leak, so it is a large improvement. It is not a sandbox.

  • Anyone who can deploy can read it. The secret is a plain string on env at runtime, so a one-line change — return new Response(env.OPENAI_API_KEY) — exfiltrates it on the next deploy. Deploy permission is therefore key-read permission, and should be treated as such when you decide who has it.
  • So can anything in your bundle. Every dependency that runs inside the isolate is inside the same trust boundary. A compromised transitive package does not need to defeat anything; it is already in the process that holds the value.
  • Error paths leak it accidentally. Forwarding an upstream error body that quotes your Authorization header back, serialising a request object into a log line, or shipping a stack trace to an error tracker are all ways the key reaches a system you did not audit.

Which means the useful controls are mostly on the other side. Scope the key at the provider to the minimum it needs and set a spend cap there, so a leaked key has a bounded cost rather than an unbounded one. Issue a separate key per Worker rather than one key for the whole organisation, so that when you do rotate you know exactly what breaks. And keep the blast radius nameable: a key used by one Worker, rotatable in five minutes, is a different incident from a key used by nine services nobody has an inventory of.

One scoping detail to check rather than assume: secrets belong to a Worker, and a named environment is a separate Worker with its own secret store. Setting a value on one does not populate the other, so a staging deploy that suddenly returns 401s after you rotated production is behaving correctly. The flag for targeting an environment is in the Wrangler command reference for wrangler secret — look it up for your Wrangler version rather than trusting a remembered spelling, because getting it wrong sets the secret on the wrong Worker and gives you no error.

Rotating without a gap

The naive rotation is: revoke the old key, create a new one, run wrangler secret put. Between the first and third steps your Worker is serving 401s. Because a Worker can hold more than one secret, you can avoid the gap entirely:

  1. Issue a second key at the provider. Both are now valid.
  2. npx wrangler secret put OPENAI_API_KEY_NEXT with the new value.
  3. Deploy code that reads env.OPENAI_API_KEY_NEXT ?? env.OPENAI_API_KEY. Traffic moves to the new key on this deploy, and the old one is still there if you need to roll back.
  4. Watch your error rate for one full traffic cycle — a day, not ten minutes. Scheduled jobs are the ones that use a key you forgot about.
  5. Revoke the old key at the provider, run npx wrangler secret put OPENAI_API_KEY with the new value, deploy code that reads only env.OPENAI_API_KEY, then npx wrangler secret delete OPENAI_API_KEY_NEXT.

It is five steps instead of three and it has no window in which requests fail. Write it down somewhere your on-call can find it, because rotation is usually performed under time pressure by whoever is awake.