Passkeys
Add WebAuthn registration, authentication, and credential management with application-owned HTTP APIs.
Passkeys use WebAuthn public-key credentials for phishing-resistant authentication. The recommended HTTP Operations layer preserves challenge, verification, credential, session, and rate-limit orchestration while your application owns its public API and sensitive-action policy.
HTTP Operations
PasskeyHttpOperations exposes registration, primary authentication, inventory, and revocation workflows. You choose which operations to publish and own their routes, schemas, middleware, and additional guards.
Configure the feature
Compose option generation, WebAuthn verification, credential management, and a durable credential store. The store must be visible while PasskeyOptionsLive is built so it can populate excludeCredentials and user-bound allowCredentials.
import {
AuthKernelFromPrimitivesLayer,
AuthKernelPrimitivesLayer,
} from "@effect-auth/core/AuthKernel";
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { HttpAuthenticationCapabilitiesLive } from "./http-authentication-capabilities.live.js";
import {
PasskeyHttpOperationsLive,
strongFactorRemovalPolicyCapabilityLayerNoDeps,
StrongFactorRemovalPolicyChoice,
} from "@effect-auth/core/HttpApi/Passkey";
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";
import {
AppAuthRuntimeLive,
AppPasskeyCredentialStoreLive,
AppRateLimitLive,
} from "./auth-runtime.live.js";
const PasskeyDomainLive = Layer.mergeAll(
PasskeyOptionsLive,
PasskeyVerificationLive(),
PasskeyCredentialManagementLive
).pipe(
Layer.provideMerge(AppPasskeyCredentialStoreLive),
Layer.provideMerge(SimpleWebAuthnPasskeyVerifier.layer())
);
const AuthKernelLayer = AuthKernelFromPrimitivesLayer.pipe(
Layer.provideMerge(AuthKernelPrimitivesLayer)
);
export const AppPasskeyServicesLive = PasskeyDomainLive.pipe(
Layer.provideMerge(AuthKernelLayer),
Layer.provideMerge(AppAuthRuntimeLive)
);
const AppSecurePasskeyConfigLive = SecurePasskeyConfigLive({
relyingParty: { id: "app.example.com", name: "Example" },
expectedOrigins: ["https://app.example.com"],
attestation: "none",
timeout: 60_000,
});
export const AppPasskeyHttpConfigLive = SecurePasskeyPresetLive.pipe(
Layer.provide(AppSecurePasskeyConfigLive)
);
export const AppPasskeyHttpOperationsLive = PasskeyHttpOperationsLive.pipe(
Layer.provide(
strongFactorRemovalPolicyCapabilityLayerNoDeps(
StrongFactorRemovalPolicyChoice.Disabled()
)
),
Layer.provide(AuthRateLimitStandardLive()),
Layer.provide(HttpAuthenticationCapabilitiesLive),
Layer.provide(AppPasskeyHttpConfigLive),
Layer.provide(Layer.mergeAll(AppPasskeyServicesLive, AppRateLimitLive))
);AppAuthRuntimeLive supplies durable auth storage, crypto, sessions, cookie configuration, and user and identity stores. Apply 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. Do not skip intervening entries, including the passkey credential-name migration. Inspect auth_passkey_credential_quarantine after the strict migration and require re-enrollment for quarantined credentials. The memory credential store is for development and tests, not production concurrency.
The secure preset fixes discoverability and UV policy: resident keys are required, registration and authentication request UV, and finish requires server-verified UV. That server verification is the security boundary. credProtect=userVerificationRequired remains an advisory compatibility request (enforceCredentialProtectionPolicy: false), so the guarantee does not depend on an authenticator implementing or enforcing the extension. The validated configuration Layer also rejects non-positive or non-integer credential caps, empty algorithm lists, unknown algorithms, and legacy SHA-1. Use PasskeyHttpConfig directly only when an application intentionally needs different low-level WebAuthn policy.
The maintained boundary rejects client data with crossOrigin: true and rejects any topOrigin. Cross-origin WebAuthn is unsupported until an application supplies an explicit persisted top-origin allowlist policy; client-supplied top-origin data is not authority.
Define the contract
Reuse all six endpoint contracts or publish a smaller application API:
import {
AuthOriginCheckMiddleware,
AuthSchemaErrorMiddleware,
} from "@effect-auth/core/HttpApi";
import {
passkeyAuthenticationFinishEndpoint,
passkeyAuthenticationStartEndpoint,
passkeyCredentialListEndpoint,
passkeyCredentialRevokeEndpoint,
passkeyRegistrationFinishEndpoint,
passkeyRegistrationStartEndpoint,
} from "@effect-auth/core/HttpApi/Passkey";
import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";
class AppPasskeyHttpApiGroup extends HttpApiGroup.make("passkey")
.add(
passkeyRegistrationStartEndpoint,
passkeyRegistrationFinishEndpoint,
passkeyAuthenticationStartEndpoint,
passkeyAuthenticationFinishEndpoint,
passkeyCredentialListEndpoint,
passkeyCredentialRevokeEndpoint
)
.prefix("/auth/passkey")
.middleware(AuthSchemaErrorMiddleware)
.middleware(AuthOriginCheckMiddleware) {}
export class AppAuthApi extends HttpApi.make("AppAuthApi").add(
AppPasskeyHttpApiGroup
) {}The built-in authentication request accepts raw userId and arbitrary metadata. For identifier-first sign-in, prefer an application schema that resolves an email or username to a user server-side. Remove or allowlist metadata before it reaches challenge, credential, AuthFlow, or session state.
Bind the operations
import { PasskeyHttpOperations } from "@effect-auth/core/HttpApi/Passkey";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { AppAuthApi } from "./auth-api.js";
import { AppPasskeyHttpOperationsLive } from "./passkey-services.live.js";
export const AppPasskeyHttpApiGroupLive = HttpApiBuilder.group(
AppAuthApi,
"passkey",
Effect.fn("app.auth.passkey")(function* (handlers) {
const passkeys = yield* PasskeyHttpOperations;
return handlers
.handle("registerStart", passkeys.registerStart)
.handle("registerFinish", passkeys.registerFinish)
.handle("authenticateStart", passkeys.authenticateStart)
.handle("authenticateFinish", passkeys.authenticateFinish)
.handle("listCredentials", passkeys.listCredentials)
.handle("revokeCredential", passkeys.revokeCredential);
})
).pipe(Layer.provide(AppPasskeyHttpOperationsLive));StrongFactorRemovalPolicyChoice.Disabled() deliberately allows removal without last-factor enforcement and ignores any ambient StrongFactorRemovalPolicy. Use Enabled({ service: policy }) in the capability Layer to enforce the policy.
Mount the application API
import {
AuthHttpApiConfigLive,
AuthOriginCheckMiddlewareLive,
AuthSchemaErrorMiddlewareLive,
} from "@effect-auth/core/HttpApi";
import * as Layer from "effect/Layer";
import { HttpServer } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { AppAuthApi } from "./auth-api.js";
import { AppPasskeyHttpApiGroupLive } from "./passkey-api-group.live.js";
export const AppAuthHttpApiLive = HttpApiBuilder.layer(AppAuthApi).pipe(
Layer.provide(AppPasskeyHttpApiGroupLive),
Layer.provide(
AuthOriginCheckMiddlewareLive({
mode: "secure",
origins: ["https://app.example.com"],
})
),
Layer.provide(AuthSchemaErrorMiddlewareLive),
Layer.provide(
AuthHttpApiConfigLive({
originPolicy: {
mode: "secure",
origins: ["https://app.example.com"],
},
requestMetadata: {
ipSource: { _tag: "CloudflareConnectingIp" },
},
})
),
Layer.provide(HttpServer.layerServices)
);Select a tagged IP source only behind the matching controlled proxy. WebAuthn expected origins verify signed client data; HTTP origin middleware protects unsafe requests. Configure both from the same canonical public URL.
Protect sensitive operations
Registration and revocation use the current session and enforce sensitiveActionStepUpPolicy, which requires password, passkey, or AAL2 evidence from the last 15 minutes and rejects sessions carrying recovery_remediation. Inventory requires only the current session. The stock operations do not emit audit events or notifications; add application controls where account takeover risk warrants them.
The library owns: standard rate-limit calls, session lookup, the registration/revocation freshness gate, server-derived registration identity, WebAuthn orchestration, credential persistence, active-owner validation, AuthFlow orchestration, generic error mapping, and cookie commitment.
Your application owns: endpoint selection, public schemas, origin policy, proxy trust, metadata policy, any stricter step-up rules, auditing, notification, and recovery UX.
Built-in contract
| Route | Request | Authentication | Success |
|---|---|---|---|
POST /auth/passkey/register/start | Optional bounded name | Fresh session | Challenge and creation options |
POST /auth/passkey/register/finish | challengeId, credential | Fresh session | { credentialId } |
POST /auth/passkey/authenticate/start | Optional userId, metadata | Public | Challenge and request options |
POST /auth/passkey/authenticate/finish | challengeId, credential; optional fields | Public | Primary auth result |
GET /auth/passkey/credentials | None | Session | Active credential inventory |
POST /auth/passkey/credentials/revoke | credentialId; optional reason | Fresh session | 204 No Content |
Primary Passkey finish rejects missing or disabled credential owners, then delegates the verified evidence to AuthFlow. The maintained makeAuthFlow implementation can authenticate, require configured MFA or approval, or return an invalid-credentials or policy-denied result. The broader primary-auth transport schema also represents email-verification and passkey-enrollment continuations for application-supplied flows, but maintained makeAuthFlow does not currently emit either one. Only an authenticated result commits the session cookie. Current-session passkey step-up uses separate /auth/step-up/passkey/* routes; the maintained login-MFA HTTP surface does not advertise passkeys.
Browser ceremony
The standalone client performs HTTP calls. Browser helpers convert JSON-safe options, call WebAuthn, and serialize binary fields back to base64url JSON.
import { createPasskeyClient } from "@effect-auth/core/Client";
import {
createPasskeyCredential,
getPasskeyCredential,
isPasskeySupported,
} from "@effect-auth/core/PasskeyBrowser";
const passkeys = createPasskeyClient({
requestInit: { credentials: "include" },
});
export const registerPasskey = async () => {
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 const signInWithPasskey = async () => {
const started = await passkeys.authentication.start({});
const credential = await getPasskeyCredential(started.publicKey);
return passkeys.authentication.finish({
challengeId: started.challengeId,
credential,
});
};For a user-bound ceremony, resolve the public identifier server-side and bind the same userId at start and finish. Omit it at both stages for discoverable authentication. If paths or schemas differ, configure a matching client or use your application transport.
Ceremony lifecycle
Authorize and start. Registration derives the user from the current session; authentication selects a discoverable or user-bound ceremony.
Issue options. The server stores a random, five-minute challenge and returns JSON-safe WebAuthn creation or request options.
Call the authenticator. The browser converts base64url values and calls
navigator.credentials.create() or navigator.credentials.get().
Submit the response. The client serializes the credential and posts it
with the server-issued challengeId.
Verify and consume. The server verifies the signed challenge, origin, RP ID, signature, and UV policy before consuming the challenge.
Commit state. Registration inserts the credential; authentication
updates counter metadata, checks the active owner, runs AuthFlow, and
commits a session cookie only for an authenticated result.
Browser cancellation before finish does not itself consume the server challenge. Failures after challenge verification can consume it even when credential or session work later fails. Do not automatically replay finish; start a fresh ceremony after an ambiguous failure.
RP and origin configuration
| Setting | Purpose |
|---|---|
relyingParty.id | RP ID, normally the registrable domain or a parent suffix of the origin host |
relyingParty.name | Human-readable authenticator label |
expectedOrigins | 1-16 exact trusted origins verified against signed client data |
userVerification | Browser request preference |
requireUserVerification | Server-side verification requirement |
authenticatorSelection | Attachment, resident-key, and registration UV preferences |
attestation | Conveyance preference; configure none unless the product validates attestation |
pubKeyCredParams | Allowed COSE algorithms; defaults to ES256 and RS256 |
timeout | Browser ceremony hint in milliseconds; it does not change challenge lifetime |
maximumActiveCredentials | Active credential cap; the secure preset defaults to 10 |
extensions | Registration extension inputs, including advisory credential protection |
userVerification: "required" asks the browser for UV; requireUserVerification: true enforces it on the server. The RP ID has no scheme or port and must remain stable for existing credentials.
WebAuthn security requirements
- Use HTTPS in production and keep
expectedOriginsexact; never derive them from untrusted request headers. - Require recent authentication for high-risk enrollment or removal and preserve another sign-in or recovery method.
- Keep HTTP origin checking enabled in addition to WebAuthn signed-origin verification.
- Derive registration identity and identifier-first user IDs server-side.
- Treat challenge, raw credential response, credential ID, public key, and user handle as sensitive telemetry.
- A positive stored signature counter must strictly advance. Zero-counter authenticators remain valid; a rejection is a security signal, not conclusive proof of cloning.
Credential lifecycle
PasskeyOptionsLive requires PasskeyCredentialStore. Registration prechecks the active credential limit, excludes active credentials, and binds the optional name and limit into server-side challenge metadata. Finish always calls the store's atomic insertWithinLimit, so concurrent ceremonies cannot exceed the cap. Successful registration stores the name, public key, counter, transports, backup state, timestamps, and verifier metadata. Authentication updates the counter, lastUsedAt, and merged metadata.
const credentials = await passkeys.credentials.list();
await passkeys.credentials.revoke({
credentialId: credentials.credentials[0].credentialId,
});Inventory includes the optional user-facing name but omits public keys and counters. Revocation is soft, revoked credentials cannot authenticate, and revoked rows do not count toward the active limit. An explicitly enabled removal policy prevents removal of the last inventoried strong factor; the required disabled choice does not. Broader recovery and minimum-factor rules remain application policy.
Do not strand the account
Before revoking a last passkey, ensure the user has another usable sign-in or recovery method. Require fresh authentication for high-risk removal and notify the user through an independent channel.
Browser failures
isPasskeySupported() checks secure context and required APIs, not enrolled authenticator availability. Effect browser helpers return typed PasskeyBrowserError; only AbortError maps to reason: "cancelled". Errors such as NotAllowedError commonly remain operation-failed, so use the retained cause for diagnostics but generic retry guidance for users.
HTTP errors
| Code | Status | Typical cause |
|---|---|---|
bad_request | 400 | Invalid payload, expired/consumed challenge, or failed WebAuthn verification |
conflict | 409 | The active credential limit was reached |
invalid_credentials | 401 | The credential owner is missing or disabled |
unauthenticated | 401 | Missing or invalid session for registration or management |
policy_denied | 403 | Configured removal or application policy denied the action |
step_up_required | 403 | Application boundary policy requires stronger authentication |
request_rejected | 403 | HTTP origin validation rejected the request |
rate_limited | 429 | A configured security rule was exceeded |
internal_error | 500 | Challenge, storage, verifier, session, or runtime failure |
Verification details are collapsed at the HTTP boundary. Do not reveal whether a credential exists, is revoked, belongs to another user, or failed a specific signature check. The broad endpoint error union includes policy and step-up errors that stock Passkey operations do not emit unless corresponding application or optional policy is installed.
Security defaults
| Operation | Standard rate limit |
|---|---|
| Registration start | 10/user/hour |
| Registration finish | 30/user/10 minutes |
| Authentication start | 20/IP/10 minutes |
| Authentication finish | 30/IP/10 minutes |
| Credential list | 60/user/minute |
| Credential revoke | 20/user/10 minutes |
The challenge uses 32 random bytes and expires after five minutes. AuthRateLimitStandardLive() applies these rules; IP limits require trusted request metadata. Without a configured source IP, requests share the missing key. Trust forwarded headers only behind a controlled proxy.
Testing checklist
- Run registration and authentication against the exact production RP ID and origin.
- Verify registration requires a valid session and cannot enroll for another user.
- Cover discoverable authentication and server-resolved user-bound authentication.
- Reject wrong origins, RP IDs, signatures, user mismatches, expired challenges, and replay.
- Confirm duplicate, revoked, unknown, and wrong-owner credentials fail without enumeration.
- Confirm missing and disabled credential owners return the same invalid-credentials response.
- Test positive counter advancement, zero-counter authenticators, and concurrent updates.
- Test unsupported browsers, cancellation, insecure contexts, and malformed base64url responses.
- Verify failure after challenge consumption requires a fresh ceremony and does not silently retry.
- Exercise passkey sign-in through configured MFA, approval, risk, and notification policies.
- Exercise last-factor policy, recent step-up, audit events, notifications, and recovery paths.
- Use injected browser credentials for unit tests and a real browser or virtual authenticator for end-to-end tests.