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

HTTP Operations

EmailOtpHttpOperations exposes the standard start and verify workflows with typed success and error contracts. Your application chooses the routes, public schemas, middleware, and which methods to expose without rebuilding authentication behavior.

Configure the feature

EmailOtpHttpOperationsLive deliberately has no OTP-specific options. It consumes EmailOtpLogin, so configure the feature service before providing it to the operations layer:

email-otp-services.live.ts
import {
  AuthKernelFromPrimitivesLayer,
  AuthKernelPrimitivesLayer,
} from "@effect-auth/core/AuthKernel";
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { HttpAuthenticationCapabilitiesLive } from "./http-authentication-capabilities.live.js";
import { EmailOtpDefaultLive } from "@effect-auth/core/EmailOtp";
import {
  EmailAuthProcessCookieLive,
  EmailOtpHttpOperationsLive,
} from "@effect-auth/core/HttpApi/EmailOtp";
import { Duration, Layer } from "effect";

import { AppAuthRuntimeLive, AppRateLimitLive } from "./auth-runtime.live.js";

export const EmailOtpFeatureLive = EmailOtpDefaultLive({
  ttl: Duration.minutes(5),
});

const AuthKernelLayer = AuthKernelFromPrimitivesLayer.pipe(
  Layer.provideMerge(AuthKernelPrimitivesLayer)
);

const AppAuthServicesLive = EmailOtpFeatureLive.pipe(
  Layer.provideMerge(AuthKernelLayer),
  Layer.provideMerge(AppAuthRuntimeLive)
);

export const AppEmailOtpHttpOperationsLive = EmailOtpHttpOperationsLive.pipe(
  Layer.provide(EmailAuthProcessCookieLive),
  Layer.provide(AuthRateLimitStandardLive()),
  Layer.provide(HttpAuthenticationCapabilitiesLive),
  Layer.provide(Layer.mergeAll(AppAuthServicesLive, AppRateLimitLive))
);

AppAuthRuntimeLive supplies storage, crypto, session secrets, AuthMailer, and domain configuration. AppRateLimitLive supplies the rate-limit runtime used by AuthRateLimitStandardLive(). The explicit cookie layer stores the opaque process credential in the host-only __Host-email-auth-process cookie; it never becomes a request payload or JSON response field.

OptionPurpose
alphabetCharacters used by the built-in secret generator
lengthNumber of generated characters, from 4 through 128
ttlDefault challenge lifetime
userIdBytesRandom-token byte count used when registration creates a user

The built-in HTTP request does not expose ttl; use a custom operation when the lifetime must vary per request. A fully custom generator or normalization rule is configured below the HTTP layer.

Define the contract

The following contract reuses the built-in endpoint schemas while choosing an application-owned API and group layout:

auth-api.ts
import {
  AuthOriginCheckMiddleware,
  AuthSchemaErrorMiddleware,
} from "@effect-auth/core/HttpApi";
import {
  emailOtpStartEndpoint,
  emailOtpVerifyEndpoint,
} from "@effect-auth/core/HttpApi/EmailOtp";
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
) {}

To expose a narrower payload such as { email }, define an application endpoint and map it to the payload.identity expected by the operation. Derive the identity kind and scope server-side rather than letting an untrusted browser select them.

Bind the operations

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

import { AppAuthApi } from "./auth-api.js";
import { AppEmailOtpHttpOperationsLive } from "./email-otp-services.live.js";

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(AppEmailOtpHttpOperationsLive));

Mount the application API

Provide the application-owned middleware when mounting the API. The feature and operations dependencies were already assembled above.

auth-http.live.ts
import {
  AuthOriginCheckMiddlewareLive,
  AuthSchemaErrorMiddlewareLive,
} from "@effect-auth/core/HttpApi";
import { Layer } from "effect";
import { HttpServer } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";

import { AppAuthApi } from "./auth-api.js";
import { AppEmailOtpHttpApiGroupLive } from "./email-otp-api-group.live.js";

export const AppAuthHttpApiLive = HttpApiBuilder.layer(AppAuthApi).pipe(
  Layer.provide(AppEmailOtpHttpApiGroupLive),
  Layer.provide(
    AuthOriginCheckMiddlewareLive({
      mode: "secure",
      origins: ["https://app.example.com"],
    })
  ),
  Layer.provide(AuthSchemaErrorMiddlewareLive),
  Layer.provide(HttpServer.layerServices)
);

The library owns: domain orchestration, standard operation security, result mapping, continuations, and cookie commitment.

Your application owns: endpoint selection, public schemas, route names, middleware, and eligibility or tenant policy.

Built-in contract

RouteRequestSuccess
POST /auth/email-otp/startidentity; optional locale, metadata, botChallenge{ challengeId, identity, expiresAt }
POST /auth/email-otp/verifychallengeId, secret; optional botChallengeAuth result or continuation

The built-in identity contains scope, kind, and value; Email OTP currently requires a global email identity. The public start operation always generates the code on the server. The domain EmailOtpLogin.start primitive retains a trusted-server/testing override, which must not be forwarded from untrusted input. Allowlist metadata keys and values because metadata is stored with the challenge and can reach authentication-flow policy.

Lifecycle

Issue the process and challenge. Start normalizes the email identity, creates a short-lived email-auth-process challenge, and binds the new email-otp challenge to it through versioned metadata.

Deliver the code. AuthMailer receives the code, challenge ID, expiry, locale, and metadata. A delivery failure consumes the new challenge.

Submit the code. The browser retains the challenge ID and sends it with the user-entered code to the verify endpoint. The process credential travels only in the secure HttpOnly cookie.

Verify the binding and challenge. Verify authenticates the process credential, checks that the process subject and challenge metadata match, then consumes the one-time code.

Resolve the identity. A valid code creates a verified user when none exists or marks the existing email identity as verified. Disabled users are not authenticated.

Complete authentication. 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.

The cookie adapter intentionally has one cookie slot. Every successful standalone or combined Email OTP start writes that same cookie name, so the last start response applied by the browser wins. An older OTP no longer matches the current browser cookie; this does not globally revoke an older process credential held by another client.

Handle continuations

After your application endpoint returns the verification result, handle every configured protocol state:

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

The library defaults are shown below; the earlier configuration example overrides code length and challenge lifetime. AuthRateLimitStandardLive() applies the standard start and verify limits.

Setting or operationDefault
Code8 characters from an unambiguous 32-character alphabet
Challenge lifetime10 minutes; configured and per-start TTLs must be 1ms to 1 hour
Process cookie__Host-email-auth-process; HttpOnly, Secure, SameSite=Lax
Assuranceaal1
Start rate limit10/IP and 5/email per 10 minutes
Verify rate limit20/IP plus 5/challenge, 5/principal, and 5/email 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. Challenge keys are HMAC-hashed by Privacy; principal and email verify keys come from the authenticated process row rather than browser payload.
  • 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 identity, 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, trusted principal, HMAC-hashed challenge, and IP keys.
  • Starting a second flow replaces the process cookie and makes the first OTP fail with that current cookie without consuming the first OTP.
  • Public schemas reject caller-selected secrets, identity scopes, and untrusted metadata when those fields are not needed.
  • Logs, traces, analytics, and test snapshots contain no OTP values.

On this page