Skip to main content

View on GitHub

tinfoilsh/tinfoil-passkey-kit
Passkey Kit is an open-source SDK for JavaScript and Swift that lets an application encrypt data with a key only the user can recover. The user’s passkey — the same credential behind Face ID, Touch ID, or a browser passkey prompt — protects the encryption key. Your server stores only an encrypted copy of the key, so neither you nor anyone with access to your infrastructure can read the user’s data. This pattern is often called customer-managed keys or user-held encryption. It is the foundation for end-to-end encrypted features such as private notes, messages, health records, or files, where the product promise is “we cannot read your data.” The JavaScript and Swift packages share the same wire format: a key protected on the web unlocks in an iOS app and vice versa, as long as both clients use the same configuration and the passkey syncs between devices through a passkey provider such as iCloud Keychain.
Tinfoil Chat uses Passkey Kit to protect its end-to-end chat encryption keys across its web and iOS clients.

How it works

Passkey Kit builds on one property of modern passkeys: the WebAuthn PRF (pseudo-random function) extension. When the user approves a passkey prompt, the authenticator can return a 32-byte secret that is stable for that passkey but never leaves the ceremony unapproved. That secret becomes the root of the key hierarchy:
  1. Passkey ceremony. The user approves a passkey prompt (biometric or device PIN). The authenticator returns the PRF output, a deterministic 32-byte secret tied to that passkey.
  2. Key derivation. Passkey Kit derives a key-encryption key (KEK) from the PRF output with HKDF-SHA-256. The KEK never leaves the client.
  3. Key wrapping. The KEK encrypts your application’s 32-byte content-encryption key (CEK) with AES-256-GCM. The result is a wrapped bundle of plain JSON-safe strings.
  4. Server storage. Your server stores the wrapped bundle. It is ciphertext: without the passkey, it cannot be turned back into the CEK.
  5. Recovery. On any device with the passkey, the user approves a prompt, the same PRF output comes back, the same KEK is derived, and the CEK is unwrapped locally.
Your application uses the CEK to encrypt and decrypt its own data. Passkey Kit only manages how the CEK is protected and recovered; what you encrypt with it is up to you. Because the PRF output is deterministic, Passkey Kit can also cache it on the device so repeat unlocks skip the passkey prompt. Caching is optional and configurable. Passkey Kit does not replace your account system, authenticate API requests, encrypt your application records, or store wrapped keys for you. It handles the passkey ceremonies and the key protection; your app provides identity, storage, and the encryption of your actual data.

What you will build

This guide covers how to:
  1. Install and configure Passkey Kit in JavaScript and Swift.
  2. Configure a shared WebAuthn relying party.
  3. Create a passkey and protect a fresh CEK with it.
  4. Store only wrapped key bundles on your server.
  5. Recover the CEK on the user’s device.
  6. Let users enroll additional passkeys for the same key.
  7. Use the same wrapped-key format across web and iOS clients.

Prerequisites

  • A domain you control, such as example.com
  • An authenticated backend that can store wrapped key bundles per user
  • A stable opaque user ID of at most 64 UTF-8 bytes
  • For web: a browser with WebAuthn PRF support (recent Chrome, Safari, and Edge on platforms with a screen lock)
  • For iOS: iOS 18 or later
WebAuthn requires a secure context. Use HTTPS in production. Browsers allow localhost for local development, but native passkeys require a configured associated domain.

Install the package

Install the published npm package:

Choose shared protocol settings

Three values define your application’s key-derivation domain. Every client must use identical bytes for all three, or keys wrapped on one client cannot be unwrapped on another:
  • Relying-party ID (RP ID): the domain that owns the passkeys, usually your registrable domain. Passkeys created for one RP ID are invisible to other RP IDs.
  • PRF salt input: an application-chosen string mixed into the PRF evaluation. Different salts produce unrelated PRF outputs from the same passkey.
  • HKDF info: a purpose-binding string for the KEK derivation. Different info strings produce unrelated KEKs from the same PRF output.
For a new application, choose domain-specific values and keep them stable. If you change either protocol string after enrollment, existing wrapped keys can no longer be recovered with the newly derived key. This guide uses:

Configure the relying party

1

Use the same RP ID on the web

A web page can use example.com as its RP ID when its origin is example.com or a subdomain such as app.example.com.
2

Add the iOS associated domain

Add the Associated Domains capability to the app target, then include the relying-party domain:
3

Host the Apple association file

Serve this file from https://example.com/.well-known/apple-app-site-association:
Replace APP_ID_PREFIX with the prefix from the built app’s application-identifier entitlement, and replace com.example.app with your bundle identifier. The prefix is often your Apple team ID, but it can differ for older or transferred apps. Serve the file as application/json over HTTPS without a redirect.

Create the client

Configure the same protocol strings in each client.
The default storage adapter caches reusable raw PRF output as plaintext in localStorage. A same-origin script or XSS attacker that reads it can derive the KEK. Pass storage: null unless you accept that risk, or provide a StorageAdapter backed by storage appropriate for your threat model.
You can also check support before offering the feature. isPrfSupported() returns an optimistic capability check for the current browser or device:

Enroll and wrap a CEK

Enrollment is the first-time setup for a user: generate a fresh CEK, create a PRF-capable passkey, and wrap the CEK under the passkey-derived KEK. enroll runs all three steps in one call. Use the same stable opaque user ID in every client. Use an email address only as the user-facing account name, not as the stable ID. The examples use application-provided user, api, AppUser, useCek, and useCEK placeholders. Connect them to your account, networking, and encryption layers.
enroll returns null when the user cancels the ceremony.
Persist only the wrapped bundle. Never send the raw CEK or PRF output to your server.

Store the wrapped bundle

Store one or more wrapped bundles for each account. Both SDKs serialize the same fields:
Your server should:
  • Authenticate every read and write request.
  • Associate each bundle with the authenticated account.
  • Preserve all three fields without transforming their encoding.
  • Allow multiple bundles so users can enroll more than one passkey.
  • Treat bundle deletion and replacement as security-sensitive operations.
The wrapped bundle is ciphertext, but its credential ID is account metadata. Apply the same access controls you use for other private account data. The bundle alone cannot recover the CEK, so your backend can store it without gaining access to the customer’s raw key.

Unlock the CEK

Unlocking is the returning-user flow: load the account’s wrapped bundles, then let the matching passkey unwrap the CEK. The user approves one passkey prompt; whichever credential they authenticate with selects the matching bundle. Try the local cache first if your application enables it. A cached unlock reuses the stored PRF output and skips the passkey prompt entirely.
unlock returns null when the user cancels or no usable credential is available.

Add another passkey

Each wrapped bundle ties the CEK to one passkey. To let a user unlock from more devices or survive losing a passkey, enroll additional passkeys that each wrap the same CEK. Every passkey for an account must wrap the same existing CEK. Do not call generateCek or generateCEK when adding another passkey, or the account will have bundles that recover different keys. First unlock the current CEK, then create the additional passkey and wrap that same CEK:

Handle passkey failures

Classify errors by type or enum case. Do not branch on localized messages.

Clear local state

Clear cached PRF output when the user signs out or removes local access.
Clearing local state does not delete the passkey from the platform credential manager or remove wrapped bundles from your server.

Test cross-platform recovery

Use a staging account and complete these checks before production:
  1. Enroll on the web and store the wrapped bundle.
  2. Load the same bundle on iOS and unlock it with the synced passkey.
  3. Enroll on iOS and unlock the resulting bundle on the web.
  4. Confirm both clients recover identical 32-byte CEK values.
  5. Confirm cancellation does not create or delete a server bundle.
  6. Confirm a tampered IV or ciphertext fails authentication.
  7. Confirm signing out clears the local PRF cache.
If cross-platform unlock fails, first compare the RP ID, PRF salt bytes, HKDF info bytes, credential ID, and bundle encodings on both clients.

Security checklist

  • Keep the PRF output and unwrapped CEK on the client. Send only ciphertext and wrapped bundles to your server.
  • Use authenticated, authorized endpoints for wrapped bundles.
  • Use HTTPS and a stable RP ID.
  • Never change protocol strings for existing data.
  • Prefer platform-protected storage over plaintext browser storage when your threat model requires at-rest protection, or disable the PRF cache.
  • Support multiple passkeys and a deliberate recovery flow. A user with a single passkey and no other recovery path loses their data if the passkey is lost.
  • Treat passkey removal and wrapped-bundle deletion as sensitive operations.
  • Remember the boundary: this design keeps your server out of the key path, but the client code that handles the unwrapped CEK is still trusted. Protect your software supply chain and release process.

Next steps

Passkey Kit source

Review the JavaScript and Swift implementations, tests, and protocol constants.

JavaScript package

View published versions and npm installation details.

WebAuthn PRF extension

Read the specification behind the passkey-derived secret used for key protection.

Tinfoil Chat

See customer-managed keys in production across web and iOS clients.