---
title: "Magic Links"
url: "https://effect-auth.itsbroly.com/authentication/magic-links/"
description: "Add passwordless authentication with short-lived bearer links delivered by email."
---





Magic links are a passwordless primary factor. A valid link verifies ownership of the email address, creates or updates the user, records server-produced magic-link evidence, and derives local `aal1` with canonical `amr: ["magic_link"]`.

<CalloutContainer type="info">
  <CalloutTitle>
    Choose the highest useful level
  </CalloutTitle>

  <CalloutDescription>
    Start with **Preset**. Use **HTTP Operations** for an application-owned API, or **Primitives** when the application must own eligibility and flow orchestration.
  </CalloutDescription>
</CalloutContainer>

<IntegrationLevelTabs>
  <Tab id="preset" value="Preset">
    ## Preset [#preset]

    ### Mount the built-in API [#mount-the-built-in-api]

    Configure an application URL builder, then provide `MagicLinkLoginLive` to the complete HTTP preset.

    ```ts title="auth.ts"
    import { AuthKernelLive } from "@effect-auth/core/AuthKernel";
    import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
    import {
      AuthHttpApiConfigLive,
      CoreAuthHttpApiLive,
    } from "@effect-auth/core/HttpApi";
    import {
      MagicLinkLoginLive,
      type MagicLinkUrlInput,
    } from "@effect-auth/core/MagicLink";
    import { Layer, Redacted } from "effect";
    import { HttpServer } from "effect/unstable/http";

    const makeMagicLinkUrl = ({
      challengeId,
      secret,
    }: MagicLinkUrlInput) => {
      const url = new URL("/magic-link", env.AUTH_PUBLIC_URL);
      url.searchParams.set("challengeId", challengeId);
      url.searchParams.set("secret", Redacted.value(secret));
      return url.toString();
    };

    const AppAuthServicesLive = MagicLinkLoginLive({
      makeUrl: makeMagicLinkUrl,
    }).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. The callback URL is application-owned and must resolve to a browser page that posts the two credentials to verify.

    :::caution[The URL is a bearer credential]
    Do not log the URL, query string, challenge secret, or mail payload. Keep them out of traces, analytics, referrers, support tooling, and error reports.
    :::

    ### Browser client [#browser-client]

    ```ts title="auth-client.ts"
    import { createAuthClient } from "@effect-auth/core/Client";

    export const auth = createAuthClient({
      requestInit: { credentials: "include" },
    });
    ```

    ```ts title="magic-link-actions.ts"
    await auth.magicLink.start({ email });

    const result = await auth.magicLink.verify({
      challengeId: searchParams.get("challengeId")!,
      secret: searchParams.get("secret")!,
    });
    ```

    The callback page should validate missing parameters, call verify once with `POST`, disable automatic retries, then remove credentials from the address bar with `history.replaceState` before rendering or navigating.

    **The preset owns:** HTTP handlers, standard security, request context, trusted-device input, continuation encoding, and session-cookie commitment.

    **Your application owns:** URL construction, mail delivery and templates, callback UI, signup eligibility, runtime dependencies, and continuation UI.
  </Tab>

  <Tab id="http-operations" value="HTTP Operations">
    ## HTTP Operations [#http-operations]

    ### Keep orchestration, own the HTTP API [#keep-orchestration-own-the-http-api]

    `MagicLinkHttpOperations` exposes the preset start and verify implementations. Bind them to an application-owned `HttpApi` for custom routes, schemas, middleware, and eligibility guards.

    ```ts title="auth-api.ts"
    import {
      AuthOriginCheckMiddleware,
      AuthSchemaErrorMiddleware,
      magicLinkStartEndpoint,
      magicLinkVerifyEndpoint,
    } from "@effect-auth/core/HttpApi";
    import { HttpApi, HttpApiGroup } from "effect/unstable/httpapi";

    class AppMagicLinkHttpApiGroup extends HttpApiGroup.make("magicLink")
      .add(magicLinkStartEndpoint, magicLinkVerifyEndpoint)
      .prefix("/auth/magic-link")
      .middleware(AuthSchemaErrorMiddleware)
      .middleware(AuthOriginCheckMiddleware) {}

    export class AppAuthApi extends HttpApi.make("AppAuthApi")
      .add(AppMagicLinkHttpApiGroup) {}
    ```

    ```ts title="magic-link-api.live.ts"
    import {
      MagicLinkHttpOperations,
      MagicLinkHttpOperationsLive,
    } from "@effect-auth/core/HttpApi";
    import { Effect, Layer } from "effect";
    import { HttpApiBuilder } from "effect/unstable/httpapi";

    export const AppMagicLinkHttpApiGroupLive = HttpApiBuilder.group(
      AppAuthApi,
      "magicLink",
      Effect.fn("app.auth.magic_link")(function* (handlers) {
        const magicLink = yield* MagicLinkHttpOperations;

        return handlers
          .handle("start", magicLink.start)
          .handle("verify", magicLink.verify);
      })
    ).pipe(Layer.provide(MagicLinkHttpOperationsLive));
    ```

    The reusable start endpoint accepts `secret`, `locale`, and arbitrary `metadata`. Public browser contracts should generally expose only `email`; derive locale and allowlisted redirect or tenant context on the server. Never accept a browser-selected link 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, redirect policy, and user eligibility.
  </Tab>

  <Tab id="primitives" value="Primitives">
    ## Primitives [#primitives]

    ### Own the authentication flow [#own-the-authentication-flow]

    `MagicLinkLogin` is the public domain service. The two calls normally happen in separate requests: start sends mail, while the callback page posts the URL credentials to verify.

    ```ts title="magic-link.ts"
    import { ChallengeId, Email } from "@effect-auth/core/Identifiers";
    import {
      MagicLinkLoginLive,
      MagicLinkLogin,
    } from "@effect-auth/core/MagicLink";
    import type { IssuedSession } from "@effect-auth/core/Sessions";
    import { Effect, Redacted } from "effect";

    declare const requireMagicLinkEligible: (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 startMagicLink = (input: {
      readonly email: string;
      readonly locale?: string;
    }) =>
      Effect.gen(function* () {
        const magicLinks = yield* MagicLinkLogin;
        const email = Email(input.email.trim().toLowerCase());

        yield* requireMagicLinkEligible(email);
        const started = yield* magicLinks.start({
          email,
          locale: input.locale,
          metadata: { tenantId: "acme", redirectTo: "/projects" },
        });
        yield* rememberPendingEmail(started.challengeId, email);

        // Keep this server-side. The recipient gets both credentials in mail.
        return { email: started.email, expiresAt: started.expiresAt };
      });

    export const verifyMagicLink = (input: {
      readonly challengeId: string;
      readonly secret: string;
      readonly ip?: string;
      readonly userAgent?: string;
    }) =>
      Effect.gen(function* () {
        const magicLinks = yield* MagicLinkLogin;
        const challengeId = ChallengeId(input.challengeId);

        // Resolve eligibility from challenge-bound application state here;
        // never trust tenant or redirect values copied from the callback URL.
        yield* requirePendingEmailEligible(challengeId);
        const result = yield* magicLinks.verify({
          challengeId,
          secret: Redacted.make(input.secret),
          request: { ip: input.ip, userAgent: input.userAgent },
        });

        if (result._tag === "Authenticated") {
          yield* commitSession(result.session);
          return { _tag: "Authenticated" as const };
        }

        // Preserve RequiresMfa and other continuations. Map invalid,
        // disabled-account, and policy results to non-enumerating responses.
        return result;
      });

    export const MagicLinkLive = MagicLinkLoginLive({
      makeUrl: ({ challengeId, secret }) => {
        const url = new URL("/magic-link", "https://app.example.com");
        url.searchParams.set("challengeId", challengeId);
        url.searchParams.set("secret", Redacted.value(secret));
        return url.toString();
      },
    });
    ```

    Store pending eligibility state with the same lifetime as the auth challenge and protect its integrity. A successful `verify` has already created the `AuthFlow` session from evidence, deriving `aal1`, `amr: ["magic_link"]`, and `verifiedIdentityKinds: ["email"]`; primitives return that issued session but do not write a cookie. The primitive start result includes `challengeId`, but a public start response should omit it because recipients obtain both credentials from the generated URL.

    `MagicLinkLoginLive` provides `MagicLinkLogin`. It requires `Challenge`, `UserStore`, `Crypto`, `AuthFlow`, and `AuthMailer`; `AuthFlow` in turn needs the session and policy services in your auth runtime. `makeUrl` is required and must use a fixed trusted origin.

    <CalloutContainer type="error">
      <CalloutTitle>
        Primitives do not apply HTTP boundary security
      </CalloutTitle>

      <CalloutDescription>
        Direct service calls do not run `AuthRateLimit`, origin checks, schema validation, trusted-device cookie extraction, or session-cookie commitment. Those controls become application responsibilities.
      </CalloutDescription>
    </CalloutContainer>

    **The library owns:** secure secret generation, challenge issuance and verification, delivery, verified-user resolution, and typed domain errors.

    **Your application owns:** transport, eligibility, policy, result commitment, error mapping, auditing, and rate limiting.
  </Tab>
</IntegrationLevelTabs>

## Built-in contract [#built-in-contract]

| Route                          | Request                                          | Success                     |
| ------------------------------ | ------------------------------------------------ | --------------------------- |
| `POST /auth/magic-link/start`  | `email`; optional `secret`, `locale`, `metadata` | `{ email, expiresAt }`      |
| `POST /auth/magic-link/verify` | `challengeId`, `secret`                          | Auth result or continuation |

Start deliberately does not expose `challengeId` over HTTP. `makeUrl` receives it together with the redacted secret, and the emailed URL carries both values to the callback page.

## Lifecycle [#lifecycle]

<Steps>
  1. Start generates a random secret and issues a `magic-link` challenge for the
     email. 2. The application URL builder receives the challenge ID, secret,
     expiry, locale, and metadata. 3. `AuthMailer` sends the resulting URL.
     URL-building or delivery failure consumes the new challenge. 4. The recipient
     opens the application callback page, which posts `challengeId` and `secret` to
     verify. 5. A valid link creates a verified user when none exists, or marks the
     existing user's email verified. Disabled users are not authenticated. 6.
     `AuthFlow` either authenticates and commits a session cookie or returns a
     continuation.
</Steps>

The default is combined sign-in and registration. Invite-only and existing-user-only products must enforce eligibility at both start and verify boundaries; checking only start leaves room for races and alternate callers.

## Callback design [#callback-design]

Email scanners and link-preview systems commonly perform `GET` requests. Keep the email URL pointed at an application page and perform verification through the built-in `POST` endpoint. Do not make a GET callback consume the credential automatically.

After parsing the URL:

* reject missing or duplicated credential parameters,
* replace the URL before loading third-party scripts or navigating,
* submit verification once and disable query-library retries,
* redirect only to a server-validated, same-origin destination,
* show the same recovery message for invalid, expired, and consumed links.

## Handle continuations [#handle-continuations]

```ts
const result = await auth.magicLink.verify({ challengeId, secret });

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;
}
```

Magic-link verification proves the email, but custom `AuthFlow` policy can still produce configured continuation states. Do not collapse them into a generic failure.

## Security defaults [#security-defaults]

| Setting or operation | Default                          |
| -------------------- | -------------------------------- |
| Generated secret     | 32 random bytes                  |
| Challenge lifetime   | 15 minutes                       |
| Assurance            | `aal1`                           |
| Start rate limit     | 10/IP and 5/email per 10 minutes |
| Verify rate limit    | 20/IP per 10 minutes             |

* Build links from a fixed, trusted HTTPS origin. Never derive the host from an untrusted request header.
* Avoid third-party resources on the callback page until the URL is scrubbed; query strings can leak through referrers and telemetry.
* Treat forwarding, mailbox compromise, and cross-device use as part of the threat model. Magic links are not phishing-resistant.
* Allowlist metadata and redirect destinations. Metadata survives with the challenge and can enter auth policy.
* Keep start responses generic if account existence is sensitive. The default flow supports registration and therefore sends to any valid email.

## HTTP errors [#http-errors]

| Code                  | Status | Typical cause                                                 |
| --------------------- | -----: | ------------------------------------------------------------- |
| `bad_request`         |    400 | Invalid email, challenge ID, secret, or payload               |
| `invalid_credentials` |    401 | Wrong, expired, consumed, or mismatched link credential       |
| `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 | URL construction, storage, crypto, mail, or auth-flow failure |

Use one public message for wrong, expired, already-used, and cross-type challenges. Offer a fresh-link action rather than exposing challenge state.

## Testing checklist [#testing-checklist]

* Start sends a URL containing the matching challenge ID and secret but omits the challenge ID from the HTTP response.
* URL-construction and delivery failures consume the issued challenge.
* Wrong, expired, consumed, and cross-type credentials produce the same public error.
* A valid link is single-use; automatic callback retries are disabled.
* 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.
* Callback URLs are HTTPS, use a fixed host, are scrubbed early, and cannot open-redirect.
* Start and verify rate limits cover normalized email and IP keys.
* Logs, traces, analytics, referrers, and snapshots contain no full magic-link URL or secret.

## Related documentation [#related-documentation]

* [Email OTP](/authentication/email-otp/)
* [Security Policies](/concepts/security-policies/)
* [Sessions](/concepts/sessions/)
* [MFA](/authentication/mfa/)
* [Step-up Authentication](/authentication/step-up/)
* [Testing](/guides/testing/)

