DEV Community

Beyond login: encrypting data with passkeys and WebAuthn PRF

Originally published at daniel-yang.com.

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. That's an encryption key. Sitting inside the same ceremony everyone already uses for login.

So I built pknotes 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 live demo if you'd rather poke it first (notes wiped daily).

One ceremony, two jobs

A normal passkey login proves who you are and nothing else. With the PRF extension, the same ceremony does double duty:

  • The server verifies the WebAuthn assertion. That's login.
  • The client reads the PRF output from the same response and derives a key from it. That's decryption.

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.

Requesting it looks like this:

const credential = await navigator.credentials.get({
  publicKey: {
    challenge,
    userVerification: 'required',
    extensions: {
      prf: {
        eval: {
          first: new TextEncoder().encode('pknotes/prf-eval/v1')
        }
      },
    },
  },
});

const prfOutput = credential.getClientExtensionResults().prf.results.first;
// 32 bytes, deterministic for this credential + this input, never sent anywhere

The key hierarchy

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:

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

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:

  • Adding a device is one wrap, not a re-encryption. 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.
  • Recovery doesn't need a password either. 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.
  • Revocation is rotation. 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.

The detail that took me longest: bind ciphertext to context

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):

crypto.subtle.encrypt(
  {
    name: 'AES-GCM',
    iv,
    additionalData: enc.encode(`pknotes/note/v1:${noteId}`)
  },
  masterKey,
  plaintext,
);

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.

What AAD doesn't cover: the server can still serve you an older 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.

What actually supports PRF (mid-2026)

The provider matters more than the browser, because third-party password-manager extensions intercept WebAuthn:

Provider PRF for third-party sites
iCloud Keychain (macOS 15+, iOS 18.4+) โœ… Yes
Google Password Manager โœ… Yes
1Password โœ… Yes
Windows Hello (Win 11 25H2+) โœ… Yes
YubiKey / FIDO2 keys โœ… Yes - except via Safari on iOS
Bitwarden, Dashlane, Proton Pass, KeePassXC โณ Not yet

Bitwarden is the one that'll bite you in practice. Its extension intercepts 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.

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.

What this doesn't solve

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.

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.

Where this pattern goes

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.

Code and threat model are in the repo. 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.

Comments

No comments yet. Start the discussion.