<?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>You can't commit what Git can't see: keeping agent exhaust out of your repo</title>
      <dc:creator>Daniel Yang</dc:creator>
      <pubDate>Tue, 21 Jul 2026 06:52:35 +0000</pubDate>
      <link>https://dev.to/ddyy/you-cant-commit-what-git-cant-see-keeping-agent-exhaust-out-of-your-repo-3foj</link>
      <guid>https://dev.to/ddyy/you-cant-commit-what-git-cant-see-keeping-agent-exhaust-out-of-your-repo-3foj</guid>
      <description>&lt;p&gt;Working with coding agents produces exhaust. Plans written before any code gets touched. Research notes on the library that didn't make the cut. A script that verified one thing once. Instructions telling the agent how I like to work.&lt;/p&gt;

&lt;p&gt;None of it belongs in the repo, but all of it wants to live right next to the repo.&lt;/p&gt;

&lt;p&gt;For a while, I handled this the usual way: a &lt;code&gt;.gitignore&lt;/code&gt; entry and good intentions.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;code&gt;.gitignore&lt;/code&gt; is a promise, not a guarantee. One &lt;code&gt;git add -f&lt;/code&gt; when you're moving fast. One overly broad pattern change that un-ignores a directory as a side effect. One agent that "helpfully" cleans up your ignore file during a refactor—which is a real thing that happens when you give a language model write access to your tree.&lt;/p&gt;

&lt;p&gt;Any of those can put your private notes into history. Once that history reaches a shared remote, cleaning it up means rewriting commits, coordinating with everyone who pulled them, and possibly rotating anything sensitive.&lt;/p&gt;

&lt;p&gt;So I stopped trusting the promise. My notes now live one directory above the repo root, outside its worktree entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;myapp-workspace/          ← workspace, outside the Git repo
├── CLAUDE.md             ← personal agent instructions
├── notes/                ← plans, research, session notes
├── scratch/              ← agent exhaust, one-off scripts
└── myapp/                ← the Git repo
    └── CLAUDE.md         ← optional, shared project instructions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Files in &lt;code&gt;notes/&lt;/code&gt; and &lt;code&gt;scratch/&lt;/code&gt; cannot be accidentally staged by commands operating on the repo. The protection doesn't depend on anyone remembering an ignore rule; the files are structurally outside the thing Git tracks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What goes where
&lt;/h2&gt;

&lt;p&gt;The workspace is a plain, untracked folder that wraps the repo.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;notes/&lt;/code&gt; holds anything durable: plans, research, and session summaries. &lt;code&gt;scratch/&lt;/code&gt; is the dumping ground for agent output and one-off experiments. Anything worth keeping graduates to &lt;code&gt;notes/&lt;/code&gt; or into the repo's own documentation. The repo itself is the only thing that ships.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; is what makes the layout work well with agents. Claude Code loads instruction files from ancestor directories of wherever it starts, so a workspace-level &lt;code&gt;CLAUDE.md&lt;/code&gt; is read by sessions inside the repo while remaining outside the repo's worktree.&lt;/p&gt;

&lt;p&gt;Mine explains the layout and adds one important rule: never reference &lt;code&gt;../notes&lt;/code&gt; or &lt;code&gt;../scratch&lt;/code&gt; from anything committed inside the repo. The files can't be staged directly, but an agent could still copy or quote their contents. The instruction makes that boundary explicit.&lt;/p&gt;

&lt;p&gt;The outer file doesn't replace a committed &lt;code&gt;CLAUDE.md&lt;/code&gt; inside the repo. They serve different audiences: the workspace file holds personal preferences and rules specific to the wrapper, while an optional inner file can hold architecture, conventions, and gotchas that should travel with the code and be shared with the team. Claude reads both. Repoyard creates only the outer one; the inner file belongs to the project and its team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the suffix goes on the outside
&lt;/h2&gt;

&lt;p&gt;The workspace is named &lt;code&gt;myapp-workspace&lt;/code&gt;, while the repo keeps the clean name &lt;code&gt;myapp&lt;/code&gt;. I tried it the other way around first and walked it back for three reasons that all turned out to matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Terminal tabs usually show the innermost folder. With the clean name inside, tabs show &lt;code&gt;myapp&lt;/code&gt; rather than some variation of &lt;code&gt;workspace&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Tools derive names from the repo directory. Docker Compose project names, &lt;code&gt;npm init&lt;/code&gt; defaults, and similar conventions stay clean.&lt;/li&gt;
&lt;li&gt;The repo directory matches the GitHub repository name, so running &lt;code&gt;git clone&lt;/code&gt; inside a fresh workspace recreates the layout without a custom target argument.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The wrapper gets the suffix because the wrapper is the unusual part. The repo keeps the name every other tool expects.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool
&lt;/h2&gt;

&lt;p&gt;I wrote a small CLI that scaffolds the convention: &lt;a href="https://github.com/ddyy/repoyard" rel="noopener noreferrer"&gt;repoyard&lt;/a&gt;. It has zero dependencies, requires Node 20 or newer, and provides three commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx repoyard create myapp   &lt;span class="c"&gt;# greenfield: workspace + fresh Git repo inside&lt;/span&gt;
npx repoyard adopt          &lt;span class="c"&gt;# wrap an existing repo&lt;/span&gt;
npx repoyard doctor         &lt;span class="c"&gt;# check a workspace against the convention&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;adopt&lt;/code&gt; is probably the command you'll use most, since most projects already exist.&lt;/p&gt;

&lt;p&gt;Run it from the root of a clean repo and it creates the sibling workspace, moves the repo inside with a same-filesystem rename, and scaffolds the workspace files. If anything fails, it rolls the move back automatically. When it's done, it prints the &lt;code&gt;cd&lt;/code&gt; command for the new location.&lt;/p&gt;

&lt;p&gt;Run it interactively and it asks what you want using arrow-key menus. Pass &lt;code&gt;--no-input&lt;/code&gt; to make it scriptable, or &lt;code&gt;--dry-run&lt;/code&gt; to print the plan without touching anything. Pass &lt;code&gt;--agent-file=claude|agents|both|none&lt;/code&gt; to scaffold &lt;code&gt;AGENTS.md&lt;/code&gt; instead of—or alongside—&lt;code&gt;CLAUDE.md&lt;/code&gt;, since the convention isn't Claude-specific.&lt;/p&gt;

&lt;p&gt;The first repo I adopted was repoyard itself. The tool now lives inside the layout it scaffolds, which is either good dogfooding or a snake eating its tail, depending on your mood.&lt;/p&gt;

&lt;h2&gt;
  
  
  Yes, this is basically &lt;code&gt;mkdir&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;You could set all of this up by hand in thirty seconds. The tool will not pretend otherwise.&lt;/p&gt;

&lt;p&gt;What you're getting is the codified convention: the naming, the division between durable notes and disposable scratch work, and an agent-facing instruction file that explains the layout to any tool reading ancestor directories.&lt;/p&gt;

&lt;p&gt;Conventions pay off when you, your agents, and your other machines all do the same thing without having to think about it. The syscalls were never the hard part.&lt;/p&gt;

&lt;p&gt;The tool is also deliberately finished. There is no config file, plugin system, framework template, telemetry, or update checker. Worktrees already work fine inside a workspace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git worktree add ../myapp-wip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Repoyard stays out of that too. Post-1.0 ideas have to solve a concrete problem, not merely exist.&lt;/p&gt;

&lt;p&gt;If this failure mode has ever bitten you—or you'd prefer it never get the chance—you can find the project at &lt;a href="https://github.com/ddyy/repoyard" rel="noopener noreferrer"&gt;github.com/ddyy/repoyard&lt;/a&gt;, or run &lt;code&gt;npx repoyard&lt;/code&gt; in a scratch directory and try it yourself.&lt;/p&gt;

</description>
      <category>git</category>
      <category>ai</category>
      <category>productivity</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Beyond login: encrypting data with passkeys and WebAuthn PRF</title>
      <dc:creator>Daniel Yang</dc:creator>
      <pubDate>Thu, 16 Jul 2026 21:24:05 +0000</pubDate>
      <link>https://dev.to/ddyy/beyond-login-encrypting-data-with-passkeys-and-webauthn-prf-p20</link>
      <guid>https://dev.to/ddyy/beyond-login-encrypting-data-with-passkeys-and-webauthn-prf-p20</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://daniel-yang.com/writing/beyond-login-encrypting-data-with-passkeys-and-webauthn-prf/" rel="noopener noreferrer"&gt;daniel-yang.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I've been using passkeys for a while now, and at some point I noticed an extension in the WebAuthn spec that almost nobody talks about: PRF. It lets a website ask your authenticator to evaluate a pseudo-random function during login. Deterministic output, 32 bytes, keyed to that specific credential, never leaves your browser.&lt;/p&gt;

&lt;p&gt;That's an encryption key. Sitting inside the same ceremony everyone already uses for login.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://github.com/ddyy/pknotes" rel="noopener noreferrer"&gt;pknotes&lt;/a&gt; to see how far the idea goes: an end-to-end encrypted notes app with no master password anywhere. Your passkey unlocks your notes in the literal, cryptographic sense. This post is the architecture writeup. There's a &lt;a href="https://pknotes-demo.dydev.workers.dev" rel="noopener noreferrer"&gt;live demo&lt;/a&gt; if you'd rather poke it first (notes wiped daily).&lt;/p&gt;

&lt;h2&gt;
  
  
  One ceremony, two jobs
&lt;/h2&gt;

&lt;p&gt;A normal passkey login proves who you are and nothing else. With the PRF extension, the same ceremony does double duty:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The server verifies the WebAuthn assertion. That's login.&lt;/li&gt;
&lt;li&gt;The client reads the PRF output from the same response and derives a key from it. That's decryption.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The server never sees the PRF bytes. They're returned to client-side JavaScript only, after user verification (Face ID, Touch ID, PIN), and only for the requesting origin.&lt;/p&gt;

&lt;p&gt;Requesting it looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;credentials&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="na"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;userVerification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;prf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;first&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pknotes/prf-eval/v1&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;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&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;prfOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getClientExtensionResults&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;prf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// 32 bytes, deterministic for this credential + this input, never sent anywhere&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The key hierarchy
&lt;/h2&gt;

&lt;p&gt;Raw PRF output shouldn't encrypt data directly, and you also want to be able to add and remove devices without re-encrypting everything. So there's a small hierarchy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Passkey PRF output
   │  HKDF-SHA256
   ▼
KEK (key-encryption key, exists only in browser memory)
   │  unwraps
   ▼
Master key (random AES-256, generated once at signup)
   │  AES-256-GCM, fresh IV per save
   ▼
Notes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The master key is random, not derived. Each passkey wraps its own copy of it, and those wrapped blobs are what the server stores. Three properties fall out of this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Adding a device is one wrap, not a re-encryption.&lt;/strong&gt; A new passkey does a ceremony, derives its own KEK, wraps the same master key, done. Your notes don't change. Passkeys sync through iCloud Keychain or Google Password Manager anyway, so most "new device" cases are free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recovery doesn't need a password either.&lt;/strong&gt; At signup you get a one-time 160-bit recovery code. It's just another wrap of the master key, with the KEK derived from the code instead of a PRF. The server stores a hash of a verifier derived from the code, so the code itself works like a passkey that lives on paper. Codes are single-use: redeeming one invalidates it and issues a replacement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revocation is rotation.&lt;/strong&gt; Deleting a stolen device's passkey row only blocks future logins; the thief may already hold the master key. Real revocation generates a fresh master key, re-encrypts every note, re-wraps for the one passkey you're holding, and cuts off everything else. In pknotes that's one button. It's the operation that makes "remove this device" mean something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The detail that took me longest: bind ciphertext to context
&lt;/h2&gt;

&lt;p&gt;Every note is encrypted with the same master key. Without extra care, a malicious server could swap the ciphertext of two notes and the client would decrypt the wrong content in the wrong place, silently. AES-GCM has a feature built for exactly this, additional authenticated data (AAD):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encrypt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AES-GCM&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;iv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;additionalData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`pknotes/note/v1:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;noteId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;masterKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;plaintext&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 note's id is baked into the authentication tag. Serve that blob under any other note's id and decryption fails loudly instead of succeeding wrongly. Cheap to add, easy to forget, and the difference between "we encrypt" and a threat model that holds up.&lt;/p&gt;

&lt;p&gt;What AAD doesn't cover: the server can still serve you an &lt;em&gt;older&lt;/em&gt; version of the same note. Detecting that would need client-side version state. It's on the documented-limitations list, and I'd rather document it than pretend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually supports PRF (mid-2026)
&lt;/h2&gt;

&lt;p&gt;The provider matters more than the browser, because third-party password-manager extensions intercept WebAuthn:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;PRF for third-party sites&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iCloud Keychain (macOS 15+, iOS 18.4+)&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Password Manager&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1Password&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows Hello (Win 11 25H2+)&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;YubiKey / FIDO2 keys&lt;/td&gt;
&lt;td&gt;✅ Yes — except via Safari on iOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitwarden, Dashlane, Proton Pass, KeePassXC&lt;/td&gt;
&lt;td&gt;⏳ Not yet&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bitwarden is the one that'll bite you in practice. Its extension &lt;em&gt;intercepts&lt;/em&gt; the WebAuthn ceremony, so it hijacks the flow even when the user has a working platform passkey (iCloud Keychain, Windows Hello) sitting right there — the others only matter if you deliberately picked them as your provider. pknotes detects the missing PRF and tells the user to dismiss the Bitwarden prompt and use a supported provider.&lt;/p&gt;

&lt;p&gt;More generally: check for PRF at signup and refuse to create an account you could never decrypt. Do that check first; the failure mode otherwise is a user with an account and no key.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't solve
&lt;/h2&gt;

&lt;p&gt;The server ships the JavaScript. A malicious or compromised server could serve a client that exfiltrates keys, and that's true of every web-based E2EE product, Proton and Bitwarden's web vault included. My answer is structural rather than cryptographic: the client is about 1,500 lines with one crypto dependency, small enough to actually read, and self-hosting makes you the party you're trusting. A pinned native client is the only stronger answer, and the web can't provide one.&lt;/p&gt;

&lt;p&gt;The server also sees metadata (note count, timestamps, sizes, IPs). And if you lose every passkey and the recovery code, the notes are gone. Nobody can reset what the server can't read. That one is the point, but it deserves to be said in plain sentences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this pattern goes
&lt;/h2&gt;

&lt;p&gt;The interesting part isn't notes. Passkeys quietly solved key distribution: key material that syncs across your devices, survives losing one, and unlocks with a fingerprint. The ecosystem uses all of that for login and nothing else. Anything that currently makes a user manage a key file, a seed phrase, or a master password is a candidate for this same PRF-to-KEK construction, and notes were just the version I needed.&lt;/p&gt;

&lt;p&gt;Code and threat model are in the &lt;a href="https://github.com/ddyy/pknotes" rel="noopener noreferrer"&gt;repo&lt;/a&gt;. If you spot a hole in the crypto, the repo has private vulnerability reporting, and I'd genuinely rather hear it from you than find it in a postmortem.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>webauthn</category>
    </item>
    <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>
