---
title: "Email OTP + Magic Link"
url: "https://effect-auth.itsbroly.com/authentication/email-auth/"
description: "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 [#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 [#configure-the-features]

Use the same OTP generator and magic-link URL policy for combined start and later verification:

```ts title="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` 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 [#define-the-start-contract]

The combined operation can be mounted in an application-owned API without exposing the standalone start routes:

```ts title="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 [#bind-the-operation]

```ts title="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](/authentication/email-otp/#mount-the-application-api). Mount the two verify operations in that same API before serving it.

## Built-in contract [#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 [#lifecycle]

<Steps>
  <Step>
    **Prepare the identity once.** Start validates and normalizes a global email
    identity and derives shared challenge metadata.
  </Step>

  <Step>
    **Issue both credentials.** `EmailAuth` creates an auth-process challenge, a
    process-bound `email-otp` challenge, and an independent `magic-link`
    challenge.
  </Step>

  <Step>
    **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.
  </Step>

  <Step>
    **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.
  </Step>

  <Step>
    **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`.
  </Step>
</Steps>

## Credential independence [#credential-independence]

:::caution[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 [#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 [#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.

## Related documentation [#related-documentation]

* [Email OTP](/authentication/email-otp/)
* [Magic Links](/authentication/magic-links/)
* [Browser Client](/clients/browser-client/)
* [HTTP Operations](/concepts/http-operations/)
* [Security Policies](/concepts/security-policies/)
* [Testing](/guides/testing/)

