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.
Add storage and server layers
Run the complete ordered SQLite/D1 migration stream through 0039_auth_passkey_credential_hardening, or the complete ordered PostgreSQL stream through 0020_auth_passkey_credential_hardening, before deployment. Do not skip intervening entries, including the passkey credential-name migration. Inspect the passkey quarantine after upgrading. Use the focused direct Drizzle SQLite, D1, or PostgreSQL store in production; each preserves atomic insertWithinLimit and sign-counter CAS. 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 {
AuthKernelFromPrimitivesLayer,
AuthKernelPrimitivesLayer,
} from "@effect-auth/core/AuthKernel";
import * as DrizzleD1PasskeyCredentialStore from "@effect-auth/core/DrizzleD1PasskeyCredentialStore";
import {
AuthHttpApiConfigLive,
PasskeyHttpApiLive,
StrongFactorRemovalPolicyChoice,
strongFactorRemovalPolicyCapabilityLayerNoDeps,
} from "@effect-auth/core/HttpApi";
import {
HttpBotVerifierCapability,
HttpLoginRiskEnricherCapability,
HttpTrustedDeviceCookieCapability,
layerNoDeps as httpAuthenticationCapabilitiesLayerNoDeps,
} from "@effect-auth/core/HttpApi/HttpAuthenticationCapabilities";
import {
PasskeyCredentialManagementLive,
PasskeyOptionsLive,
PasskeyVerificationLive,
} from "@effect-auth/core/Passkey";
import { SimpleWebAuthnPasskeyVerifier } from "@effect-auth/core/PasskeySimpleWebAuthn";
import {
SecurePasskeyConfigLive,
SecurePasskeyPresetLive,
} from "@effect-auth/core/PasskeySecure";
import * as Layer from "effect/Layer";
const AppPasskeyCredentialStoreLive = DrizzleD1PasskeyCredentialStore.layer();
const PasskeyFeaturesLive = Layer.mergeAll(
PasskeyOptionsLive,
PasskeyCredentialManagementLive,
PasskeyVerificationLive().pipe(
Layer.provide(SimpleWebAuthnPasskeyVerifier.layer())
)
).pipe(Layer.provideMerge(AppPasskeyCredentialStoreLive));
const AuthKernelLayer = AuthKernelFromPrimitivesLayer.pipe(
Layer.provideMerge(AuthKernelPrimitivesLayer)
);
const AppAuthServicesWithPasskeysLive = Layer.merge(
AppExistingAuthFeaturesLive,
PasskeyFeaturesLive
).pipe(
Layer.provideMerge(AuthKernelLayer),
Layer.provideMerge(AppAuthRuntimeLive)
);
const HttpAuthenticationCapabilitiesLive =
httpAuthenticationCapabilitiesLayerNoDeps({
requestMetadata: {
ipSource: { _tag: "CloudflareConnectingIp" },
},
botVerifier: HttpBotVerifierCapability.Disabled(),
trustedDeviceCookie: HttpTrustedDeviceCookieCapability.Disabled(),
loginRiskEnricher: HttpLoginRiskEnricherCapability.Disabled(),
});
export const PasskeyHttpApiGroupLive = PasskeyHttpApiLive.pipe(
Layer.provide(AuthRateLimitStandardLive()),
Layer.provide(HttpAuthenticationCapabilitiesLive),
Layer.provide(
strongFactorRemovalPolicyCapabilityLayerNoDeps(
StrongFactorRemovalPolicyChoice.Disabled()
)
),
Layer.provide(
Layer.mergeAll(AppAuthServicesWithPasskeysLive, AppRateLimitLive)
),
Layer.provide(
SecurePasskeyPresetLive.pipe(
Layer.provide(
SecurePasskeyConfigLive({
relyingParty: { id: "app.example.com", name: "Example" },
expectedOrigins: ["https://app.example.com"],
attestation: "none",
timeout: 60_000,
})
)
)
),
Layer.provide(
AuthHttpApiConfigLive({
originPolicy: {
mode: "secure",
origins: ["https://app.example.com"],
},
})
)
);AppExistingAuthFeaturesLive is your password/email feature layer and AppAuthRuntimeLive is the existing storage/crypto/config runtime, including DrizzleD1Database. The focused passkey Layer derives its store from that database service. SQLite and PostgreSQL deployments use the corresponding focused constructors or PostgreSQL Layer. HttpAuthenticationCapabilitiesLive supplies the immutable metadata used by maintained passkey limits and risk context; AuthHttpApiConfigLive separately supplies origin middleware policy. Replace the explicit disabled removal choice with Enabled({ service: removalPolicy }) when the product must prevent removing the last inventoried strong factor. Provide the same HTTP server services used by the password API. The built-in SimpleWebAuthn adapter performs WebAuthn cryptographic verification.
Those App* Layers are app-owned composition boundaries. On Cloudflare, keep
the feature in the existing Alchemy v2 stack:
Derive both policies from one exact canonical public URL. Production uses HTTPS secure mode and must not default to a loopback URL. loopback-development must be selected explicitly and permits only localhost, IPv4 127/8, or ::1. RP IDs are canonical lowercase IDNA hostnames; secure mode rejects IPs, localhost, and single-label names. The built-in check intentionally does not ship a full public-suffix list, so applications with registrable-domain requirements must apply their own PSL policy. Do not claim full public-suffix defense from the single-label rejection.
The maintained passkey boundary rejects client data with crossOrigin: true and rejects any topOrigin. Cross-origin WebAuthn is unsupported until the application has an explicit persisted top-origin allowlist policy.
The registration challenge binds RP ID, expected origins, UV, attestation, and offered algorithms. Finish uses that snapshot rather than current configuration, preventing configuration drift from weakening an in-flight ceremony. Secure preset attestation is absent/none; applications using direct, indirect, or enterprise attestation must own and review the low-level attestation policy.
| Resource | Passkey use |
|---|---|
Cloudflare.D1.Database | Apply the complete ordered SQLite/D1 stream through 0039; provide DrizzleD1Database to the focused passkey store Layer. |
Cloudflare.Worker DB binding | Persists credentials, one-time challenges, counters, and sessions in the same database. |
| Durable Object rate limiter | Limits registration and authentication ceremonies. |
| Setting | Example | Rule |
|---|---|---|
| RP ID | app.example.com | Domain, not URL; binds credentials to that host. example.com also permits eligible subdomains. |
| Expected origin | https://app.example.com | Exact browser origin, including scheme and any non-default port. |
| Transport | HTTPS | Required in production; localhost is the development exception. |
Keep RP ID and expected origin stable or existing passkeys stop working.
Enroll and sign in
The registration endpoints require a valid session and fresh step-up, so identity comes from the server rather than editable browser fields. Registration accepts an optional bounded credential name and enforces the default limit of 10 active credentials atomically.
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({ name: "My laptop" });
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 verification checks the one-time challenge, origin, RP ID, signature, and user verification, then updates signCount and lastUsedAt. The finish call returns the normal primary-auth result: commit a session cookie only for type: "authenticated" and handle MFA, approval, verification, or enrollment continuations. 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.