> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tinfoil.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Add customer-managed keys with passkeys

> Protect user encryption keys with passkeys so your server only ever stores encrypted key material.

> [card] [View on GitHub](https://github.com/tinfoilsh/tinfoil-passkey-kit)
>
> 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.

> [!NOTE]
>
> [Tinfoil Chat](https://chat.tinfoil.sh) 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

> [tab] JavaScript
>
> Install the published npm package:
>
> ```bash theme={"dark"}
> npm install @tinfoilsh/passkey-kit
> ```

> [tab] iOS
>
> In Xcode, select **File > Add Package Dependencies** and enter:
>
> ```text theme={"dark"}
> https://github.com/tinfoilsh/tinfoil-passkey-kit
> ```
>
> Select the `TinfoilPasskeyKit` product and add it to your app target.
>
> For a `Package.swift` consumer, add the package and product directly:
>
> ```swift theme={"dark"}
> dependencies: [
>     .package(
>         url: "https://github.com/tinfoilsh/tinfoil-passkey-kit",
>         from: "0.2.3"
>     )
> ],
> targets: [
>     .target(
>         name: "YourApp",
>         dependencies: [
>             .product(
>                 name: "TinfoilPasskeyKit",
>                 package: "tinfoil-passkey-kit"
>             )
>         ]
>     )
> ]
> ```

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

| Setting | Value |
| - | - |
| Relying-party ID | `example.com` |
| PRF salt input | `example-key-encryption-v1` |
| HKDF info | `example-passkey-kek-v1` |

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

```text theme={"dark"}
webcredentials:example.com
```

**3. Host the Apple association file**

Serve this file from
`https://example.com/.well-known/apple-app-site-association`:

```json theme={"dark"}
{
  "webcredentials": {
    "apps": ["APP_ID_PREFIX.com.example.app"]
  }
}
```

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.

> [tab] JavaScript
>
> ```typescript theme={"dark"}
> import { createPasskeyKeyManager } from "@tinfoilsh/passkey-kit";
>
> const encoder = new TextEncoder();
>
> export const passkeyManager = createPasskeyKeyManager({
>   profile: {
>     version: 1,
>     relyingPartyId: "example.com",
>     prfSalt: encoder.encode("example-key-encryption-v1"),
>     hkdfInfo: encoder.encode("example-passkey-kek-v1"),
>   },
>   relyingPartyName: "Example App",
> });
> ```
>
> 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.

> [tab] iOS
>
> ```swift theme={"dark"}
> import Foundation
> import TinfoilPasskeyKit
>
> @MainActor
> func makePasskeyManager(
>     presentationAnchorProvider: any PasskeyPresentationAnchorProviding
> ) throws -> PasskeyKeyManager {
>     let storage = KeychainPasskeyKeyStorage(
>         service: "example.com",
>         account: "com.example.passkey-prf",
>         localCredentialIdKey: "com.example.local-passkey-id"
>     )
>
>     return try PasskeyKeyManager(
>         profile: PasskeyKeyProfile(
>             version: 1,
>             relyingPartyId: "example.com",
>             prfSalt: Data("example-key-encryption-v1".utf8),
>             hkdfInfo: Data("example-passkey-kek-v1".utf8)
>         ),
>         relyingPartyName: "Example App",
>         storage: storage,
>         presentationAnchorProvider: presentationAnchorProvider
>     )
> }
> ```
>
> The presentation anchor provider supplies the window that presents the
> passkey prompt. `KeychainPasskeyKeyStorage` protects cached PRF output with
> `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. Omit `storage` if every
> unlock should require a new passkey ceremony.

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

```typescript theme={"dark"}
const support = await passkeyManager.capability({ operation: "enroll" });
if (support !== "unsupported") {
  showPasskeySetupOption();
}
```

## 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.

> [tab] JavaScript
>
> ```typescript theme={"dark"}
> import { encodeWrappedKeyRecord } from "@tinfoilsh/passkey-kit";
> import { passkeyManager } from "./passkey-kit";
>
> const cek = crypto.getRandomValues(new Uint8Array(32));
> const created = await passkeyManager.createAndWrapKey({
>   user: {
>     id: new TextEncoder().encode(user.id),
>     name: user.email,
>     displayName: user.displayName,
>   },
>   key: cek,
> });
>
> await api.saveWrappedKeyRecord(encodeWrappedKeyRecord(created.wrappedKey));
> useCek(cek);
> ```
>
> `createAndWrapKey` throws a `PasskeyKeyError` with the `cancelled`
> category when the user cancels the ceremony.

> [tab] iOS
>
> ```swift theme={"dark"}
> import CryptoKit
> import Foundation
> import TinfoilPasskeyKit
>
> @MainActor
> func enroll(user: AppUser, manager: PasskeyKeyManager) async throws {
>     let cek = SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }
>     let created = try await manager.createAndWrapKey(
>         user: PasskeyUser(
>             id: Data(user.id.utf8),
>             name: user.email,
>             displayName: user.displayName
>         ),
>         key: cek
>     )
>
>     try await api.saveWrappedKeyRecord(encodeWrappedKeyRecord(created.wrappedKey))
>     useCEK(cek)
> }
> ```
>
> Swift reports cancellation as `PasskeyKeyError.cancelled`.

> [!WARNING]
>
> 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:

```json theme={"dark"}
{
  "version": 1,
  "profile": {
    "version": 1,
    "relyingPartyId": "example.com",
    "prfSalt": "base64url-prf-salt",
    "hkdfInfo": "base64url-hkdf-info"
  },
  "credentialId": "base64url-credential-id",
  "kekIvHex": "12-byte-iv-as-lowercase-hex",
  "wrappedKeyHex": "ciphertext-and-16-byte-gcm-tag-as-lowercase-hex"
}
```

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.

> [tab] JavaScript
>
> ```typescript theme={"dark"}
> import { decodeWrappedKeyRecord } from "@tinfoilsh/passkey-kit";
> import { passkeyManager } from "./passkey-kit";
>
> const records: string[] = await api.listWrappedKeyRecords();
> const wrappedKeys = records.map(decodeWrappedKeyRecord);
>
> const recovered =
>   (await passkeyManager.recoverKeyFromCache({ wrappedKeys })) ??
>   (await passkeyManager.recoverKey({ wrappedKeys }));
>
> useCek(recovered.key);
> ```
>
> `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.

> [tab] iOS
>
> ```swift theme={"dark"}
> import Foundation
> import TinfoilPasskeyKit
>
> @MainActor
> func recover(manager: PasskeyKeyManager) async throws {
>     let records: [Data] = try await api.listWrappedKeyRecords()
>     let wrappedKeys = try records.map { try decodeWrappedKeyRecord($0) }
>
>     if let cached = try manager.recoverKeyFromCache(wrappedKeys: wrappedKeys) {
>         useCEK(cached.key)
>         return
>     }
>
>     let recovered = try await manager.recoverKey(wrappedKeys: wrappedKeys)
>     useCEK(recovered.key)
> }
> ```
>
> Use `.immediatelyAvailable` when you want to check local credentials
> without presenting the full cross-device passkey interface. Browsers do
> not support this mode:
>
> ```swift theme={"dark"}
> let recovered = try await manager.recoverKey(
>     wrappedKeys: wrappedKeys,
>     interaction: .immediatelyAvailable
> )
> ```

## 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`:

> [tab] JavaScript
>
> ```typescript theme={"dark"}
> import { encodeWrappedKeyRecord } from "@tinfoilsh/passkey-kit";
> import { passkeyManager } from "./passkey-kit";
>
> async function addPasskey(existingCek: Uint8Array) {
>   const created = await passkeyManager.createAndWrapKey({
>     user: {
>       id: new TextEncoder().encode(user.id),
>       name: user.email,
>       displayName: user.displayName,
>     },
>     key: existingCek,
>   });
>
>   await api.saveWrappedKeyRecord(encodeWrappedKeyRecord(created.wrappedKey));
> }
> ```

> [tab] iOS
>
> ```swift theme={"dark"}
> import Foundation
> import TinfoilPasskeyKit
>
> @MainActor
> func addPasskey(
>     existingCEK: Data,
>     user: AppUser,
>     manager: PasskeyKeyManager
> ) async throws {
>     let created = try await manager.createAndWrapKey(
>         user: PasskeyUser(
>             id: Data(user.id.utf8),
>             name: user.email,
>             displayName: user.displayName
>         ),
>         key: existingCEK
>     )
>
>     try await api.saveWrappedKeyRecord(encodeWrappedKeyRecord(created.wrappedKey))
> }
> ```

## 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.

> [tab] JavaScript
>
> ```typescript theme={"dark"}
> import { PasskeyKeyError } from "@tinfoilsh/passkey-kit";
>
> try {
>   const recovered = await passkeyManager.recoverKey({ wrappedKeys });
>   useCek(recovered.key);
> } catch (error) {
>   if (!(error instanceof PasskeyKeyError)) throw error;
>   switch (error.category) {
>     case "cancelled":
>       break;
>     case "unsupported":
>       showUnsupportedAuthenticatorMessage();
>       break;
>     case "timeout":
>       showProviderTimeoutMessage();
>       break;
>     default:
>       throw error;
>   }
> }
> ```

> [tab] iOS
>
> ```swift theme={"dark"}
> do {
>     let recovered = try await manager.recoverKey(wrappedKeys: wrappedKeys)
>     useCEK(recovered.key)
> } catch PasskeyKeyError.cancelled {
>     return
> } catch PasskeyKeyError.unsupported {
>     showUnsupportedAuthenticatorMessage()
> } catch PasskeyKeyError.timeout {
>     showProviderTimeoutMessage()
> } catch {
>     throw error
> }
> ```

## Clear local state

Clear cached PRF output when the user signs out or removes local access.

```typescript JavaScript theme={"dark"}
passkeyManager.clearLocalState();
```

```swift iOS theme={"dark"}
manager.clearLocalState()
```

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

> [card] [Passkey Kit source](https://github.com/tinfoilsh/tinfoil-passkey-kit)
>
> Review the JavaScript and Swift implementations, tests, and protocol
> constants.

> [card] [JavaScript package](https://www.npmjs.com/package/@tinfoilsh/passkey-kit)
>
> View published versions and npm installation details.

> [card] [WebAuthn PRF extension](https://w3c.github.io/webauthn/#prf-extension)
>
> Read the specification behind the passkey-derived secret used for key
> protection.

> [card] [Tinfoil Chat](https://chat.tinfoil.sh)
>
> See customer-managed keys in production across web and iOS clients.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.