DEV Community

My "Deploy to Cloudflare" button disabled auth

How it happened

The deploy button does something that sounds reasonable: it reads your .dev.vars.example file, prompts the deployer for each entry as a required secret, and deploys those values with the Worker.

Here's what my .dev.vars.example looked like at the time:

DEV_BYPASS_ACCESS = true
APP_BASE_URL = http://localhost:8787
STRIPE_SECRET_KEY = sk_test_xxx
STRIPE_WEBHOOK_SECRET = whsec_xxx
PAYPAL_CLIENT_ID = sandbox_client_id
PAYPAL_CLIENT_SECRET = sandbox_client_secret
PAYPAL_WEBHOOK_ID = sandbox_webhook_id

That file is a normal local-development template, and on its own it's inert - nothing reads it. You copy it to .dev.vars (gitignored), adjust the values, and the copy is what wrangler dev loads. The values are pre-filled so the copy works immediately: DEV_BYPASS_ACCESS=true skips auth in local dev, because nobody wants to click through a login screen a hundred times a day.

The deploy button doesn't know any of that. It saw seven config entries and prompted for all seven, with the example values pre-filled as suggestions. Most people deploying someone else's app for the first time accept the defaults. So the deployed Workers got:

  • DEV_BYPASS_ACCESS=true as a production secret. My auth middleware checked env.DEV_BYPASS_ACCESS === 'true' and skipped verification. The admin was wide open.
  • APP_BASE_URL=http://localhost:8787. Every pay link the app generated, in emails, in PDFs, on the copy-link button, pointed at localhost.
  • Placeholder API keys that passed the "is it configured?" checks. sk_test_xxx is a truthy string, so the app rendered payment buttons that would fail the moment a client tried to pay.

Three failures, one root cause: template text that was only ever meant to be copied and edited by a human became someone else's production configuration, with the editing step removed. Nothing in the pipeline knew the difference.

The fix took three layers

My first instinct was to fix the example file and move on. That's the right first step, and it is nowhere near enough. If a one-click deploy can turn example values into production config, the app has to survive that on its own. Hoping the example file stays clean forever is not a plan.

Layer 1: treat the example file as an installer

Once a deploy button exists, .dev.vars.example stops being documentation. It's the installer prompt. So now only the one value a first deploy genuinely needs is active, and everything else is commented out with instructions:

# NOTE: the Deploy to Cloudflare button prompts for every UNCOMMENTED entry
# as a required secret - so only the true first-deploy requirement is active.
# Admin login until Cloudflare Access is configured (Access disables it)
ADMIN_PASSWORD =
# ---- Payments: add when ready ----
# STRIPE_SECRET_KEY=
# ...

The button now prompts for exactly one thing, an admin password. The app's dashboard warns about everything else until it's configured.

Layer 2: make the bypass flag useless on a deployed Worker

Even if DEV_BYPASS_ACCESS=true gets deployed again someday (someone copies a config, a fork resurrects the old example file), it must not matter. The flag now only counts when the request is genuinely local:

export function devBypassActive(env, req): boolean {
  return env.DEV_BYPASS_ACCESS === 'true' && isLocalRequest(req);
}

This is the part that surprised me. My first version of isLocalRequest checked the URL hostname, and it broke immediately in local development, because wrangler dev emulates your configured route host in request.url. A request to localhost:8787 shows up inside the Worker as https://your-production-domain.com/.... On Workers, hostname checks can't tell local from production.

The signal that works is the cf-ray header:

/** Local dev request: localhost hostname, or no cf-ray (never traversed the edge). */
export function isLocalRequest(req): boolean {
  const host = new URL(req.url).hostname;
  if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]') return true;
  return !req.headers.get('cf-ray');
}

Every request that traverses Cloudflare's edge carries cf-ray, and a client can't strip it; the edge adds it after the client's headers are already fixed. No cf-ray means the request never touched the edge, which means it's local. The same signal later fixed a second bug (localhost pay links in a different code path), and I now treat any hostname-based branching in Workers code as a smell worth auditing.

Layer 3: reject placeholder secrets

The payment buttons rendered because the code asked whether a Stripe key existed, and sk_test_xxx is, technically, a string. The configuration checks now reject known placeholder shapes:

const PLACEHOLDER_VALUES = new Set([
  'sk_test_xxx',
  'whsec_xxx',
  'sandbox_client_id',
  // ...
]);

export function secretConfigured(v: string | undefined): boolean {
  const t = (v ?? '').trim();
  return t !== '' && !PLACEHOLDER_VALUES.has(t);
}

The same idea protects auth-mode selection. Placeholder Access values from the example wrangler config don't count as configured (the AUD has to actually look like a 64-character hex AUD), so the app can't be tricked into a half-configured auth mode. Base-URL resolution got the same treatment: a localhost value arriving on a request that came through the edge gets ignored in favor of the request origin.

Each layer covers a hole the others leave. The example file can regress; layer 2 doesn't care. The bypass flag can leak; layer 1 makes it unlikely and layer 2 makes it inert. A placeholder can slip through a prompt; layer 3 keeps it from pretending to be real.

All three now have unit tests with names like "never active for requests that traversed the Cloudflare edge" and "placeholder Access values do not count as configured." You only write tests like that after the incident that makes them obvious.

What I'd ask of the platform

None of this is really a Cloudflare bug. The docs do say secrets come from .dev.vars.example - I put dev conveniences in a file that feeds the installer, and that's on me. What the docs don't say is that every entry becomes a required prompt with the example value pre-filled, and there's no way to mark one optional.

Two small changes would have prevented the whole class of problem:

  • An optional-secrets flag. Deploy buttons currently treat every .dev.vars.example entry as required. A way to mark entries optional (or a documented convention of skipping commented entries, which is effectively what I reverse-engineered) would let template authors separate "needed to boot" from "add later."
  • A loud warning in the deploy-button docs that .dev.vars.example becomes a production prompt. Every Workers template with local-dev conveniences in its example file is one deploy button away from this postmortem.

I've filed both: the docs warning and the optional-secrets feature request. If you maintain a Workers template with a deploy button, go look at your example file right now.

The lesson

The general version of this incident: distribution inverts your threat model. The moment you add a one-click deploy path, every convenience you built for yourself gets inherited by people who don't know it exists. Your example files, your defaults, the flags nobody would ever set in production. All of it ships.

So design the example file like it's an installer, and gate the dangerous flags on signals that can't be faked. A variable existing was never the same thing as a variable being real.

Minvoice is open source on GitHub. The fix commits and the tests are all there if you want the details.

Comments

No comments yet. Start the discussion.