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.
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
import { createAuthClient } from "@effect-auth/core/Client";
export const auth = createAuthClient({
requestInit: { credentials: "include" },
});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.
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) {}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
| 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:
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
| 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
- Start normalizes the email input, generates a code, and issues an
email-otpchallenge. 2.AuthMailerreceives the code, challenge ID, expiry, locale, and metadata. A delivery failure consumes the new challenge. - 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.
AuthFloweither 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 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
| 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
- 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.