effect-auth

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:

email-auth-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 { 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 optionDefaultPurpose
ttlMethod defaultsShared fallback lifetime for both challenges
emailOtpTtl10 minutesOTP challenge lifetime
magicLinkTtl15 minutesMagic-link challenge lifetime
magicLinkSecretBytes32 bytesGenerated magic-link secret size
identityIdBytes16 bytesGenerated pending identity ID size
userIdBytes16 bytesGenerated auto-registration user ID size
makeUrlRequiredApplication-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:

auth-api.ts
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

email-auth-api-group.live.ts
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

RouteRequestSuccess
POST /auth/email/startidentity; 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 operationDefault
OTP8 unambiguous characters
OTP lifetime10 minutes
Magic-link secret32 random bytes
Magic-link lifetime15 minutes
Assuranceaal1
Combined-start rate limit10/IP and 5/email per 10 minutes
OTP verify rate limitIP, 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.

On this page