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 profile 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 uses 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 key record that serializes to JSON.
  4. Server storage. Your server stores the wrapped record. 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 off by default. 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 records 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 records 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 a shared profile

A profile defines your application’s key-derivation domain. It has a version, which is always 1, and three values. 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.
Each wrapped record embeds the profile it was created with, and a key manager skips records whose profile differs from its own. For a new application, choose domain-specific values and keep them stable. If you change any profile value after enrollment, existing wrapped records can no longer be recovered with the new profile. 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. Use the entitlement’s prefix rather than assuming it matches your Apple team ID. Serve the file as application/json over HTTPS without a redirect.

Create the key manager

A key manager is bound to one profile. Configure the same profile in each client.
The manager caches PRF output only when you pass storage. Cached PRF output is raw key material: createInsecureBrowserLocalStoragePasskeyKeyStorage(namespace) stores it as plaintext in localStorage, where a same-origin script or XSS attacker can read it and derive the KEK. Leave storage unset unless you accept that risk, or implement PasskeyKeyStorage with storage appropriate for your threat model.
You can also check support before offering the feature. capability returns supported, unsupported, or unknown. The check is advisory, so attempt the ceremony unless the result is unsupported:

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. createAndWrapKey creates the passkey and wraps the CEK in one call. Use the same stable opaque user ID in every client, encoded as the same bytes. 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.
createAndWrapKey throws a PasskeyKeyError with the cancelled category when the user cancels the ceremony.
Persist only the wrapped record. Never send the raw CEK or PRF output to your server.

Store the wrapped record

Store one or more wrapped records for each account. encodeWrappedKeyRecord produces the same JSON in both SDKs (a string in JavaScript, Data in Swift), and decodeWrappedKeyRecord reads it back:
Your server should:
  • Authenticate every read and write request.
  • Associate each record with the authenticated account.
  • Store each record exactly as encoded, without adding or transforming fields.
  • Allow multiple records so users can enroll more than one passkey.
  • Treat record deletion and replacement as security-sensitive operations.
The wrapped record is ciphertext, but its credential ID is account metadata. Apply the same access controls you use for other private account data. The record alone cannot recover the CEK, so your backend can store it without gaining access to the customer’s raw key.

Recover the CEK

Recovery is the returning-user flow: load the account’s wrapped records, then let the matching passkey unwrap the CEK. The user approves one passkey prompt; whichever credential they authenticate with selects the matching record. Try the local cache first if your application enables it. A cached recovery reuses the stored PRF output and skips the passkey prompt entirely.
recoverKeyFromCache returns null when no matching cached PRF output exists. recoverKey skips records whose profile differs from the manager’s, and throws a PasskeyKeyError when the user cancels or no usable record remains.

Add another passkey

Each wrapped record 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 generate a new key when adding another passkey, or the account will have records that recover different keys. First recover the current CEK, then pass that same CEK to createAndWrapKey:

Handle passkey failures

Every failure is a PasskeyKeyError with one category: unsupported, cancelled, timeout, operation_in_progress, invalid_input, or operation_failed. Branch on the category, not on 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 records 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 record.
  2. Load the same record on iOS and recover the CEK with the synced passkey.
  3. Enroll on iOS and recover the CEK from the resulting record on the web.
  4. Confirm both clients recover identical 32-byte CEK values.
  5. Confirm cancellation does not create or delete a server record.
  6. Confirm a tampered IV or ciphertext fails authentication.
  7. Confirm signing out clears the local PRF cache.
If cross-platform recovery fails, first compare the RP ID, PRF salt bytes, HKDF info bytes, user ID bytes, and credential ID on both clients.

Security checklist

  • Keep the PRF output and unwrapped CEK on the client. Send only ciphertext and wrapped records to your server.
  • Use authenticated, authorized endpoints for wrapped records.
  • Use HTTPS and a stable RP ID.
  • Never change profile values for existing data.
  • Leave the PRF cache disabled unless your threat model allows it, and prefer platform-protected storage over plaintext browser storage.
  • 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-record 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.