---
title: "Passkeys"
url: "https://effect-auth.itsbroly.com/authentication/passkeys/"
description: "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.

<CalloutContainer type="info">
  <CalloutTitle>
    Start with the preset
  </CalloutTitle>

  <CalloutDescription>
    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.
  </CalloutDescription>
</CalloutContainer>

<IntegrationLevelTabs>
  <Tab id="preset" value="Preset">
    ## Preset [#preset]

    ### Mount the passkey API [#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.

    ```ts title="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 [#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.

    ```ts title="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.
  </Tab>

  <Tab id="http-operations" value="HTTP Operations">
    ## HTTP Operations [#http-operations]

    ### Keep orchestration, own the contract [#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.

    ```ts title="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) {}
    ```

    ```ts title="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.
  </Tab>

  <Tab id="primitives" value="Primitives">
    ## Primitives [#primitives]

    ### Own the ceremony and flow [#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 [#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.

    ```ts title="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:

    ```ts title="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 [#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.

    ```ts title="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 [#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.

    ```ts title="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.

    <CalloutContainer type="error">
      <CalloutTitle>
        Primitives do not secure an HTTP boundary
      </CalloutTitle>

      <CalloutDescription>
        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.
      </CalloutDescription>
    </CalloutContainer>

    **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.
  </Tab>
</IntegrationLevelTabs>

## Built-in routes [#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 [#ceremony-lifecycle]

<Steps>
  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.
</Steps>

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 [#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-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 [#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`.

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

:::caution[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-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 [#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 [#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 [#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.

## Related documentation [#related-documentation]

* [MFA](/authentication/mfa/)
* [Step-up Authentication](/authentication/step-up/)
* [Sessions](/concepts/sessions/)
* [Security Policies](/concepts/security-policies/)
* [App-owned Guards](/guides/app-owned-guards/)
* [Testing](/guides/testing/)

