> ## 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 title="View on GitHub" icon="github" href="https://github.com/tinfoilsh/tinfoil-passkey-kit" horizontal>
  tinfoilsh/tinfoil-passkey-kit
</Card>

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.

<Info>
  [Tinfoil Chat](https://chat.tinfoil.sh) uses Passkey Kit to protect its
  end-to-end chat encryption keys across its web and iOS clients.
</Info>

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

<Tabs>
  <Tab title="JavaScript">
    Install the published npm package:

    ```bash theme={"dark"}
    npm install @tinfoilsh/passkey-kit
    ```
  </Tab>

  <Tab title="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.1.0"
        )
    ],
    targets: [
        .target(
            name: "YourApp",
            dependencies: [
                .product(
                    name: "TinfoilPasskeyKit",
                    package: "tinfoil-passkey-kit"
                )
            ]
        )
    ]
    ```
  </Tab>
</Tabs>

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

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

## Configure the relying party

<Steps>
  <Step title="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`.
  </Step>

  <Step title="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
    ```
  </Step>

  <Step title="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. 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.
  </Step>
</Steps>

## Create the client

Configure the same protocol strings in each client.

<Tabs>
  <Tab title="JavaScript">
    ```typescript theme={"dark"}
    import { createPasskeyKit } from "@tinfoilsh/passkey-kit";

    export const passkeyKit = createPasskeyKit({
      rpId: "example.com",
      rpName: "Example App",
      prfSaltInput: "example-key-encryption-v1",
      hkdfInfo: "example-passkey-kek-v1",
    });
    ```

    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.
  </Tab>

  <Tab title="iOS">
    ```swift theme={"dark"}
    import Foundation
    import TinfoilPasskeyKit

    @MainActor
    func makePasskeyKit() -> PasskeyKit {
        let store = KeychainPasskeyStateStore(
            service: "example.com",
            account: "com.example.passkey-prf",
            localCredentialIdKey: "com.example.local-passkey-id"
        )

        return PasskeyKit(
            configuration: PasskeyKitConfiguration(
                rpId: "example.com",
                rpName: "Example App",
                prfSalt: Data("example-key-encryption-v1".utf8),
                hkdfInfo: Data("example-passkey-kek-v1".utf8),
                stateStore: store
            )
        )
    }
    ```

    `KeychainPasskeyStateStore` protects cached PRF output with
    `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. Pass `stateStore: nil` if
    every unlock should require a new passkey ceremony.
  </Tab>
</Tabs>

You can also check support before offering the feature. `isPrfSupported()`
returns an optimistic capability check for the current browser or device:

```typescript theme={"dark"}
if (await passkeyKit.isPrfSupported()) {
  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. `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.

<Tabs>
  <Tab title="JavaScript">
    ```typescript theme={"dark"}
    import { generateCek } from "@tinfoilsh/passkey-kit";
    import { passkeyKit } from "./passkey-kit";

    const cek = generateCek();
    const enrollment = await passkeyKit.enroll({
      user: {
        id: user.id,
        name: user.email,
        displayName: user.displayName,
      },
      cek,
    });

    if (enrollment) {
      await api.saveWrappedCek(enrollment.wrappedCek);
      useCek(cek);
    }
    ```

    `enroll` returns `null` when the user cancels the ceremony.
  </Tab>

  <Tab title="iOS">
    ```swift theme={"dark"}
    import TinfoilPasskeyKit

    @MainActor
    func enroll(user: AppUser, kit: PasskeyKit) async throws {
        let cek = try PasskeyCrypto.generateCEK()
        let enrollment = try await kit.enroll(
            user: PasskeyUser(
                id: user.id,
                name: user.email,
                displayName: user.displayName
            ),
            cek: cek
        )

        try await api.saveWrappedCEK(enrollment.wrappedCEK)
        useCEK(cek)
    }
    ```

    Swift reports cancellation as `PasskeyKitError.userCancelled`.
  </Tab>
</Tabs>

<Warning>
  Persist only the wrapped bundle. Never send the raw CEK or PRF output to your
  server.
</Warning>

## Store the wrapped bundle

Store one or more wrapped bundles for each account. Both SDKs serialize the
same fields:

```json theme={"dark"}
{
  "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 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.

<Tabs>
  <Tab title="JavaScript">
    ```typescript theme={"dark"}
    import type { WrappedCek } from "@tinfoilsh/passkey-kit";
    import { passkeyKit } from "./passkey-kit";

    const bundles: WrappedCek[] = await api.listWrappedCeks();
    const cached = await passkeyKit.unlockWithCachedPrf(bundles);
    const unlocked = cached ?? (await passkeyKit.unlock(bundles));

    if (unlocked) {
      useCek(unlocked.cek);
    }
    ```

    `unlock` returns `null` when the user cancels or no usable credential is
    available.
  </Tab>

  <Tab title="iOS">
    ```swift theme={"dark"}
    import TinfoilPasskeyKit

    @MainActor
    func unlock(kit: PasskeyKit) async throws {
        let bundles: [WrappedCEK] = try await api.listWrappedCEKs()

        if let cached = kit.unlockWithCachedPRF(bundles) {
            useCEK(cached.cek)
            return
        }

        let unlocked = try await kit.unlock(bundles)
        useCEK(unlocked.cek)
    }
    ```

    Use `.immediatelyAvailable` when you want to check local credentials
    without presenting the full cross-device passkey interface:

    ```swift theme={"dark"}
    let unlocked = try await kit.unlock(
        bundles,
        mode: .immediatelyAvailable
    )
    ```
  </Tab>
</Tabs>

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

<Tabs>
  <Tab title="JavaScript">
    ```typescript theme={"dark"}
    async function addPasskey(existingCek: Uint8Array) {
      const prfResult = await passkeyKit.createPasskey({
        id: user.id,
        name: user.email,
        displayName: user.displayName,
      });

      if (prfResult) {
        const wrappedCek = await passkeyKit.wrapWithPrfResult(
          prfResult,
          existingCek,
        );
        await api.saveWrappedCek(wrappedCek);
      }
    }
    ```
  </Tab>

  <Tab title="iOS">
    ```swift theme={"dark"}
    import Foundation
    import TinfoilPasskeyKit

    @MainActor
    func addPasskey(existingCEK: Data, kit: PasskeyKit) async throws {
        let prfResult = try await kit.createPasskey(
            for: PasskeyUser(
                id: user.id,
                name: user.email,
                displayName: user.displayName
            )
        )
        let wrappedCEK = try kit.wrapWithPRFResult(
            prfResult,
            cek: existingCEK
        )
        try await api.saveWrappedCEK(wrappedCEK)
    }
    ```
  </Tab>
</Tabs>

## Handle passkey failures

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

<Tabs>
  <Tab title="JavaScript">
    ```typescript theme={"dark"}
    import {
      PasskeyTimeoutError,
      PrfNotSupportedError,
    } from "@tinfoilsh/passkey-kit";

    try {
      const unlocked = await passkeyKit.unlock(bundles);
      if (unlocked) useCek(unlocked.cek);
    } catch (error) {
      if (error instanceof PrfNotSupportedError) {
        showUnsupportedAuthenticatorMessage();
      } else if (error instanceof PasskeyTimeoutError) {
        showProviderTimeoutMessage();
      } else {
        throw error;
      }
    }
    ```
  </Tab>

  <Tab title="iOS">
    ```swift theme={"dark"}
    do {
        let unlocked = try await kit.unlock(bundles)
        useCEK(unlocked.cek)
    } catch PasskeyKitError.userCancelled {
        return
    } catch PasskeyKitError.prfNotSupported {
        showUnsupportedAuthenticatorMessage()
    } catch {
        throw error
    }
    ```
  </Tab>
</Tabs>

## Clear local state

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

<CodeGroup>
  ```typescript JavaScript theme={"dark"}
  passkeyKit.clearLocalState();
  ```

  ```swift iOS theme={"dark"}
  kit.clearLocalState()
  ```
</CodeGroup>

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

<CardGroup cols={2}>
  <Card title="Passkey Kit source" icon="github" href="https://github.com/tinfoilsh/tinfoil-passkey-kit">
    Review the JavaScript and Swift implementations, tests, and protocol
    constants.
  </Card>

  <Card title="JavaScript package" icon="npm" href="https://www.npmjs.com/package/@tinfoilsh/passkey-kit">
    View published versions and npm installation details.
  </Card>

  <Card title="WebAuthn PRF extension" icon="book" href="https://w3c.github.io/webauthn/#prf-extension">
    Read the specification behind the passkey-derived secret used for key
    protection.
  </Card>

  <Card title="Tinfoil Chat" icon="message" href="https://chat.tinfoil.sh">
    See customer-managed keys in production across web and iOS clients.
  </Card>
</CardGroup>
