---
title: "TOTP"
url: "https://effect-auth.itsbroly.com/authentication/totp/"
description: "Enroll, verify, inventory, and revoke authenticator-app factors at the integration level your application needs."
---





TOTP support covers RFC-compatible secret generation and verification, durable factor management, a typed HTTP API, and integration with MFA and step-up flows.

<CalloutContainer type="info">
  <CalloutTitle>
    Choose the highest useful level
  </CalloutTitle>

  <CalloutDescription>
    Use **Preset** for the built-in `/auth/totp/*` settings API. Use **HTTP Operations** for an application-owned contract, or **Primitives** when your application owns factor storage or authentication orchestration.
  </CalloutDescription>
</CalloutContainer>

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

    ### Mount the TOTP API [#mount-the-totp-api]

    `TotpHttpApiLive` mounts the complete TOTP settings API. It is a feature preset, not part of `CoreAuthHttpApiLive`, so mount it explicitly.

    ```ts title="totp-api.live.ts"
    import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
    import {
      TotpHttpApiLive,
      TotpHttpConfigLive,
    } from "@effect-auth/core/HttpApi";
    import {
      TotpFactorManagementLive,
      TotpLive,
    } from "@effect-auth/core/Totp";
    import { Layer } from "effect";
    import { HttpServer } from "effect/unstable/http";

    const TotpDomainLive = Layer.mergeAll(
      TotpLive,
      TotpFactorManagementLive
    ).pipe(Layer.provideMerge(AppAuthRuntimeLive));

    export const TotpHttpApiGroupLive = TotpHttpApiLive.pipe(
      Layer.provide(AuthRateLimitStandardLive()),
      Layer.provide(TotpDomainLive),
      Layer.provide(AppSessionLive),
      Layer.provide(AppRateLimitLive),
      Layer.provide(
        TotpHttpConfigLive({
          issuer: "Example",
          window: 1,
        })
      ),
      Layer.provide(HttpServer.layerServices)
    );
    ```

    `AppAuthRuntimeLive` supplies `Crypto` and a durable `TotpFactorStore`. `AppSessionLive` supplies `Sessions` and `SessionCookie`. The Effect-QB storage adapters include TOTP support after migration `0006_auth_totp_factor`.

    ### Browser client [#browser-client]

    ```ts title="totp-client.ts"
    import { createTotpClient } from "@effect-auth/core/Client";

    export const totp = createTotpClient({
      requestInit: { credentials: "include" },
    });

    const started = await totp.enrollment.start({
      accountName: "reader@example.com",
      metadata: { label: "Authenticator app" },
    });

    await totp.enrollment.confirm({
      factorId: started.factor.factorId,
      code,
    });

    const { factors } = await totp.factors.list();
    await totp.factors.revoke({ factorId, reason: "user_request" });
    ```

    The start response contains plaintext `secret` and `uri`. Render the URI as a QR code or show the secret for manual entry, then discard both after confirmation.

    **The preset owns:** current-session lookup, endpoint orchestration, schemas, origin checks, standard security-policy execution, ownership checks, and safe response projection.

    **Your application owns:** durable secret protection, QR UI, step-up before enrollment or removal, audit/notification delivery, and whether a user may remove their last strong factor.
  </Tab>

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

    ### Own the contract [#own-the-contract]

    `TotpHttpOperations` contains the same secured operations used by the preset. Reuse the endpoint contracts when only grouping, middleware, or mounting differs.

    ```ts title="totp-api.ts"
    import {
      AuthOriginCheckMiddleware,
      AuthSchemaErrorMiddleware,
      totpEnrollmentConfirmEndpoint,
      totpEnrollmentStartEndpoint,
      totpFactorListEndpoint,
      totpFactorRevokeEndpoint,
      totpVerifyEndpoint,
    } from "@effect-auth/core/HttpApi";
    import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";

    class AppTotpHttpApiGroup extends HttpApiGroup.make("totp")
      .add(
        totpEnrollmentStartEndpoint,
        totpEnrollmentConfirmEndpoint,
        totpVerifyEndpoint,
        totpFactorListEndpoint,
        totpFactorRevokeEndpoint
      )
      .prefix("/auth/totp")
      .middleware(AuthSchemaErrorMiddleware)
      .middleware(AuthOriginCheckMiddleware) {}

    export class AppAuthApi extends HttpApi.make("AppAuthApi")
      .add(AppTotpHttpApiGroup) {}
    ```

    ```ts title="totp-api.live.ts"
    import {
      TotpHttpOperations,
      TotpHttpOperationsLive,
    } from "@effect-auth/core/HttpApi";
    import { Effect, Layer } from "effect";
    import { HttpApiBuilder } from "effect/unstable/httpapi";

    export const AppTotpHttpApiGroupLive = HttpApiBuilder.group(
      AppAuthApi,
      "totp",
      Effect.fn("app.auth.totp")(function* (handlers) {
        const totp = yield* TotpHttpOperations;

        return handlers
          .handle("startEnrollment", totp.startEnrollment)
          .handle("confirmEnrollment", totp.confirmEnrollment)
          .handle("verify", totp.verify)
          .handle("listFactors", totp.listFactors)
          .handle("revokeFactor", totp.revokeFactor);
      })
    ).pipe(Layer.provide(TotpHttpOperationsLive));
    ```

    Every operation derives `userId` from the session. Do not add a browser-controlled user ID. `TotpHttpOperationsLive` also applies `AuthRateLimit` and uses `StrongFactorRemovalPolicy` on revoke when that optional service is provided.

    **The library owns:** TOTP orchestration, current-session scoping, standard operation security, typed HTTP results, and optional last-factor enforcement.

    **Your application owns:** public schemas, routes, middleware, extra step-up requirements, and session-upgrade behavior.
  </Tab>

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

    ### Enroll and confirm for the current user [#enroll-and-confirm-for-the-current-user]

    `TotpFactorManagement` is the normal primitive. Bind every operation to the authenticated `CurrentSession`; never accept `userId` from a form or API payload. Require your settings policy before exposing a new shared secret.

    ```ts title="totp-settings.ts"
    import { CurrentSession } from "@effect-auth/core/Sessions";
    import { requireFresh } from "@effect-auth/core/StepUp";
    import { TotpFactorManagement } from "@effect-auth/core/Totp";
    import { Duration, Effect, Redacted } from "effect";

    export const startTotpEnrollment = (
      accountName: string,
      label: string,
    ) =>
      Effect.gen(function* () {
        yield* requireFresh(Duration.minutes(10));
        const session = yield* CurrentSession;
        const factors = yield* TotpFactorManagement;

        return yield* factors.startEnrollment({
          userId: session.userId,
          issuer: "Example",
          accountName,
          metadata: { label },
        });
      });

    export const confirmTotpEnrollment = (
      factorId: Parameters<
        TotpFactorManagement["confirmEnrollment"]
      >[0]["factorId"],
      code: string,
    ) =>
      Effect.gen(function* () {
        yield* requireFresh(Duration.minutes(10));
        const session = yield* CurrentSession;
        const factors = yield* TotpFactorManagement;

        return yield* factors.confirmEnrollment({
          userId: session.userId,
          factorId,
          code: Redacted.make(code),
          window: 1,
          metadata: { confirmedFromSession: session.sessionId },
        });
      });
    ```

    `startEnrollment` persists a pending factor and returns a redacted plaintext `secret` and `uri`. Unwrap them only at the final presentation boundary, never in logs or telemetry. `confirmEnrollment` verifies ownership and the code before setting `confirmedAt`; retain the server-side factor ID between these two requests rather than trusting an unrelated user's ID.

    ### Verify and make an authentication decision [#verify-and-make-an-authentication-decision]

    Use `MfaHttpOperations` for pending login or `StepUpHttpOperations` for a current session. Their factor-specific TOTP paths verify the code, obtain server-produced `totpEvidence` with the accepted absolute counter, atomically advance the factor replay floor, append evidence, rotate the session token, and commit the replacement cookie. Do not compose `verifyForUser` and a generic session update and call it atomic.

    The default replay policy accepts only a counter greater than `lastAcceptedCounter`. Consequently the same counter cannot succeed twice, including concurrently, and an older delayed window is rejected after a newer counter succeeds. This monotonic floor deliberately favors replay safety over out-of-order acceptance. For custom server-only orchestration, construct evidence with `totpEvidence({ factorId, acceptedCounter, verifiedAt })` only from the trusted verification result and preserve the same transactional boundary.

    ### Inventory and revoke [#inventory-and-revoke]

    ```ts title="totp-management.ts"
    import { CurrentSession } from "@effect-auth/core/Sessions";
    import { requireFresh } from "@effect-auth/core/StepUp";
    import {
      StrongFactorRemovalPolicy,
      strongFactor,
    } from "@effect-auth/core/StrongFactor";
    import { TotpFactorManagement } from "@effect-auth/core/Totp";
    import { Duration, Effect } from "effect";

    export const listTotpFactors = Effect.gen(function* () {
      const session = yield* CurrentSession;
      return yield* TotpFactorManagement.use((factors) =>
        factors.listForUser({ userId: session.userId }),
      );
    });

    export const revokeTotpFactor = (factorId: Parameters<
      TotpFactorManagement["revokeForUser"]
    >[0]["factorId"]) =>
      Effect.gen(function* () {
        yield* requireFresh(Duration.minutes(10));
        const session = yield* CurrentSession;
        const removal = yield* StrongFactorRemovalPolicy;
        const factors = yield* TotpFactorManagement;

        yield* removal.requireCanRemove({
          userId: session.userId,
          factor: strongFactor("totp", factorId),
        });
        yield* factors.revokeForUser({
          userId: session.userId,
          factorId,
          reason: "user_request",
        });
      });
    ```

    Normal listing excludes pending and revoked factors. Administrative diagnostics can opt into `includeUnconfirmed` or `includeRevoked`, but do not expose those switches directly without an authorization policy. The primitive revoke checks ownership, while `StrongFactorRemovalPolicy` is a separate application-composed guard that protects the last strong factor.

    ### Low-level TOTP [#low-level-totp]

    Use `Totp` directly only when your application owns factor storage or secret retrieval.

    ```ts
    import { Totp } from "@effect-auth/core/Totp";

    const result = yield* Totp.use((totp) =>
      totp.verify({ secret, code: Redacted.make(code), window: 1 })
    );
    ```

    `TotpLive` provides generation, base32 normalization, `otpauth://` URI construction, code generation, and timing-safe verification. `TotpFactorManagementLive` additionally requires `TotpFactorStore` and `Crypto`.

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

      <CalloutDescription>
        They do not authenticate a request, rate-limit attempts, require step-up, upgrade a session, or emit audit events. Those controls become your responsibility.
      </CalloutDescription>
    </CalloutContainer>

    **The library owns:** RFC-compatible TOTP calculation, typed domain errors, factor state transitions, ownership checks, and safe factor projections.

    **Your application owns:** authorization, transport, policy, secret-at-rest protection, auditing, and authentication/session decisions.
  </Tab>
</IntegrationLevelTabs>

## Built-in routes [#built-in-routes]

All TOTP settings routes require a session.

| Route                            | Result                                       |
| -------------------------------- | -------------------------------------------- |
| `POST /auth/totp/enroll/start`   | Pending factor plus plaintext secret and URI |
| `POST /auth/totp/enroll/confirm` | Confirmed factor metadata                    |
| `POST /auth/totp/verify`         | `{ valid, factor?, delta? }`                 |
| `GET /auth/totp/factors`         | Safe active factor inventory                 |
| `POST /auth/totp/factors/revoke` | `204 No Content`                             |

## Enrollment lifecycle [#enrollment-lifecycle]

<Steps>
  1. Require an authenticated settings session and any application-owned fresh
     step-up. 2. Start enrollment. Core generates a secret, stores a pending
     factor, and returns the secret and `otpauth://` URI. 3. Render the URI or
     manual secret without logging, tracing, or persisting it in browser analytics.
  2. Confirm with a code from the authenticator. An invalid code leaves the
     factor pending. 5. On success, core sets `confirmedAt`; only then does the
     factor appear in normal lists and verification.
</Steps>

Pending enrollment has no built-in expiry or cleanup job. Define an application retention policy for abandoned pending factors.

## MFA and step-up [#mfa-and-step-up]

`POST /auth/totp/verify` verifies a factor for an already authenticated settings session; it does not complete an MFA continuation or raise the session assurance level.

* MFA uses `/auth/mfa/totp/verify` or `/auth/mfa/totp/verify-flow` through `MfaHttpOperations`.
* Step-up uses `/auth/step-up/totp/verify` through `StepUpHttpOperations`.
* At the primitive level, call the appropriate `AuthFlow` operation or session upgrade only after `verifyForUser` succeeds.

Do not treat `{ valid: false }` as an infrastructure error, and never upgrade a session unless `valid` is `true`.

## Security defaults [#security-defaults]

| Setting             |                 Default |
| ------------------- | ----------------------: |
| Algorithm           |                   SHA-1 |
| Digits              |                       6 |
| Period              |              30 seconds |
| Generated secret    |                20 bytes |
| Verification window | 1 step before and after |

The standard security profile limits enrollment start to 10 per user per hour, confirmation and verification to 20 per user per 10 minutes, listing to 60 per user per minute, and revoke to 20 per user per 10 minutes. MFA TOTP verification is separately limited to 20 per IP per 10 minutes.

:::caution[Protect the shared secret]
`Redacted` prevents accidental string rendering, not storage compromise. The reference store persists the secret value it receives. Encrypt TOTP secrets at rest or wrap `TotpFactorStore` with runtime-appropriate secret protection. Never expose a secret after confirmation.
:::

Keep `window: 1` unless clock behavior requires otherwise. Larger windows accept more candidate codes and increase brute-force opportunity.

## HTTP errors [#http-errors]

| Code               | Status | Typical cause                                                      |
| ------------------ | -----: | ------------------------------------------------------------------ |
| `bad_request`      |    400 | Invalid enrollment, code configuration, factor ID, or factor state |
| `unauthenticated`  |    401 | Missing or invalid session                                         |
| `policy_denied`    |    403 | Security or last-strong-factor policy rejected the action          |
| `step_up_required` |    403 | Configured removal policy requires fresher assurance               |
| `request_rejected` |    403 | Origin validation rejected the request                             |
| `rate_limited`     |    429 | A standard or application rule was exceeded                        |
| `internal_error`   |    500 | Crypto, storage, session, or runtime failure                       |

Wrong verification codes are normally successful HTTP responses with `valid: false`. Confirmation instead maps an invalid code to `bad_request` because the requested state transition failed.

## Testing checklist [#testing-checklist]

* RFC 6238 vectors pass for every enabled algorithm and digit policy.
* Enrollment secrets and URIs never appear in logs, traces, snapshots, or analytics.
* A factor remains pending after a wrong confirmation code and becomes active after a valid one.
* Pending and revoked factors cannot verify; successful verification updates `lastUsedAt`.
* Factor list responses never contain secrets.
* Cross-user factor IDs cannot be confirmed or revoked.
* Verification, confirmation, and MFA routes exercise their separate rate-limit keys.
* Revoke tests cover step-up and last-strong-factor policy when enabled.
* MFA continuation and step-up tests assert the resulting session assurance and AMR.

## Related documentation [#related-documentation]

* [MFA](/authentication/mfa/)
* [Recovery Codes](/authentication/recovery-codes/)
* [Step-up Authentication](/authentication/step-up/)
* [Security Policies](/concepts/security-policies/)
* [Sessions](/concepts/sessions/)
* [Testing](/guides/testing/)

