---
title: "Add Passkeys to Password Auth"
url: "https://effect-auth.itsbroly.com/recipes/add-passkeys-to-password-auth/"
description: "Add authenticated passkey enrollment and passwordless sign-in to an existing session app."
---



Keep password sign-in as the recovery path, then let signed-in users add a phishing-resistant passkey. This recipe assumes your app already has the password flow and cookie-backed sessions from [Password authentication](/authentication/password/). For custom routes, primitives, and credential management, use the full [Passkeys guide](/authentication/passkeys/).

```text
password + session ──> enroll ──> navigator.credentials.create() ──> saved passkey
                                                                        │
signed out ──────────> sign in ──> navigator.credentials.get() ─────────┘
                                      │
                                      └──> verified assertion + session cookie
```

## Add storage and server layers [#add-storage-and-server-layers]

Run `authStorageMigrations` before deployment. Migration `0005_auth_passkey` adds durable credential records containing the credential ID, public key, signature counter, user ID, timestamps, and authenticator metadata. The SQLite auth storage layers provide `PasskeyCredentialStore`; a custom adapter must implement the same atomic counter-update contract. Never use the memory store in production.

Add the focused preset beside your existing password/session API:

```ts
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { AuthKernelLive } from "@effect-auth/core/AuthKernel";
import {
  AuthHttpApiConfigLive,
  PasskeyHttpApiLive,
  PasskeyHttpConfigLive,
} from "@effect-auth/core/HttpApi";
import {
  PasskeyCredentialManagementLive,
  PasskeyOptionsLive,
  PasskeyVerificationLive,
} from "@effect-auth/core/Passkey";
import { SimpleWebAuthnPasskeyVerifierLive } from "@effect-auth/core/PasskeySimpleWebAuthn";
import { Layer } from "effect";

const PasskeyFeaturesLive = Layer.mergeAll(
  PasskeyOptionsLive,
  PasskeyCredentialManagementLive,
  PasskeyVerificationLive().pipe(
    Layer.provide(SimpleWebAuthnPasskeyVerifierLive)
  )
).pipe(Layer.provideMerge(AppPasskeyCredentialStoreLive));

const AppAuthServicesWithPasskeysLive = Layer.merge(
  AppExistingAuthFeaturesLive,
  PasskeyFeaturesLive
).pipe(
  Layer.provideMerge(AuthKernelLive),
  Layer.provideMerge(AppAuthRuntimeLive)
);

export const PasskeyHttpApiGroupLive = PasskeyHttpApiLive.pipe(
  Layer.provide(AuthRateLimitStandardLive()),
  Layer.provide(
    Layer.mergeAll(AppAuthServicesWithPasskeysLive, AppRateLimitLive)
  ),
  Layer.provide(
    PasskeyHttpConfigLive({
      relyingParty: { id: "app.example.com", name: "Example" },
      expectedOrigin: "https://app.example.com",
      userVerification: "preferred",
      requireUserVerification: true,
      authenticatorSelection: { residentKey: "preferred" },
      attestation: "none",
      timeout: 60_000,
    })
  ),
  Layer.provide(
    AuthHttpApiConfigLive({
      originCheck: { allowedOrigins: ["https://app.example.com"] },
    })
  )
);
```

`AppExistingAuthFeaturesLive` is your password/email feature layer, `AppAuthRuntimeLive` is the existing storage/crypto/config runtime, and `AppPasskeyCredentialStoreLive` provides the durable passkey store. Provide the same HTTP server services used by the password API. The built-in SimpleWebAuthn adapter performs WebAuthn cryptographic verification.

The RP ID is a domain, not a URL: `app.example.com` binds credentials to that host, while `example.com` permits eligible subdomains. `expectedOrigin` is the exact browser origin, including scheme and non-default port. Production ceremonies require HTTPS; `localhost` is the development exception. Keep these values stable or existing passkeys stop working.

## Enroll and sign in [#enroll-and-sign-in]

The registration endpoints require a valid session, so identity comes from the server rather than editable browser fields. Put enrollment behind recent password reauthentication for sensitive accounts.

```ts
import { createPasskeyClient } from "@effect-auth/core/Client";
import {
  createPasskeyCredential,
  getPasskeyCredential,
  isPasskeySupported,
} from "@effect-auth/core/PasskeyBrowser";

const passkeys = createPasskeyClient({
  requestInit: { credentials: "include" },
});

export async function enrollPasskey() {
  if (!isPasskeySupported()) throw new Error("Passkeys are unavailable");
  const started = await passkeys.registration.start();
  const credential = await createPasskeyCredential(started.publicKey);
  return passkeys.registration.finish({
    challengeId: started.challengeId,
    credential,
  });
}

export async function signInWithPasskey() {
  const started = await passkeys.authentication.start({});
  const credential = await getPasskeyCredential(started.publicKey);
  return passkeys.authentication.finish({
    challengeId: started.challengeId,
    credential,
  });
}
```

The browser helpers perform `navigator.credentials.create()`/`get()` and base64url serialization. The empty authentication input enables username-less discoverable sign-in. For identifier-first sign-in, resolve the user server-side and pass the same `{ userId }` to both start and finish.

Successful authentication verifies the one-time challenge, origin, RP ID, signature, and user verification; it then updates `signCount` and `lastUsedAt` before issuing the normal session cookie. Treat a counter failure as suspicious rather than silently resetting it.

## Recovery and release checklist [#recovery-and-release-checklist]

Keep password reset and verified account recovery available; do not let a sole lost device permanently lock an account. Let authenticated users list and revoke passkeys, encourage two credentials, notify on enrollment/revocation, and require step-up before destructive credential changes.

1. Test unsupported browsers, user cancellation, timeout, duplicate enrollment, revoked credentials, and expired or replayed challenges.
2. Test discoverable sign-in, session-cookie creation, exact origin/RP rejection, and password fallback.
3. Run concurrent authentication against the production database and verify the counter update cannot race backward.
4. Use HTTPS, secure cookies, origin checks, rate limits, audit events, encrypted backups, and log neither credential responses nor public-key records unnecessarily.

