<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Daniel Yang</title>
    <description>The latest articles on DEV Community by Daniel Yang (@ddyy).</description>
    <link>https://dev.to/ddyy</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4023328%2F7d8734e5-14a1-4dd5-b752-4ed98621c414.png</url>
      <title>DEV Community: Daniel Yang</title>
      <link>https://dev.to/ddyy</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ddyy"/>
    <language>en</language>
    <item>
      <title>My "Deploy to Cloudflare" button disabled auth</title>
      <dc:creator>Daniel Yang</dc:creator>
      <pubDate>Fri, 10 Jul 2026 01:54:20 +0000</pubDate>
      <link>https://dev.to/ddyy/my-deploy-to-cloudflare-button-disabled-auth-2p67</link>
      <guid>https://dev.to/ddyy/my-deploy-to-cloudflare-button-disabled-auth-2p67</guid>
      <description>&lt;p&gt;I recently open-sourced &lt;a href="https://github.com/ddyy/minvoice" rel="noopener noreferrer"&gt;Minvoice&lt;/a&gt;, a small invoicing app that runs entirely on Cloudflare Workers. Like every well-behaved open-source Workers project, it has a &lt;a href="https://developers.cloudflare.com/workers/platform/deploy-buttons/" rel="noopener noreferrer"&gt;Deploy to Cloudflare&lt;/a&gt; button in the README: one click, and you get the repo forked to your GitHub, a D1 database provisioned, and the app running on your own workers.dev subdomain.&lt;/p&gt;

&lt;p&gt;I clicked my own button to test it. The deploy went fine. The app came up, the setup wizard ran, invoices saved. Then I opened &lt;code&gt;/admin&lt;/code&gt; on the fresh deployment and walked straight in, past no login screen of any kind. Every copy of my app deployed through that button had admin auth turned off.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it happened
&lt;/h2&gt;

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

&lt;p&gt;Here's what my &lt;code&gt;.dev.vars.example&lt;/code&gt; looked like at the time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DEV_BYPASS_ACCESS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="nv"&gt;APP_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:8787
&lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk_test_xxx
&lt;span class="nv"&gt;STRIPE_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;whsec_xxx
&lt;span class="nv"&gt;PAYPAL_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sandbox_client_id
&lt;span class="nv"&gt;PAYPAL_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sandbox_client_secret
&lt;span class="nv"&gt;PAYPAL_WEBHOOK_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sandbox_webhook_id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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:&lt;/p&gt;

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

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix took three layers
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 1: treat the example file as an installer
&lt;/h3&gt;

&lt;p&gt;Once a deploy button exists, &lt;code&gt;.dev.vars.example&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# NOTE: the Deploy to Cloudflare button prompts for every UNCOMMENTED entry&lt;/span&gt;
&lt;span class="c"&gt;# as a required secret — so only the true first-deploy requirement is active.&lt;/span&gt;

&lt;span class="c"&gt;# Admin login until Cloudflare Access is configured (Access disables it)&lt;/span&gt;
&lt;span class="nv"&gt;ADMIN_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;

&lt;span class="c"&gt;# ---- Payments: add when ready ----&lt;/span&gt;
&lt;span class="c"&gt;# STRIPE_SECRET_KEY=&lt;/span&gt;
&lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h3&gt;
  
  
  Layer 2: make the bypass flag useless on a deployed Worker
&lt;/h3&gt;

&lt;p&gt;Even if &lt;code&gt;DEV_BYPASS_ACCESS=true&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;devBypassActive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEV_BYPASS_ACCESS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isLocalRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;The signal that works is the &lt;code&gt;cf-ray&lt;/code&gt; header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Local dev request: localhost hostname, or no cf-ray (never traversed the edge). */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isLocalRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;127.0.0.1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[::1]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cf-ray&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request that traverses Cloudflare's edge carries &lt;code&gt;cf-ray&lt;/code&gt;, and a client can't strip it; the edge adds it after the client's headers are already fixed. No &lt;code&gt;cf-ray&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer 3: reject placeholder secrets
&lt;/h3&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PLACEHOLDER_VALUES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sk_test_xxx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;whsec_xxx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sandbox_client_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;secretConfigured&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;PLACEHOLDER_VALUES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd ask of the platform
&lt;/h2&gt;

&lt;p&gt;None of this is really a Cloudflare bug. The docs do say secrets come from &lt;code&gt;.dev.vars.example&lt;/code&gt; — 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:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An optional-secrets flag.&lt;/strong&gt; Deploy buttons currently treat every &lt;code&gt;.dev.vars.example&lt;/code&gt; 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."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A loud warning in the deploy-button docs&lt;/strong&gt; that &lt;code&gt;.dev.vars.example&lt;/code&gt; becomes a production prompt. Every Workers template with local-dev conveniences in its example file is one deploy button away from this postmortem.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I've filed both: the &lt;a href="https://github.com/cloudflare/cloudflare-docs/issues/31988" rel="noopener noreferrer"&gt;docs warning&lt;/a&gt; and the &lt;a href="https://github.com/cloudflare/workers-sdk/discussions/14639" rel="noopener noreferrer"&gt;optional-secrets feature request&lt;/a&gt;. If you maintain a Workers template with a deploy button, go look at your example file right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Minvoice is open source &lt;a href="https://github.com/ddyy/minvoice" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;. The fix commits and the tests are all there if you want the details.&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>security</category>
      <category>serverless</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
