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