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.
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
import { createAuthClient } from "@effect-auth/core/Client";
export const auth = createAuthClient({
requestInit: { credentials: "include" },
});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.
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) {}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.
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
| 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
- Start generates a random secret and issues a
magic-linkchallenge for the email. 2. The application URL builder receives the challenge ID, secret, expiry, locale, and metadata. 3.AuthMailersends the resulting URL. URL-building or delivery failure consumes the new challenge. 4. The recipient opens the application callback page, which postschallengeIdandsecretto 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.AuthFloweither 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 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
| 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
- 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.