Email OTP + Magic Link
Send one passwordless sign-in email containing both a short code and a magic link.
Combined email authentication lets the recipient choose how to finish the same sign-in attempt: enter a short OTP on the originating device or follow a magic link. EmailAuth issues both credentials and sends one email, while verification reuses the standard Email OTP and Magic Link flows.
Both choices prove control of the same email address and produce aal1. They are alternatives, not two factors and not MFA. The completed method determines the authentication evidence and amr: email_otp for the code or magic_link for the link.
HTTP Operations
EmailAuthHttpOperations exposes the combined start workflow. It applies the standard combined-start guard and calls EmailAuth once, so rate limiting and optional bot verification are not duplicated for the two credentials.
Verification deliberately remains on EmailOtpHttpOperations.verify and MagicLinkHttpOperations.verify. This keeps method-specific evidence, request context, continuations, and cookie commitment identical to the standalone methods.
Configure the features
Use the same OTP generator and magic-link URL policy for combined start and later verification:
import {
AuthKernelFromPrimitivesLayer,
AuthKernelPrimitivesLayer,
} from "@effect-auth/core/AuthKernel";
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { HttpAuthenticationCapabilitiesLive } from "./http-authentication-capabilities.live.js";
import { EmailAuthLive } from "@effect-auth/core/EmailAuth";
import { EmailOtpDefaultLive } from "@effect-auth/core/EmailOtp";
import {
EmailAuthHttpOperationsLive,
EmailAuthProcessCookieLive,
} from "@effect-auth/core/HttpApi/EmailAuth";
import {
layerNoDeps as magicLinkLayerNoDeps,
type MagicLinkUrlInput,
} from "@effect-auth/core/MagicLink";
import { Duration, Layer, Redacted } from "effect";
import { AppAuthRuntimeLive, AppRateLimitLive } from "./auth-runtime.live.js";
export const makeMagicLinkUrl = ({
challengeId,
secret,
}: MagicLinkUrlInput): string => {
const url = new URL("/magic-link", "https://app.example.com");
url.hash = new URLSearchParams({
challengeId,
secret: Redacted.value(secret),
}).toString();
return url.toString();
};
const EmailOtpLive = EmailOtpDefaultLive({ ttl: Duration.minutes(10) });
const CombinedEmailLive = EmailAuthLive({
makeUrl: makeMagicLinkUrl,
emailOtpTtl: Duration.minutes(10),
magicLinkTtl: Duration.minutes(15),
}).pipe(Layer.provideMerge(EmailOtpLive));
const EmailSignInLive = Layer.merge(
CombinedEmailLive,
magicLinkLayerNoDeps({
makeUrl: makeMagicLinkUrl,
ttl: Duration.minutes(15),
})
);
const AuthKernelLayer = AuthKernelFromPrimitivesLayer.pipe(
Layer.provideMerge(AuthKernelPrimitivesLayer)
);
const AppAuthServicesLive = EmailSignInLive.pipe(
Layer.provideMerge(AuthKernelLayer),
Layer.provideMerge(AppAuthRuntimeLive)
);
export const AppEmailAuthHttpOperationsLive = EmailAuthHttpOperationsLive.pipe(
Layer.provide(EmailAuthProcessCookieLive),
Layer.provide(AuthRateLimitStandardLive()),
Layer.provide(HttpAuthenticationCapabilitiesLive),
Layer.provide(Layer.mergeAll(AppAuthServicesLive, AppRateLimitLive))
);EmailOtpDefaultLive supplies EmailOtpLogin, EmailOtpSecretGenerator, and the transport-neutral EmailAuthProcess captured by EmailAuthLive. EmailAuthProcessCookieLive is the explicit HTTP adapter. Magic Link's layerNoDeps supplies link verification. Reusing one makeMagicLinkUrl function prevents standalone and combined links from drifting to different callback policies.
EmailAuthLive option | Default | Purpose |
|---|---|---|
ttl | Method defaults | Shared fallback lifetime for both challenges |
emailOtpTtl | 10 minutes | OTP challenge lifetime |
magicLinkTtl | 15 minutes | Magic-link challenge lifetime |
magicLinkSecretBytes | 32 bytes | Generated magic-link secret size |
identityIdBytes | 16 bytes | Generated pending identity ID size |
userIdBytes | 16 bytes | Generated auto-registration user ID size |
makeUrl | Required | Application-owned callback URL construction |
Configured and per-start combined-auth OTP lifetimes must be 1ms through 1 hour; magic-link lifetimes must be 1ms through 7 days. Invalid values are rejected rather than clamped. Trusted domain inputs resolve each factor as its specific override, then shared ttl, then its independently validated default. The HTTP start payload does not expose these overrides.
Combined and standalone starts always generate their OTP or magic-link credential. Custom OTP generation remains available only through EmailOtpSecretGenerator; start inputs cannot select either plaintext credential.
Define the start contract
The combined operation can be mounted in an application-owned API without exposing the standalone start routes:
import {
AuthOriginCheckMiddleware,
AuthSchemaErrorMiddleware,
} from "@effect-auth/core/HttpApi";
import { emailAuthStartEndpoint } from "@effect-auth/core/HttpApi/EmailAuth";
import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";
class AppEmailAuthHttpApiGroup extends HttpApiGroup.make("emailAuth")
.add(emailAuthStartEndpoint)
.prefix("/auth/email")
.middleware(AuthSchemaErrorMiddleware)
.middleware(AuthOriginCheckMiddleware) {}
export class AppAuthApi extends HttpApi.make("AppAuthApi").add(
AppEmailAuthHttpApiGroup
) {}Add the Email OTP verify and Magic Link verify endpoints to the same application API using their focused HTTP Operations guides. The combined start operation does not replace either verifier.
Bind the operation
import { EmailAuthHttpOperations } from "@effect-auth/core/HttpApi/EmailAuth";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { AppAuthApi } from "./auth-api.js";
import { AppEmailAuthHttpOperationsLive } from "./email-auth-services.live.js";
export const AppEmailAuthHttpApiGroupLive = HttpApiBuilder.group(
AppAuthApi,
"emailAuth",
Effect.fn("app.auth.email")(function* (handlers) {
const emailAuth = yield* EmailAuthHttpOperations;
return handlers.handle("start", emailAuth.start);
})
).pipe(Layer.provide(AppEmailAuthHttpOperationsLive));Mount AppAuthApi with the origin-check and schema-error middleware as shown in the Email OTP guide. Mount the two verify operations in that same API before serving it.
Built-in contract
| Route | Request | Success |
|---|---|---|
POST /auth/email/start | identity; optional locale, metadata, botChallenge | { identity, emailOtp: { challengeId, expiresAt }, magicLink: { expiresAt } } |
The response includes the OTP challenge ID because the browser needs it when submitting the code. It omits the magic-link challenge ID and both secrets. Both credentials are generated on the server. The domain primitive retains trusted-server/testing overrides, but the public HTTP schema never accepts them.
Lifecycle
Prepare the identity once. Start validates and normalizes a global email identity and derives shared challenge metadata.
Issue both credentials. EmailAuth creates an auth-process challenge, a
process-bound email-otp challenge, and an independent magic-link
challenge.
Send one message. AuthMailer receives one _tag: "EmailAuth" payload
containing the OTP and the already-built magic-link URL. A URL or delivery
failure invalidates both newly issued challenges on a best-effort basis.
Offer either completion path. Keep the OTP challenge ID in short-lived browser state; the opaque process credential remains only in the secure HttpOnly cookie. Parse and scrub magic-link credentials before third-party browser code runs, then require explicit user activation.
Verify through the selected method. Code entry calls Email OTP verify;
the callback calls Magic Link verify. The selected verifier resolves the
email identity and completes AuthFlow.
Credential independence
Using one credential does not invalidate the other
The OTP and magic link are separate one-time challenges. Successfully using the code does not consume the link, and using the link does not consume the code. Each remains independently valid until it is consumed or expires.
Combined and standalone OTP starts use the same __Host-email-auth-process cookie. The latest start response applied by the browser replaces its previous value, so only the OTP bound to that current process can verify in that browser. Magic-link verification is intentionally independent of this cookie.
This matches issuing two short-lived sign-in credentials to the same mailbox, but it is not strict "first choice wins" behavior. Applications that require atomic sibling invalidation need application-owned grouped-challenge storage and verification; consuming the sibling only after successful verification does not close concurrent races.
Security defaults
| Setting or operation | Default |
|---|---|
| OTP | 8 unambiguous characters |
| OTP lifetime | 10 minutes |
| Magic-link secret | 32 random bytes |
| Magic-link lifetime | 15 minutes |
| Assurance | aal1 |
| Combined-start rate limit | 10/IP and 5/email per 10 minutes |
| OTP verify rate limit | IP, hashed challenge, trusted principal, and email |
- Use a fixed trusted HTTPS origin in
makeUrl; Core does not validate the returned URL. - A magic-link URL is a bearer credential. Keep it out of request logs, referrers, analytics, traces, and support tooling.
- Do not log OTP values, mail payloads, or trusted domain-level secret overrides.
- Require explicit user activation on the callback page because email scanners may follow links.
- Apply eligibility before combined start and again in verification when the product is invite-only or existing-user-only.
- Allowlist metadata and redirect destinations server-side.
Testing checklist
- One start request creates both typed challenges and sends exactly one email.
- The email contains the matching OTP and magic-link URL, while the HTTP response exposes neither secret.
- URL-construction and delivery failures leave neither new credential usable.
- OTP verification and Magic Link verification both complete authentication and preserve their method-specific evidence.
- Wrong, expired, consumed, and cross-type credentials produce non-enumerating public errors.
- The UI handles every configured continuation state after either verifier.
- Rate limiting and optional bot protection run once for combined start.
- Callback credentials are scrubbed before third-party code and verification requires user activation.
- Tests explicitly capture the chosen sibling-validity policy.