effect-auth

Email OTP

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"].

Choose the highest useful level

Start with Preset. Use HTTP Operations to own routes and public schemas, or Primitives to own user eligibility and authentication orchestration.

Preset

Mount the built-in API

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

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:

import { Duration } from "effect";

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

Browser client

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

export const auth = createAuthClient({
  requestInit: { credentials: "include" },
});
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.

HTTP Operations

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.

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) {}
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.

Primitives

Own the authentication flow

CapabilityService
Issue, deliver, and verify codesEmailOtpLogin
Generate and normalize code valuesEmailOtpSecretGenerator
Default service compositionEmailOtpDefaultLive

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:

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.

Primitives do not apply HTTP boundary security

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.

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.

Built-in contract

RouteRequestSuccess
POST /auth/email-otp/startemail; optional secret, locale, metadata{ challengeId, email, expiresAt }
POST /auth/email-otp/verifychallengeId, secretAuth 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

  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.

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

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

Setting or operationDefault
Code6 numeric digits
Challenge lifetime10 minutes
Assuranceaal1
Start rate limit10/IP and 5/email per 10 minutes
Verify rate limit20/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

CodeStatusTypical cause
bad_request400Invalid email, challenge ID, code shape, or payload
invalid_credentials401Wrong, expired, consumed, or mismatched challenge/code
policy_denied403Auth or application policy rejected the request
step_up_required403Boundary policy requires stronger authentication
request_rejected403Origin validation rejected the request
rate_limited429A configured security rule was exceeded
internal_error500Storage, crypto, mail, or auth-flow failure

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

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.

On this page