effect-auth

Add Passkeys to Password Auth

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. For custom routes, primitives, and credential management, use the full Passkeys guide.

password + session ──> enroll ──> navigator.credentials.create() ──> saved passkey

signed out ──────────> sign in ──> navigator.credentials.get() ─────────┘

                                      └──> verified assertion + session cookie

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:

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

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.

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

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.

On this page