---
title: "Email OTP"
url: "https://effect-auth.itsbroly.com/authentication/email-otp/"
description: "Add passwordless authentication with short-lived, one-time codes delivered by email."
---





Email OTP is a passwordless primary factor. A successful code verifies ownership of the email address, creates or updates the user, records server-produced email OTP evidence, and derives local `aal1` with canonical `amr: ["email_otp"]`.

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

  <CalloutDescription>
    Start with **Preset**. Use **HTTP Operations** to own routes and public schemas, or **Primitives** to own user eligibility and authentication orchestration.
  </CalloutDescription>
</CalloutContainer>

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

    ### Mount the built-in API [#mount-the-built-in-api]

    `CoreAuthHttpApiLive` includes the email OTP routes, schema and origin middleware, standard security rules, continuation handling, and session-cookie commitment.

    ```ts title="auth.ts"
    import { AuthKernelLive } from "@effect-auth/core/AuthKernel";
    import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
    import { EmailOtpDefaultLive } from "@effect-auth/core/EmailOtp";
    import {
      AuthHttpApiConfigLive,
      CoreAuthHttpApiLive,
    } from "@effect-auth/core/HttpApi";
    import { Layer } from "effect";
    import { HttpServer } from "effect/unstable/http";

    const AppAuthServicesLive = EmailOtpDefaultLive().pipe(
      Layer.provideMerge(AuthKernelLive),
      Layer.provideMerge(AppAuthRuntimeLive)
    );

    export const AuthLive = CoreAuthHttpApiLive.pipe(
      Layer.provide(AuthRateLimitStandardLive()),
      Layer.provide(Layer.mergeAll(AppAuthServicesLive, AppRateLimitLive)),
      Layer.provide(
        AuthHttpApiConfigLive({
          originCheck: { allowedOrigins: ["https://app.example.com"] },
        })
      ),
      Layer.provide(HttpServer.layerServices)
    );
    ```

    `AppAuthRuntimeLive` supplies storage, crypto, session secrets, `AuthMailer`, and domain configuration. `AppRateLimitLive` supplies the rate-limit runtime used by `AuthRateLimitStandardLive()`.

    Configure code shape and lifetime on the feature layer when the defaults do not fit:

    ```ts
    import { Duration } from "effect";

    const EmailOtpLive = EmailOtpDefaultLive({
      length: 8,
      ttl: Duration.minutes(5),
    });
    ```

    ### Browser client [#browser-client]

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

    export const auth = createAuthClient({
      requestInit: { credentials: "include" },
    });
    ```

    ```ts title="email-otp-actions.ts"
    const started = await auth.emailOtp.start({ email });

    const result = await auth.emailOtp.verify({
      challengeId: started.challengeId,
      secret: code,
    });
    ```

    Keep `started.challengeId` in short-lived client state. It identifies the challenge but does not replace the emailed secret.

    **The preset owns:** HTTP handlers, standard operation security, request context, trusted-device input, continuation encoding, and session-cookie commitment.

    **Your application owns:** mail delivery and templates, code-entry UI, runtime dependencies, signup eligibility, and continuation UI.
  </Tab>

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

    ### Keep orchestration, own the HTTP API [#keep-orchestration-own-the-http-api]

    `EmailOtpHttpOperations` exposes the preset implementations with their typed success and error contracts. Use it to select routes, restrict the browser payload, or compose application middleware without rebuilding authentication.

    ```ts title="auth-api.ts"
    import {
      AuthOriginCheckMiddleware,
      AuthSchemaErrorMiddleware,
      emailOtpStartEndpoint,
      emailOtpVerifyEndpoint,
    } from "@effect-auth/core/HttpApi";
    import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";

    class AppEmailOtpHttpApiGroup extends HttpApiGroup.make("emailOtp")
      .add(emailOtpStartEndpoint, emailOtpVerifyEndpoint)
      .prefix("/auth/email-otp")
      .middleware(AuthSchemaErrorMiddleware)
      .middleware(AuthOriginCheckMiddleware) {}

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

    ```ts title="email-otp-api.live.ts"
    import {
      EmailOtpHttpOperations,
      EmailOtpHttpOperationsLive,
    } from "@effect-auth/core/HttpApi";
    import { Effect, Layer } from "effect";
    import { HttpApiBuilder } from "effect/unstable/httpapi";

    export const AppEmailOtpHttpApiGroupLive = HttpApiBuilder.group(
      AppAuthApi,
      "emailOtp",
      Effect.fn("app.auth.email_otp")(function* (handlers) {
        const emailOtp = yield* EmailOtpHttpOperations;

        return handlers
          .handle("start", emailOtp.start)
          .handle("verify", emailOtp.verify);
      })
    ).pipe(Layer.provide(EmailOtpHttpOperationsLive));
    ```

    The built-in start schema also accepts `secret`, `locale`, and arbitrary `metadata`. For a public browser API, normally expose only `email` and derive locale and allowlisted metadata server-side. Never let a browser choose the OTP with `secret`.

    **The library owns:** domain orchestration, security-policy execution, result mapping, continuations, and cookie commitment.

    **Your application owns:** endpoint selection, public schemas, route names, middleware, and eligibility or tenant policy.
  </Tab>

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

    ### Own the authentication flow [#own-the-authentication-flow]

    | Capability                         | Service                   |
    | ---------------------------------- | ------------------------- |
    | Issue, deliver, and verify codes   | `EmailOtpLogin`           |
    | Generate and normalize code values | `EmailOtpSecretGenerator` |
    | Default service composition        | `EmailOtpDefaultLive`     |

    The two calls normally happen in separate requests. This transport-neutral orchestration keeps the browser contract narrow and repeats eligibility at verification, where it protects against races and callers that bypass start:

    ```ts title="email-otp.ts"
    import { EmailOtpLogin } from "@effect-auth/core/EmailOtp";
    import { ChallengeId, Email } from "@effect-auth/core/Identifiers";
    import type { IssuedSession } from "@effect-auth/core/Sessions";
    import { Effect, Redacted } from "effect";

    // Application services: normalize/validate public input, check tenant or
    // invite policy, and serialize an issued session into a secure cookie.
    declare const requireEmailOtpEligible: (email: Email) => Effect.Effect<void>;
    declare const rememberPendingEmail: (
      challengeId: ChallengeId,
      email: Email
    ) => Effect.Effect<void>;
    declare const requirePendingEmailEligible: (
      challengeId: ChallengeId
    ) => Effect.Effect<void>;
    declare const commitSession: (session: IssuedSession) => Effect.Effect<void>;

    export const startEmailOtp = (input: {
      readonly email: string;
      readonly locale?: string;
    }) =>
      Effect.gen(function* () {
        const emailOtp = yield* EmailOtpLogin;
        const email = Email(input.email.trim().toLowerCase());

        yield* requireEmailOtpEligible(email);
        const started = yield* emailOtp.start({
          email,
          locale: input.locale,
          metadata: { tenantId: "acme" }, // Derive this server-side.
        });
        yield* rememberPendingEmail(started.challengeId, email);

        // Return this opaque ID to the code-entry page, never the mailed code.
        return { challengeId: started.challengeId, expiresAt: started.expiresAt };
      });

    export const verifyEmailOtp = (input: {
      readonly challengeId: string;
      readonly code: string;
      readonly ip?: string;
      readonly userAgent?: string;
    }) =>
      Effect.gen(function* () {
        const emailOtp = yield* EmailOtpLogin;
        const challengeId = ChallengeId(input.challengeId);

        // Re-check eligibility using challenge-bound application state when
        // policy can change between start and verify.
        yield* requirePendingEmailEligible(challengeId);
        const result = yield* emailOtp.verify({
          challengeId,
          secret: Redacted.make(input.code),
          request: { ip: input.ip, userAgent: input.userAgent },
        });

        if (result._tag === "Authenticated") {
          yield* commitSession(result.session);
          return { _tag: "Authenticated" as const };
        }

        // Return continuations to the next server-owned flow. Map credential,
        // disabled-account, and policy results to non-enumerating responses.
        return result;
      });
    ```

    Store pending eligibility state with the same lifetime as the auth challenge and protect its integrity; do not trust a second email supplied by the browser. `start` sends the code through `AuthMailer`; it never returns the code. A successful `verify` has already created the `AuthFlow` session from evidence, deriving `aal1`, `amr: ["email_otp"]`, and `verifiedIdentityKinds: ["email"]`; primitives return that issued session but do not write a cookie.

    `EmailOtpDefaultLive()` provides `EmailOtpLogin` and the default `EmailOtpSecretGenerator`. It requires `Challenge`, `UserStore`, `Crypto`, `AuthFlow`, and `AuthMailer`; `AuthFlow` in turn must be backed by the session and policy services in your auth runtime.

    Supply a custom `EmailOtpSecretGenerator` when codes need a different format or normalization rule. The built-in alphabet generator uses unbiased random sampling and trims submitted values.

    <CalloutContainer type="error">
      <CalloutTitle>
        Primitives do not apply HTTP boundary security
      </CalloutTitle>

      <CalloutDescription>
        Direct service calls do not run `AuthRateLimit`, origin checks, HTTP schema validation, request-context extraction, or cookie commitment. Apply those controls and commit successful auth results yourself.
      </CalloutDescription>
    </CalloutContainer>

    **The library owns:** challenge issuance and verification, code delivery, verified-user resolution, and typed domain errors.

    **Your application owns:** transport, eligibility, policy, result commitment, error mapping, auditing, and rate limiting.
  </Tab>
</IntegrationLevelTabs>

## Built-in contract [#built-in-contract]

| Route                         | Request                                          | Success                             |
| ----------------------------- | ------------------------------------------------ | ----------------------------------- |
| `POST /auth/email-otp/start`  | `email`; optional `secret`, `locale`, `metadata` | `{ challengeId, email, expiresAt }` |
| `POST /auth/email-otp/verify` | `challengeId`, `secret`                          | Auth result or continuation         |

Treat `secret` on start as a trusted-server/testing facility, not a public browser capability. `metadata` is stored with the challenge and can reach authentication-flow policy; allowlist its keys and values.

## Lifecycle [#lifecycle]

<Steps>
  1. Start normalizes the email input, generates a code, and issues an
     `email-otp` challenge. 2. `AuthMailer` receives the code, challenge ID,
     expiry, locale, and metadata. A delivery failure consumes the new challenge.
  2. The browser retains the challenge ID and submits it with the user-entered
     code. 4. Verify normalizes the code and checks its challenge type, secret,
     lifetime, and consumption state. 5. A valid code creates a verified user when
     none exists, or marks the existing user's email verified. Disabled users are
     not authenticated. 6. `AuthFlow` either authenticates and commits a session
     cookie or returns a continuation.
</Steps>

By default, verification is both sign-in and registration. If your product is invite-only or existing-user-only, enforce eligibility before start and again before verification; the default primitive deliberately auto-creates users.

## Handle continuations [#handle-continuations]

```ts
const result = await auth.emailOtp.verify({ challengeId, secret: code });

switch (result.type) {
  case "authenticated":
    break;
  case "requires_mfa":
    // Continue with result.flowId and an offered factor.
    break;
  case "requires_login_approval":
  case "requires_passkey_enrollment":
  case "requires_email_verification":
    // Render the corresponding configured flow.
    break;
}
```

Email OTP already proves control of the address and marks it verified, but custom `AuthFlow` policy can still produce any configured continuation. Handle results as protocol states, not failures.

## Security defaults [#security-defaults]

| Setting or operation | Default                          |
| -------------------- | -------------------------------- |
| Code                 | 6 numeric digits                 |
| Challenge lifetime   | 10 minutes                       |
| Assurance            | `aal1`                           |
| Start rate limit     | 10/IP and 5/email per 10 minutes |
| Verify rate limit    | 20/IP per 10 minutes             |

* Never log codes, authorization headers, challenge secrets, or mail payloads.
* Keep responses and UI copy generic if account existence is sensitive. The default start route sends mail for any syntactically valid email because registration occurs at verification.
* Rate-limit both start and verify. Short numeric codes depend on online attempt controls.
* Do not treat email OTP as phishing-resistant or as a strong factor merely because it verifies an email address.
* Bind tenant or redirect metadata server-side and validate it before use.

## HTTP errors [#http-errors]

| Code                  | Status | Typical cause                                          |
| --------------------- | -----: | ------------------------------------------------------ |
| `bad_request`         |    400 | Invalid email, challenge ID, code shape, or payload    |
| `invalid_credentials` |    401 | Wrong, expired, consumed, or mismatched challenge/code |
| `policy_denied`       |    403 | Auth or application policy rejected the request        |
| `step_up_required`    |    403 | Boundary policy requires stronger authentication       |
| `request_rejected`    |    403 | Origin validation rejected the request                 |
| `rate_limited`        |    429 | A configured security rule was exceeded                |
| `internal_error`      |    500 | Storage, crypto, mail, or auth-flow failure            |

Do not reveal whether an invalid credential was wrong, expired, consumed, or for another challenge type.

## Testing checklist [#testing-checklist]

* Start sends one message with the expected expiry and never exposes the code in its response.
* Delivery failure consumes the issued challenge.
* Wrong, expired, consumed, and cross-type challenges produce the same public credential error.
* A valid code is single-use and trims input according to the configured generator.
* Existing unverified users become verified; missing users are created only when product policy allows it.
* Disabled users cannot receive an authenticated session.
* Every continuation state is handled and authenticated results commit the session cookie.
* Start and verify rate limits cover normalized email and IP keys.
* Public schemas reject caller-selected secrets and untrusted metadata when those fields are not needed.
* Logs, traces, analytics, and test snapshots contain no OTP values.

## Related documentation [#related-documentation]

* [Magic Links](/authentication/magic-links/)
* [Security Policies](/concepts/security-policies/)
* [Sessions](/concepts/sessions/)
* [MFA](/authentication/mfa/)
* [Step-up Authentication](/authentication/step-up/)
* [Testing](/guides/testing/)

