A Developer’s Guide to Deriving Keys with WebAuthn PRF and YubiKeys

In Part 1, we introduced the power of the WebAuthn PRF extension. Now, let’s get practical. This guide will walk you through the code required to derive a hardware-backed secret from a YubiKey and, most importantly, the cryptographic best practices for turning that secret into a secure encryption key.

Prerequisites

  • A YubiKey that supports FIDO2 (any YubiKey 5 Series or YubiKey Bio Series).

  • A browser and platform that support the WebAuthn prf extension.

  • A basic understanding of the WebAuthn registration (create) and authentication (get) ceremonies.

Web Implementation: The JavaScript API

Using PRF in a web app involves a three-step process: enabling the capability, deriving the raw secret, and then securely transforming that secret into a usable encryption key.

Checking for PRF Support

Before attempting a PRF operation, you can proactively check if the browser itself supports the extension. This allows you to progressively enhance your UI, for example, by only showing an "Encrypt with YubiKey" option if the feature is available.

if (window.PublicKeyCredential &&
    typeof PublicKeyCredential.getClientCapabilities === 'function') {
    const caps = await PublicKeyCredential.getClientCapabilities("public-key");
    if (caps.extensions?.includes("prf")) {
        console.log("This browser supports the PRF extension.");
    }
}

Step 1: Enabling PRF During Credential Creation

To use PRF, you must first signal your intent during the registration ceremony (navigator.credentials.create()). The primary goal here is not to derive a secret, but to instruct the authenticator to generate and associate the necessary internal PRF key with the new credential.

The most robust and minimal way to do this is to pass an empty prf object in the extensions input.

// Enable the PRF capability for the new credential
const createOptions = {
  publicKey: {
    // ... other required publicKey options (rp, user, challenge, pubKeyCredParams)
    extensions: {
      prf: {} // The presence of the empty object enables the extension.
    }
  }
};

const newCredential = await navigator.credentials.create(createOptions);

// After creation, verify that the authenticator supports and enabled PRF.
const prfExtensionOutput = newCredential.getClientExtensionResults()?.prf;
if (prfExtensionOutput?.enabled) {
  console.log('✅ PRF was successfully enabled for this new credential.');
} else {
  console.log('⚠️ The authenticator does not support PRF or did not enable it.');
}

Step 2: Deriving the Secret During Authentication

During the navigator.credentials.get() call, you request the 32-byte secret by providing a salt.

// For increased security, a unique, per-credential random salt should be
// generated by the server during registration and stored alongside the
// credential ID. For demonstration, we use a static salt here.
const encryptionSalt = new TextEncoder().encode('encryption-key-v1');

const getOptions = {
  publicKey: {
    // ... other required publicKey options (challenge, rpId)
    extensions: {
      prf: {
        eval: {
          first: encryptionSalt // Request evaluation using the salt
        }
      }
    }
  }
};

const assertion = await navigator.credentials.get(getOptions);
const prfResults = assertion.getClientExtensionResults()?.prf?.results?.first;

You now have a 32-byte, high-entropy secret in the prfResults variable. The next step is the most critical from a security engineering perspective.

Step 3: Deriving an Encryption Key with a KDF

The raw 32-byte output from the PRF is excellent entropy, but it should be treated as Input Keying Material (IKM), not as a final encryption key. The definitive best practice is to use a Key Derivation Function (KDF) to transform this IKM into a purpose-bound encryption key.

The recommended KDF is HKDF (HMAC-based Key Derivation Function) RFC5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF), which is available natively in the browser through the Web Crypto API.

// This is the based on the FUNKE wwWallet keystore pattern.
if (prfResults) {
    // 1. First, import the raw PRF result as a master key for HKDF.
    //    This key's usage is restricted to only deriving other keys.
    const masterKey = await crypto.subtle.importKey(
        'raw',
        prfResults,
        'HKDF', // Specify that this key's algorithm is HKDF
        false,  // This key is not extractable
        ['deriveKey'] // It can ONLY be used to derive other keys
    );

    // 2. Now, derive a specific key for your purpose (e.g., AES-GCM).
    const aesKey = await crypto.subtle.deriveKey(
        {
            name: 'HKDF',
            salt: new Uint8Array(), // Salt can be empty if the masterKey is strong entropy, which PRF output is.
            hash: 'SHA-256',
            info: new TextEncoder().encode('AES-GCM Vault Encryption Key V1') // CRITICAL: Purpose-binding
        },
        masterKey, // The master key derived from the PRF secret
        { name: 'AES-GCM', length: 256 }, // The properties of the key you want to create footnote:[NIST-GCM: link:https://csrc.nist.gov/pubs/sp/800/38/d/final[NIST SP 800-38D Recommendation for AES-GCM]]
        false, // The final key should also be non-extractable
        ['encrypt', 'decrypt'] // The final key's intended usages
    );

    // Now use the derived `aesKey` for all encryption/decryption.
}

Cryptographic Considerations & Caveats

Cryptography is subtle, and the choices of algorithms and parameters matter.

  1. Due Diligence: The cryptographic patterns described here (HKDF to derive an AES-GCM key) are robust and follow modern best practices. However, they are provided as an example. You must perform your own threat modeling and due diligence to ensure the choices are appropriate for your specific use case.

  2. Domain Separation: The info parameter in HKDF is crucial. It cryptographically binds the derived key to a specific purpose. If you later need an HMAC key for message signing, you can derive a new, unrelated key from the same masterKey by simply changing the info string (e.g., "HMAC Authentication Key"). This prevents a class of vulnerabilities related to key reuse.

Advanced Patterns: Key Management & Recovery

Building a production-ready PRF implementation requires planning for the entire key lifecycle, including rotation and recovery.

Key Rotation with WebAuthn PRF

In any secure system, cryptographic keys should not live forever. Key rotation is the process of retiring an old key and replacing it with a new one. This critical practice limits the amount of data exposed if a single key is ever compromised (the "cryptoperiod"). The WebAuthn PRF extension was explicitly designed to make this process seamless by allowing an application to derive two different secrets from the same YubiKey in a single user authentication event. This mechanism is the foundation for a "decrypt with old key, re-encrypt with new key" atomic operation, a principle detailed in standards like NIST SP 800-57.

Code Example for Key Rotation

Let’s walk through rotating a Key Encryption Key (KEK) in the Envelope Encryption architecture.

/**
 * Performs a key rotation for a user's wrapped Data Encryption Key.
 * @param {BufferSource} oldSalt - The salt for the key to be retired.
 * @param {BufferSource} newSalt - The salt for the new key.
 * @param {ArrayBuffer} oldWrappedDEKBlob - The encrypted DEK blob from the server,
 * which contains both the IV and the ciphertext.
 * @returns {Promise<{newWrappedDEKBlob: ArrayBuffer}>} - The new encrypted DEK blob.
 */
async function rotateKeyEncryptionKey(oldSalt, newSalt, oldEncryptedDEK) {
    // 1. Request PRF secrets for BOTH the old and new salts in one transaction.
    const getOptions = {
      publicKey: { /* ... challenge, rpId, etc. ... */,
        extensions: { prf: { eval: { first: oldSalt, second: newSalt } } }
      }
    };
    const assertion = await navigator.credentials.get(getOptions);
    const prfResults = assertion.getClientExtensionResults().prf.results;

    const oldPrfSecret = prfResults.first;
    const newPrfSecret = prfResults.second;

    // 2. Derive both the old and new Key Encryption Keys (KEKs) using HKDF.
    const oldMasterKey = await crypto.subtle.importKey('raw', oldPrfSecret, 'HKDF', false, ['deriveKey']);
    const oldKEK = await crypto.subtle.deriveKey(
        { name: 'HKDF', salt: new Uint8Array(), hash: 'SHA-256', info: new TextEncoder().encode('DEK Wrapping Key V1') },
        oldMasterKey, { name: 'AES-GCM', length: 256 }, false, ['unwrapKey']
    );

    const newMasterKey = await crypto.subtle.importKey('raw', newPrfSecret, 'HKDF', false, ['deriveKey']);
    const newKEK = await crypto.subtle.deriveKey(
        { name: 'HKDF', salt: new Uint8Array(), hash: 'SHA-256', info: new TextEncoder().encode('DEK Wrapping Key V2') },
        newMasterKey, { name: 'AES-GCM', length: 256 }, false, ['wrapKey']
    );

    // 3. Use the old KEK to decrypt (unwrap) the DEK.
    // Assumes a structure of [12-byte IV][Ciphertext]
    const oldIv = oldWrappedDEKBlob.slice(0, 12);
    const oldCiphertext = oldWrappedDEKBlob.slice(12);

    const plaintextDEK = await crypto.subtle.unwrapKey(
        'raw',
        oldCiphertext, // Use the parsed ciphertext
        oldKEK,
        { name: 'AES-GCM', iv: oldIv } // Use the parsed IV
    );

    // 4. Immediately use the new KEK to re-encrypt (wrap) the DEK.
    const newIv = crypto.getRandomValues(new Uint8Array(12));
    const newWrappedDEK = await crypto.subtle.wrapKey(
        'raw',
        plaintextDEK,
        newKEK,
        { name: 'AES-GCM', iv: newIv }
    );

    // --- Step 5: Combine the new IV and new ciphertext into a single blob ---
    const newWrappedDEKBlob = new Uint8Array(newIv.byteLength + newWrappedDEK.byteLength);
    newWrappedDEKBlob.set(new Uint8Array(newIv), 0);
    newWrappedDEKBlob.set(new Uint8Array(newWrappedDEK), newIv.byteLength);

    // 6. Return the new wrapped DEK blob to the server.
    return { newWrappedDEKBlob: newWrappedDEKBlob.buffer };
}

Planning for Key Recovery: Multi-Device Unlock with Envelope Encryption

A robust recovery strategy is non-negotiable. The recommended architecture is Envelope Encryption (also known as Key Wrapping), which decouples data encryption from authentication and allows any of a user’s registered YubiKeys to unlock the same data vault. This pattern is a standard cryptographic practice, detailed in publications like NIST SP 800-57.

  1. Data Encryption Key (DEK): A single, strong, symmetric key is created on the client side to encrypt the main data vault.

  2. Key Encryption Keys (KEKs): Each registered YubiKey can produce its own unique KEK via the PRF extension (using the recommended KDF pattern).

  3. The Envelope: The server stores the encrypted vault and multiple encrypted copies of the DEK, each one "wrapped" by a different KEK.

To implement this, your application must allow a logged-in user to add a new authenticator. After the new YubiKey is registered, the client must perform one final step: create a new wrapped DEK for it.

/**
 * Creates a new wrapped DEK for a newly registered authenticator.
 * @param {PublicKeyCredential} newCredential - The credential object from a successful create() call.
 * @param {CryptoKey} plaintextDEK - The plaintext Data Encryption Key held in the current session.
 * @returns {Promise<{credentialId: string, encryptedDEKBlob: ArrayBuffer}>} - The data to send to the server.
 */
async function createWrappedDEK(newCredential, plaintextDEK) {
    // 1. Get the PRF results from the new credential.
    //    This requires a get() call immediately after the create() call.
    const prfResults = await getPrfFromNewCredential(newCredential);

    // 2. Derive the new Key Encryption Key (KEK) using the recommended KDF pattern.
    const masterKey = await crypto.subtle.importKey('raw', prfResults, 'HKDF', false, ['deriveKey']);
    const newKEK = await crypto.subtle.deriveKey(
        { name: 'HKDF', salt: new Uint8Array(), hash: 'SHA-256', info: new TextEncoder().encode('DEK Wrapping Key V1') },
        masterKey,
        { name: 'AES-GCM', length: 256 },
        false,
        ['wrapKey'] // This key only needs to encrypt/wrap.
    );

    // 3. Generate a fresh, random 12-byte IV for this encryption operation.
    const newIv = crypto.getRandomValues(new Uint8Array(12));

    // 4. Encrypt (wrap) the plaintext DEK with the new KEK and the generated IV.
    //    For details on the parameters for AES-GCM, see the link:https://developer.mozilla.org/en-US/docs/Web/API/AesGcmParams[MDN Documentation].
    const wrappedDEK = await crypto.subtle.wrapKey(
        'raw',
        plaintextDEK,
        newKEK,
        { name: 'AES-GCM', iv: newIv }
    );

    // 5. Construct the final blob by prepending the IV to the ciphertext.
    //    This is the "envelope" that will be stored on the server.
    const encryptedDEKBlob = new Uint8Array(newIv.byteLength + wrappedDEK.byteLength);
    encryptedDEKBlob.set(new Uint8Array(newIv), 0);
    encryptedDEKBlob.set(new Uint8Array(wrappedDEK), newIv.byteLength);

    // 6. Return the new wrapped DEK blob to be stored on the server.
    return {
        credentialId: newCredential.id,
        encryptedDEKBlob: encryptedDEKBlob.buffer,
    };
}

Navigating Platform Support and Incompatibilities

The support landscape for the prf extension is evolving rapidly. A successful implementation depends on the entire chain: the authenticator (e.g., YubiKey), the OS platform, and the client (browser). When a PRF operation fails, it should be handled gracefully. Do not treat it as a hard error. Instead, inform the user that to access encrypted features, they must sign in with a compatible authenticator on a supported platform.

PRF Compatibility (as of mid-2025)

The following table provides a general overview. Always test on your target platforms.

Platform

Browser(s)

Platform Authenticator (Passkey) Support

Roaming Authenticator (YubiKey) Support

Windows 11

Chrome, Edge, Firefox

❌ (Windows Hello lacks hmac-secret)

✅ (Also used for link:https://learn.microsoft.com/en-us/windows/security/identity-protection/hello-for-business/webauthn-apis[offline domain login])

macOS 15+

Safari 18+, Chrome

✅ (iCloud Keychain)

Chrome: ✅, Safari: ❌

iOS / iPadOS 18+

Safari 18+

✅ (iCloud Keychain)

❌ (Critical Limitation) Platform does not pass extension data to/from external keys.

Android

Chrome

✅ (Google Password Manager)

USB: ✅, NFC: ❌
The Nuances of the Apple Ecosystem

As of mid-2025 (iOS 18, iPadOS 18, macOS 15), Apple has implemented support for PRF, but with critical limitations for developers who rely on roaming authenticators like YubiKeys.

What Works: Platform Authenticators. When a user authenticates with a passkey stored in their iCloud Keychain (using Face ID or Touch ID), a web application in Safari 18+ can successfully derive a secret using the PRF extension. This enables non-custodial encryption for users fully within the Apple passkey ecosystem.

What Doesn’t Work: Roaming Authenticators (YubiKeys) on iOS/iPadOS. Apple’s current WebAuthn implementation on iOS and iPadOS does not support passing extension data, including prf`, to or from an external, roaming authenticator. This means that even though a YubiKey fully supports the hmac-secret extension, an application running in Safari on an iPhone or iPad cannot use it. This is a platform-level limitation that blocks high-security use cases on Apple’s mobile devices.

A Call to Action: Help Bring Full PRF Support to Apple Platforms

The WebAuthn PRF extension provides a powerful, standardized way to build end-to-end encrypted services anchored in hardware security. While Apple’s initial support for platform passkeys is a welcome first step, enabling this feature for roaming authenticators like the YubiKey is critical for high-security use cases.

If this feature is important for your applications, we encourage you to let Apple know. By filing a detailed report through their official Feedback Assistant and contributing to public discussions, you provide a direct signal to their engineering teams about the developer community’s needs.

File an Official Report:

  • Tool: Apple Feedback Assistant

  • What to Request: "Full support for WebAuthn extensions, specifically the prf extension, for roaming authenticators (security keys) connected via USB/NFC on iOS, iPadOS, and macOS."

  • Your Business Case: Explain that your application relies on hardware-backed keys like the YubiKey for the highest assurance and that the lack of this feature is a blocker for your most secure features on iOS.

Track Public Progress & Add Your Voice:

The more developers who voice their need for this feature, the more likely it is to be prioritized.

Reference Architectures

The open-source wwWallet project (a participant in the FUNKE innovation challenge) and the Yubico Labs Android PRF Sample are excellent references because they both implement the recommended KDF Derivation pattern.

  • The Keystore service in the wwWallet frontend is a model implementation of how to securely receive the PRF result and manage the derived key’s lifecycle using HKDF.

  • The Android PRF Sample is a valuable resource for native mobile developers, demonstrating the end-to-end flow using YubiKit for Android.

Beyond the Browser: hmac-secret in Native & Mobile Apps

The underlying CTAP hmac-secret extension can be accessed directly in your desktop and mobile applications using Yubico’s SDKs.

Caution

When using hmac-secret directly via a native SDK like libfido2, the client is responsible for its own domain separation. Unlike a browser, the SDK sends the salt you provide directly to the authenticator. It does not automatically hash it with a context string like "WebAuthn PRF".

Platform-Specific Considerations
  • iOS/iPadOS: PRF support is currently limited. The WebAuthn prf extension doesn’t work with YubiKeys in Safari, and the underlying hmac-secret function is not yet exposed in our modern YubiKit-Swift SDK. If you need hardware-backed key derivation in your native Swift app, please open a feature request on GitHub to help us prioritize it.

Yubico SDKs

YubiKit for Android, the Yubico .NET SDK, libfido2, and the python-fido2 library all provide the necessary building blocks to set the hmac-secret extension parameter on CTAP2 commands, giving you full control in your native app.

In the final part of our series, we’ll go under the hood to explore the cryptography of the CTAP2 protocol itself.