effect-auth

Magic Links

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"].

Choose the highest useful level

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

Preset

Mount the built-in API

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

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.

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

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

export const auth = createAuthClient({
  requestInit: { credentials: "include" },
});
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.

HTTP Operations

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.

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) {}
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.

Primitives

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.

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.

Primitives do not apply HTTP boundary security

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

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.

Built-in contract

RouteRequestSuccess
POST /auth/magic-link/startemail; optional secret, locale, metadata{ email, expiresAt }
POST /auth/magic-link/verifychallengeId, secretAuth 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

  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.

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

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

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

Setting or operationDefault
Generated secret32 random bytes
Challenge lifetime15 minutes
Assuranceaal1
Start rate limit10/IP and 5/email per 10 minutes
Verify rate limit20/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

CodeStatusTypical cause
bad_request400Invalid email, challenge ID, secret, or payload
invalid_credentials401Wrong, expired, consumed, or mismatched link credential
policy_denied403Auth or application policy rejected the request
step_up_required403Boundary policy requires stronger authentication
request_rejected403Origin validation rejected the request
rate_limited429A configured security rule was exceeded
internal_error500URL 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

  • 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.

On this page