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:
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.
| Option | Purpose |
|---|---|
alphabet | Characters used by the built-in secret generator |
length | Number of generated characters, from 4 through 128 |
ttl | Default challenge lifetime |
userIdBytes | Random-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:
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
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.
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
| Route | Request | Success |
|---|---|---|
POST /auth/email-otp/start | identity; optional locale, metadata, botChallenge | { challengeId, identity, expiresAt } |
POST /auth/email-otp/verify | challengeId, secret; optional botChallenge | Auth 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 operation | Default |
|---|---|
| Code | 8 characters from an unambiguous 32-character alphabet |
| Challenge lifetime | 10 minutes; configured and per-start TTLs must be 1ms to 1 hour |
| Process cookie | __Host-email-auth-process; HttpOnly, Secure, SameSite=Lax |
| Assurance | aal1 |
| Start rate limit | 10/IP and 5/email per 10 minutes |
| Verify rate limit | 20/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
| Code | Status | Typical cause |
|---|---|---|
bad_request | 400 | Invalid identity, 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
- 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.