Passkeys
Add WebAuthn registration, authentication, and credential management at the integration level your application needs.
Passkeys use WebAuthn public-key credentials for phishing-resistant authentication. Effect Auth exposes the feature at three integration levels; each uses the same challenge, verifier, credential store, and session model.
Start with the preset
Use Preset for the built-in /auth/passkey/* contract. Choose HTTP Operations to own routes and middleware, or Primitives to own the ceremony and authentication flow.
Preset
Mount the passkey API
PasskeyHttpApiLive mounts only the passkey API. It includes typed schemas and errors, origin checking, session-cookie handling, standard security-policy integration, and all six passkey routes. CoreAuthHttpApiLive is the full auth preset and includes the same passkey group alongside the other configured auth features.
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
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";
import { HttpServer } from "effect/unstable/http";
const PasskeysLive = Layer.mergeAll(
PasskeyOptionsLive,
PasskeyCredentialManagementLive,
PasskeyVerificationLive().pipe(
Layer.provide(SimpleWebAuthnPasskeyVerifierLive)
)
);
const AppPasskeyServicesLive = PasskeysLive.pipe(
Layer.provideMerge(AppAuthRuntimeLive)
);
export const AuthLive = PasskeyHttpApiLive.pipe(
Layer.provide(AuthRateLimitStandardLive()),
Layer.provide(
Layer.mergeAll(AppPasskeyServicesLive, 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"],
},
})
),
Layer.provide(HttpServer.layerServices)
);AppAuthRuntimeLive must supply the normal auth runtime plus PasskeyCredentialStore; the SQLite storage layers include the passkey table after migration 0005_auth_passkey. The example uses the built-in SimpleWebAuthn verifier.
To mount the complete auth API, replace PasskeyHttpApiLive with CoreAuthHttpApiLive and provide the services required by every included group.
Browser ceremony
The passkey client performs HTTP requests. createPasskeyCredential and getPasskeyCredential separately convert JSON-safe options, call the WebAuthn browser API, and serialize binary fields back to base64url JSON. Effect applications can use createPasskeyCredentialEffect and getPasskeyCredentialEffect for typed PasskeyBrowserError failures and interruption; the Promise functions are browser-friendly facades over the same core.
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,
});
};Registration requires an existing session. Authentication can be discoverable, as above, or user-bound by passing { userId } to both start and finish.
The preset owns: endpoint handlers, challenge orchestration, WebAuthn verification, standard policy execution, credential persistence, session creation and cookie commitment.
Your application owns: runtime storage, RP/origin configuration, enrollment UI, browser error UX, account-recovery policy, and additional sensitive-action policy.
HTTP Operations
Keep orchestration, own the contract
PasskeyHttpOperations exposes the implementations used by the preset. Bind all or selected operations to an application-owned HttpApi while retaining typed errors, security-policy execution, session reads, session creation, and cookie commitment.
import {
AuthOriginCheckMiddleware,
AuthSchemaErrorMiddleware,
passkeyAuthenticationFinishEndpoint,
passkeyAuthenticationStartEndpoint,
passkeyCredentialListEndpoint,
passkeyCredentialRevokeEndpoint,
passkeyRegistrationFinishEndpoint,
passkeyRegistrationStartEndpoint,
} from "@effect-auth/core/HttpApi";
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) {}import {
PasskeyHttpOperations,
PasskeyHttpOperationsLive,
} from "@effect-auth/core/HttpApi";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";
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(PasskeyHttpOperationsLive));Provide PasskeyHttpConfigLive, passkey domain layers, auth runtime, and AuthRateLimit to this layer just as for the preset. If you rename paths, configure matching prefix and paths in createPasskeyClient.
The library owns: passkey-domain orchestration, operation security, exact success/error semantics, and cookie commitment.
Your application owns: endpoint selection, route names, public schemas, middleware, and application-specific guards.
Primitives
Own the ceremony and flow
Use the domain services when passkey verification is one step in a larger application-owned protocol. These are server-side services: send their JSON-safe publicKey output to the browser helpers, then return the serialized credential to a finish function.
| Capability | Service |
|---|---|
| Issue registration/authentication options and challenges | PasskeyOptions |
| Verify a response and persist/update a credential | PasskeyVerification |
| Verify WebAuthn cryptography | PasskeyVerifier |
| List and revoke a user's credentials | PasskeyCredentialManagement |
| Persist credential records | PasskeyCredentialStore |
Provide PasskeyOptionsLive, PasskeyVerificationLive() with a PasskeyVerifier, PasskeyCredentialManagementLive, and a durable PasskeyCredentialStore. The storage layer must also provide challenge storage; the SQLite auth storage includes both required tables.
Registration ceremony
Enrollment must derive the user and profile fields from an authenticated server context, never from browser-controlled input. startRegistration creates the single-use challenge and automatically excludes the user's active credentials. finishRegistration verifies and consumes that challenge, rejects a duplicate credential ID, and persists the verified public key and authenticator state.
import { ChallengeId, UserId } from "@effect-auth/core/Identifiers";
import {
PasskeyOptions,
PasskeyVerification,
type PasskeyClientCredentialJson,
} from "@effect-auth/core/Passkey";
const relyingPartyId = "app.example.com";
const expectedOrigin = "https://app.example.com";
export const startRegistration = (currentUser: {
readonly id: UserId;
readonly email: string;
}) =>
PasskeyOptions.use((passkeys) =>
passkeys.startRegistration({
relyingParty: { id: relyingPartyId, name: "Example" },
userId: currentUser.id,
userName: currentUser.email,
userDisplayName: currentUser.email,
authenticatorSelection: {
residentKey: "preferred",
userVerification: "required",
},
attestation: "none",
timeout: 60_000,
})
);
export const finishRegistration = (input: {
readonly currentUserId: UserId;
readonly challengeId: ChallengeId;
readonly credential: PasskeyClientCredentialJson;
}) =>
PasskeyVerification.use((passkeys) =>
passkeys.finishRegistration({
userId: input.currentUserId,
challengeId: input.challengeId,
response: input.credential,
relyingPartyId,
expectedOrigin,
requireUserVerification: true,
})
);The browser round trip uses the same public helpers as the preset, but your transport calls the functions above:
import { createPasskeyCredential } from "@effect-auth/core/PasskeyBrowser";
const started = await api.startPasskeyRegistration();
const credential = await createPasskeyCredential(started.publicKey);
await api.finishPasskeyRegistration({
challengeId: started.challengeId,
credential,
});Authentication ceremony
Omit userId for username-less, discoverable authentication. Supply the same server-resolved userId to both start and finish for an identifier-first flow; this creates allowCredentials and prevents a credential for another account from satisfying the ceremony.
import { passkeyEvidence } from "@effect-auth/core/Assurance";
import { AuthFlow } from "@effect-auth/core/AuthFlow";
import { ChallengeId, UnixMillis, UserId } from "@effect-auth/core/Identifiers";
import {
PasskeyOptions,
PasskeyVerification,
type PasskeyClientCredentialJson,
} from "@effect-auth/core/Passkey";
import { Effect } from "effect";
const relyingPartyId = "app.example.com";
const expectedOrigin = "https://app.example.com";
export const startAuthentication = (userId?: UserId) =>
PasskeyOptions.use((passkeys) =>
passkeys.startAuthentication({
relyingPartyId,
userVerification: "required",
timeout: 60_000,
...(userId === undefined ? {} : { userId }),
})
);
export const finishAuthentication = (input: {
readonly userId?: UserId;
readonly challengeId: ChallengeId;
readonly credential: PasskeyClientCredentialJson;
}) =>
Effect.gen(function* () {
const verifiedAt = UnixMillis(Date.now());
const finished = yield* PasskeyVerification.use((passkeys) =>
passkeys.finishAuthentication({
challengeId: input.challengeId,
response: input.credential,
relyingPartyId,
expectedOrigin,
requireUserVerification: true,
now: verifiedAt,
...(input.userId === undefined ? {} : { userId: input.userId }),
})
);
return yield* AuthFlow.use((flow) =>
flow.completePrimaryFactor({
userId: finished.userId,
method: "passkey",
evidence: [passkeyEvidence({
credentialId: finished.credential.id,
verifiedAt,
userVerification: finished.userVerification,
authenticatorAttachment: finished.authenticatorAttachment,
backedUp: finished.backedUp,
backupEligible: finished.backupEligible,
signCount: finished.signCount,
aaguid: finished.aaguid,
})],
})
);
});In the browser, call getPasskeyCredential(started.publicKey) and submit its result with challengeId, just as registration uses createPasskeyCredential.
finishAuthentication loads the credential, rejects unknown, revoked, or user-mismatched records, verifies the assertion, consumes the challenge, and updates signCount, verifier metadata, and lastUsedAt. Its server-produced passkey evidence preserves the UV result. A passkey is local aal2 only when UV is verified; client flags or credential enrollment alone cannot grant it. Verification alone does not authenticate an HTTP response.
AuthFlow.completePrimaryFactor is preferable to calling Sessions.create directly when the normal login pipeline should evaluate risk, MFA enrollment/challenges, trusted devices, or login approval. Its result may therefore require another step instead of containing an authenticated session. Your transport must expose that result and commit a session cookie only when the flow returns an authenticated session. For a deliberately standalone flow, create a Sessions record explicitly and commit it through your own HTTP boundary. For passkey MFA or step-up, complete the existing MFA/step-up flow rather than creating a second session.
Credential management
Use the management service rather than exposing PasskeyCredentialStore directly. It omits public keys from inventory results, scopes operations to the supplied user, hides revoked credentials by default, and makes revocation idempotent.
import { UserId } from "@effect-auth/core/Identifiers";
import {
PasskeyCredentialManagement,
type PasskeyCredentialId,
} from "@effect-auth/core/Passkey";
export const listPasskeys = (currentUserId: UserId) =>
PasskeyCredentialManagement.use((credentials) =>
credentials.listForUser({ userId: currentUserId })
);
export const revokePasskey = (
currentUserId: UserId,
credentialId: PasskeyCredentialId
) =>
PasskeyCredentialManagement.use((credentials) =>
credentials.revokeForUser({
userId: currentUserId,
credentialId,
reason: "user_request",
})
);Authorize both functions from the current session, and apply StrongFactorRemovalPolicy or an app-owned recent-step-up check before revocation. PasskeyCredentialStore remains the extension point for durable persistence or administrative tooling, not the normal account-settings API.
Primitives do not secure an HTTP boundary
At this level your application must authenticate enrollment and credential-management requests, validate request origins, rate-limit ceremonies, map errors safely, create sessions, commit cookies, audit changes, and enforce strong-factor removal policy.
The library owns: option generation, challenge verification, WebAuthn verifier integration, and credential-store operations.
Your application owns: transport, orchestration, authorization, policy, session decisions, and public error mapping.
Built-in routes
| Route | Authentication | Result |
|---|---|---|
POST /auth/passkey/register/start | Session | Challenge and creation options |
POST /auth/passkey/register/finish | Session | Registered credential ID |
POST /auth/passkey/authenticate/start | Public | Challenge and request options |
POST /auth/passkey/authenticate/finish | Public | Authenticated session result |
GET /auth/passkey/credentials | Session | Active credentials |
POST /auth/passkey/credentials/revoke | Session | 204 No Content |
The standalone passkey authentication finish route creates a session directly. Passkeys used inside MFA or step-up use separate /auth/mfa/passkey/* and /auth/step-up/passkey/* routes and complete those flows instead.
Ceremony lifecycle
- The client asks the server to start a registration or authentication
ceremony. 2. The server issues a random, five-minute, single-use challenge and
returns JSON-safe WebAuthn options. 3. A browser helper converts base64url
values to buffers and calls
navigator.credentials.create()ornavigator.credentials.get(). 4. The helper serializes the browser credential and the client sends it withchallengeIdto the finish route. 5. The server verifies challenge, origin, RP ID, credential signature, and user-verification policy before storing or updating the credential.
Do not retry a finish request with an already-consumed challenge. Start a new ceremony after cancellation, expiry, or a recoverable browser failure.
RP and origin configuration
PasskeyHttpConfigLive is required by passkey HTTP operations.
| Setting | Purpose |
|---|---|
relyingParty.id | WebAuthn RP ID, normally the registrable domain or a parent suffix of the origin host |
relyingParty.name | Human-readable name shown by authenticators |
expectedOrigin | Exact trusted origin, or an allowlist, checked during verification |
userVerification | Browser preference in request options |
requireUserVerification | Server-side verification requirement |
authenticatorSelection | Platform/cross-platform, resident-key, and registration UV preferences |
attestation | Attestation conveyance preference; default to none unless the product validates attestation |
pubKeyCredParams | Allowed COSE algorithms; defaults to ES256 and RS256 |
timeout | Browser ceremony hint in milliseconds, not challenge lifetime |
userVerification: "required" asks the browser for UV; requireUserVerification: true enforces it cryptographically on the server. Use both when UV is mandatory.
WebAuthn security requirements
- WebAuthn requires a secure context: HTTPS in production; browsers commonly treat
localhostas trustworthy for local development. - Keep
expectedOriginexact. Include scheme and port, do not derive it from an untrustedHost,Origin, or forwarded header. - The RP ID has no scheme or port and must be valid for the site origin. Changing it later makes existing credentials unusable.
- Registration and credential revocation are authenticated account changes. Add recent step-up where account takeover risk warrants it.
- Keep application origin checking enabled in addition to WebAuthn's signed-origin verification; they protect different HTTP requests and phases.
- Never log challenges, raw WebAuthn responses, public-key records, credential IDs, or user handles as routine telemetry.
- Do not treat signature-counter behavior alone as proof of cloning; synchronized passkeys may report counters that do not increase conventionally.
Credential lifecycle
Registration excludes the user's existing active credentials by default. Successful verification stores the credential ID, public key, signature counter, transports, backup state, creation time, and verifier metadata. Authentication updates the counter and lastUsedAt.
const credentials = await passkeys.credentials.list();
await passkeys.credentials.revoke({
credentialId: credentials.credentials[0].credentialId,
});Revocation is soft: revoked credentials remain available for audit/storage history but are excluded from normal lists and cannot authenticate. The optional StrongFactorRemovalPolicy can deny removal that would violate account-recovery or minimum-factor policy. Product UI should identify credentials with app-owned, non-sensitive metadata because WebAuthn does not provide a reliable human-friendly passkey name.
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
Browser ceremony helpers reject promises rather than returning HTTP errors. Handle cancellation and platform failures separately from server responses. NotAllowedError commonly covers cancellation, timeout, or a disallowed ceremony; InvalidStateError can indicate an excluded credential during registration. Browser names are not sufficiently precise for security decisions, so show generic retry guidance and let the server remain authoritative.
isPasskeySupported() checks for a secure context and required WebAuthn APIs. It is a capability check, not proof that an authenticator is enrolled or available.
HTTP errors
| Code | Status | Typical cause |
|---|---|---|
bad_request | 400 | Invalid payload, expired/consumed challenge, or failed WebAuthn verification |
unauthenticated | 401 | Missing/invalid session for registration or credential management |
policy_denied | 403 | Security or strong-factor removal policy denied the action |
step_up_required | 403 | Stronger or fresher authentication is required |
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 intentionally collapsed at the HTTP boundary. Do not reveal whether a submitted credential ID exists, is revoked, belongs to another user, or failed a specific signature check.
Security defaults
AuthRateLimitStandardLive() limits registration start to 10 per user per hour, registration finish to 30 per user per 10 minutes, authentication start to 20 per IP per 10 minutes, authentication finish to 30 per IP per 10 minutes, credential list to 60 per user per minute, and revocation to 20 per user per 10 minutes.
The default challenge is 32 random bytes and expires after five minutes. Browser timeout does not extend or shorten that server-side lifetime.
Testing checklist
- Run registration and authentication against the exact production RP ID/origin configuration.
- Verify registration requires a valid session and cannot enroll for another user.
- Cover discoverable authentication and user-bound
allowCredentialsauthentication. - Reject wrong origins, RP IDs, signatures, credential/user mismatches, expired challenges, and reused challenges.
- Confirm duplicate credentials, revoked credentials, and unknown credential IDs fail safely.
- Confirm authentication updates
lastUsedAtand counter state without assuming every authenticator increments its counter. - Test browser cancellation, unsupported browsers, insecure contexts, and malformed base64url responses.
- Verify revoking a credential owned by another user does not disclose its existence.
- Exercise last-factor removal policy, step-up requirements, audit events, and recovery paths.
- Use injected
credentialsinPasskeyBrowserOptionsfor unit tests; use a real browser or virtual authenticator for end-to-end WebAuthn tests.