effect-auth

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.

auth.ts
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.

passkey-actions.ts
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.

auth-api.ts
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) {}
passkey-api.live.ts
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.

CapabilityService
Issue registration/authentication options and challengesPasskeyOptions
Verify a response and persist/update a credentialPasskeyVerification
Verify WebAuthn cryptographyPasskeyVerifier
List and revoke a user's credentialsPasskeyCredentialManagement
Persist credential recordsPasskeyCredentialStore

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.

passkey-registration.ts
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:

register-passkey.ts
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.

passkey-authentication.ts
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.

passkey-settings.ts
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

RouteAuthenticationResult
POST /auth/passkey/register/startSessionChallenge and creation options
POST /auth/passkey/register/finishSessionRegistered credential ID
POST /auth/passkey/authenticate/startPublicChallenge and request options
POST /auth/passkey/authenticate/finishPublicAuthenticated session result
GET /auth/passkey/credentialsSessionActive credentials
POST /auth/passkey/credentials/revokeSession204 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

  1. 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() or navigator.credentials.get(). 4. The helper serializes the browser credential and the client sends it with challengeId to 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.

SettingPurpose
relyingParty.idWebAuthn RP ID, normally the registrable domain or a parent suffix of the origin host
relyingParty.nameHuman-readable name shown by authenticators
expectedOriginExact trusted origin, or an allowlist, checked during verification
userVerificationBrowser preference in request options
requireUserVerificationServer-side verification requirement
authenticatorSelectionPlatform/cross-platform, resident-key, and registration UV preferences
attestationAttestation conveyance preference; default to none unless the product validates attestation
pubKeyCredParamsAllowed COSE algorithms; defaults to ES256 and RS256
timeoutBrowser 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 localhost as trustworthy for local development.
  • Keep expectedOrigin exact. Include scheme and port, do not derive it from an untrusted Host, 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

CodeStatusTypical cause
bad_request400Invalid payload, expired/consumed challenge, or failed WebAuthn verification
unauthenticated401Missing/invalid session for registration or credential management
policy_denied403Security or strong-factor removal policy denied the action
step_up_required403Stronger or fresher authentication is required
request_rejected403HTTP origin validation rejected the request
rate_limited429A configured security rule was exceeded
internal_error500Challenge, 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 allowCredentials authentication.
  • 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 lastUsedAt and 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 credentials in PasskeyBrowserOptions for unit tests; use a real browser or virtual authenticator for end-to-end WebAuthn tests.

On this page