Matt's Headroom

Encrypting Data in the Browser Using WebAuthn

- 8 minute read

My sneakernet hacker fantasies are becoming reality 👟

When I discovered WebAuthn three years ago a quirky idea came to me: “what if you could also protect data with a security key?” The idea of a physical authenticator being used to encrypt and decrypt information stuck with me, even after I came to understand that WebAuthn couldn’t be used in that way.

Fast forward to 2023. The recent addition of the prf extension to the WebAuthn L3 Draft spec is introducing functionality to WebAuthn that makes my crazy idea possible! Imagine it: a quick tap to encrypt a super secret message, a short journey via sneakernet, then a quick tap to decrypt the message…

I’m happy to report that my “crazy idea” has become a reality. And even better, it can all be done entirely in the browser 😏

Disclaimer: for as in-depth as I can speak to the practical use of cryptographic concepts vis-a-vis WebAuthn, I am still early in my education of many other fundamentals of cryptography. This post represents my deepest dive yet into more complex concepts like HMACs, key derivation, and encryption. While I took steps to verify the content of this post, I apologize for any inaccuracies. Please feel free to contact me if I’m off the mark on anything.

## A summary of the prf WebAuthn extension

Briefly, prf passes bytes from the “Relying Party” (that’s you, using WebAuthn) to the authenticator during a WebAuthn authentication ceremony. The authenticator “hashes” (HMACs) these bytes with secret bytes internally associated with a previously registered credential (as per CTAP’s hmac-secret extension) and returns the resulting bytes to the browser in the output from navigator.credentials.get().

The high-entropy bytes returned from the authenticator are perfect input key material for an “HMAC-based Key Derivation Function” (HKDF), which helps us generate a “key derivation key”. The key derivation key is then used to derive another symmetric key that’s used to perform the actual data encryption.

Something to remember is that output from the prf extension will be the same for every authentication ceremony so long as A) the same WebAuthn credential is used, and B) the bytes the RP passes to the authenticator are the same. These two come together to make it possible to deterministically recreate the symmetric encryption key protecting the data at any time. And even better, the secret bytes within the authenticator are origin-bound as well because of the origin-bound credential they are associated with!

## Practical use

You can follow along in just a few steps:

  1. Install Chrome Canary (at least Version 111.0.5548.0)
  2. Navigate to chrome://flags/#enable-experimental-web-platform-features and enable it
  3. Grab a FIDO2 security key manufactured in the last couple of years. hmac-secret exists in CTAP as early as 2018 so you shouldn’t need the latest and greatest. I used a YubiKey Security Key for this.

Let’s get down to brass tacks.

### Step 1: Register to prime the authenticator

Make a typical call to navigator.credentials.create() with the prf extension defined:

 * This value is for sake of demonstration. Pick 32 random
 * bytes. `salt` can be static for your site or unique per
 * credential depending on your needs.
const firstSalt = new Uint8Array(new Array(32).fill(1)).buffer;

const regCredential = await navigator.credentials.create({
  publicKey: {
    challenge: new Uint8Array([1, 2, 3, 4]), // Example value
    rp: {
      name: "SimpleWebAuthn Example",
      id: "",
    user: {
      id: new Uint8Array([5, 6, 7, 8]),  // Example value
      name: "[email protected]",
      displayName: "[email protected]",
    pubKeyCredParams: [
      { alg: -8, type: "public-key" },   // Ed25519
      { alg: -7, type: "public-key" },   // ES256
      { alg: -257, type: "public-key" }, // RS256
    authenticatorSelection: {
      userVerification: "required",
    extensions: {
      prf: {
        eval: {
          first: firstSalt,

NOTE: The first passed in here isn’t currently used during registration, but the prf extension requires it to be set.

Tap your security key, follow the browser prompts, then call getClientExtensionResults() afterwards and look for a prf entry:

// {
//   prf: {
//     enabled: true
//   }
// }

If you see enabled: true then you’re good to continue. If you don’t then you’ll need to try it again with another security key until you find one that works.

### Step 2: Authenticate to encrypt

The next step is to call navigator.credentials.get() and pass in our firstSalt:

const auth1Credential = await navigator.credentials.get({
  publicKey: {
    challenge: new Uint8Array([9, 0, 1, 2]), // Example value
    allowCredentials: [
        id: regCredential.rawId,
        transports: regCredential.response.getTransports(),
        type: "public-key",
    rpId: "",
    // This must always be either "discouraged" or "required".
    // Pick one and stick with it.
    userVerification: "required",
    extensions: {
      prf: {
        eval: {
          first: firstSalt,

Tap your security key again, then call getClientExtensionResults() afterwards and look for the prf entry:

const auth1ExtensionResults = auth1Credential.getClientExtensionResults();
//   prf: {
//     results: {
//       first: ArrayBuffer(32),
//     }
//   }
// }

The first bytes returned here are the key (no pun intended) to the next steps involving WebCrypto’s SubtleCrypto browser API:

#### Step 2.1: Import the input key material

Create a key derivation key using crypto.subtle.importKey():

const inputKeyMaterial = new Uint8Array(
const keyDerivationKey = await crypto.subtle.importKey(

#### Step 2.2: Derive the encryption key

Next, create the symmetric key that we’ll use for encryption with crypto.subtle.deriveKey():

// Never forget what you set this value to or the key can't be
// derived later
const label = "encryption key";
const info = new TextEncoder().encode(label);
// `salt` is a required argument for `deriveKey()`, but should
// be empty
const salt = new Uint8Array();

const encryptionKey = await crypto.subtle.deriveKey(
  { name: "HKDF", info, salt, hash: "SHA-256" },
  { name: "AES-GCM", length: 256 },
  // No need for exportability because we can deterministically
  // recreate this key
  ["encrypt", "decrypt"],

#### Step 2.3: Encrypt the message

Now we can encrypt our message using the aptly named crypto.subtle.encrypt() method:

// Keep track of this `nonce`, you'll need it to decrypt later!
// FYI it's not a secret so you don't have to protect it.
const nonce = crypto.getRandomValues(new Uint8Array(12));

const encrypted = await crypto.subtle.encrypt(
  { name: "AES-GCM", iv: nonce },
  new TextEncoder().encode("hello readers 🥳"),

### Step 3: Authenticate to decrypt

Decrypting the message looks almost the same as everything in Step 2, except during the last step you’ll call crypto.subtle.decrypt() instead:

const decrypted = await crypto.subtle.decrypt(
  // `nonce` should be the same value from Step 2.3
  { name: "AES-GCM", iv: nonce },

If you did everything right, you should see your super secret message:

console.log((new TextDecoder()).decode(decrypted));
// hello readers 🥳

## Proof

Here’s a screenshot of Chrome Canary after I wired all of this up into my SimpleWebAuthn example server:

A screenshot of Chrome with the SimpleWebAuthn example site loaded. The left side shows a successful authentication message, and raw JSON inputs and outputs into the WebAuthn authentication API method. The right half of the browser window shows the console with a sequence of cryptographic events ending in the output of the encrypted message, “hello readers”, after having been successfully decrypted.

## Things to remember

## In Conclusion

So there you have it, data encryption using WebAuthn. I’m excited by the possibilities this brings to websites, and I know that it’s just a matter of time before others find novel ways to apply this technique to strongly protect your secrets.

And heck, now that I’ve written this I might just try creating something novel with prf myself…

(A huge thanks to Cendyne for helping proof-read the cryptographic-heavy parts of this post!)

Edit (Jun 10, 2023): I fixed an issue with one of the code samples. I also created a gist based on this post that can be hosted at http://localhost to test prf support in various browsers.