Magic Links
Add passwordless authentication with short-lived bearer links delivered by email.
Magic links are a passwordless primary factor. A valid link verifies a global email identity and can create a user or verify an existing identity before AuthFlow produces an authenticated session or continuation.
HTTP Operations
MagicLinkHttpOperations exposes the standard start and verify workflows while your application owns routes, public schemas, middleware, redirect policy, and eligibility checks.
Configure the feature
MagicLinkHttpOperationsLive consumes a configured MagicLinkLogin. Build the callback URL on a fixed trusted origin and keep both credentials out of the initial HTTP request by placing them in the fragment:
import {
AuthKernelFromPrimitivesLayer,
AuthKernelPrimitivesLayer,
} from "@effect-auth/core/AuthKernel";
import { AuthRateLimitStandardLive } from "@effect-auth/core/AuthRateLimit";
import { HttpAuthenticationCapabilitiesLive } from "./http-authentication-capabilities.live.js";
import { MagicLinkHttpOperationsLive } from "@effect-auth/core/HttpApi/MagicLink";
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();
};
export const MagicLinkFeatureLive = magicLinkLayerNoDeps({
makeUrl: makeMagicLinkUrl,
ttl: Duration.minutes(15),
secretBytes: 32,
});
const AuthKernelLayer = AuthKernelFromPrimitivesLayer.pipe(
Layer.provideMerge(AuthKernelPrimitivesLayer)
);
export const AppMagicLinkServicesLive = MagicLinkFeatureLive.pipe(
Layer.provideMerge(AuthKernelLayer),
Layer.provideMerge(AppAuthRuntimeLive)
);
export const AppMagicLinkHttpOperationsLive = MagicLinkHttpOperationsLive.pipe(
Layer.provide(AuthRateLimitStandardLive()),
Layer.provide(HttpAuthenticationCapabilitiesLive),
Layer.provide(Layer.mergeAll(AppMagicLinkServicesLive, AppRateLimitLive))
);| Option | Default | Purpose |
|---|---|---|
ttl | 15 minutes | Default challenge lifetime |
secretBytes | 32 bytes | Generated secret size |
identityIdBytes | 16 bytes | Random-token size for pending identity IDs |
userIdBytes | 16 bytes | Random-token size for auto-created user IDs |
makeUrl | Required | Application-owned callback URL construction |
The domain start method accepts a trusted per-request ttl override, but it always generates the bearer secret on the server. Configured and per-start lifetimes must be integer milliseconds from 1ms through 7 days. Generated ID and secret options must be safe integers from 16 through 128 bytes; invalid Layers fail before identity, random, challenge, or delivery work.
The URL is a bearer credential
Do not log the URL, fragment or query string, challenge secret, or mail payload. Keep them out of traces, analytics, referrers, support tooling, and error reports.
Define the contract
import {
AuthOriginCheckMiddleware,
AuthSchemaErrorMiddleware,
} from "@effect-auth/core/HttpApi";
import {
magicLinkStartEndpoint,
magicLinkVerifyEndpoint,
} from "@effect-auth/core/HttpApi/MagicLink";
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
) {}To expose { email } instead of the built-in identity object, define an application endpoint and map it to payload.identity. Derive the global email kind and scope, locale, metadata, and redirect destination server-side.
Bind the operations
import { MagicLinkHttpOperations } from "@effect-auth/core/HttpApi/MagicLink";
import { Effect, Layer } from "effect";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { AppAuthApi } from "./auth-api.js";
import { AppMagicLinkHttpOperationsLive } from "./magic-link-services.live.js";
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(AppMagicLinkHttpOperationsLive));Trusted-device support is selected through the required HttpAuthenticationCapabilities assembly when MagicLinkHttpOperationsLive is built. Use the tagged enabled choice carrying the cookie service or choose disabled explicitly; ambient cookie services are ignored.
Mount the application API
import {
AuthHttpApiConfigLive,
AuthOriginCheckMiddlewareLive,
AuthSchemaErrorMiddlewareLive,
} from "@effect-auth/core/HttpApi";
import { Layer } from "effect";
import { HttpServer } from "effect/unstable/http";
import { HttpApiBuilder } from "effect/unstable/httpapi";
import { AppAuthApi } from "./auth-api.js";
import { AppMagicLinkHttpApiGroupLive } from "./magic-link-api-group.live.js";
export const AppAuthHttpApiLive = HttpApiBuilder.layer(AppAuthApi).pipe(
Layer.provide(AppMagicLinkHttpApiGroupLive),
Layer.provide(
AuthOriginCheckMiddlewareLive({
mode: "secure",
origins: ["https://app.example.com"],
})
),
Layer.provide(AuthSchemaErrorMiddlewareLive),
Layer.provide(
AuthHttpApiConfigLive({
originPolicy: {
mode: "secure",
origins: ["https://app.example.com"],
},
requestMetadata: {
ipSource: { _tag: "CloudflareConnectingIp" },
},
})
),
Layer.provide(HttpServer.layerServices)
);Enable trusted proxy headers only behind a controlled reverse proxy.
The library owns: domain orchestration, standard rate limits, result mapping, continuations, and cookie commitment.
Your application owns: endpoint selection, public schemas, origin policy, redirect policy, callback UX, and user eligibility.
Built-in contract
| Route | Request | Success |
|---|---|---|
POST /auth/magic-link/start | identity; optional locale, metadata, botChallenge | { identity, expiresAt } |
POST /auth/magic-link/verify | challengeId, secret; optional botChallenge | Auth result or continuation |
The start response intentionally omits challengeId; the recipient gets both credentials through makeUrl. Magic Link currently requires a global email identity. Although the schema includes botChallenge, the standard Magic Link policy skips bot verification unless the application installs a custom guard or handler.
Lifecycle
Prepare the identity. Start validates and normalizes a global email identity and creates a pending identity ID when needed.
Issue the challenge. The service generates a secret and stores a
short-lived magic-link challenge.
Build and deliver the URL. The application URL builder receives the
credentials and AuthMailer sends the link. Failure consumes the challenge.
Confirm user intent. The callback page scrubs the URL and waits for an explicit user action before posting the credentials once.
Consume and resolve. A valid secret consumes the challenge before the service creates or resolves the verified email identity.
Complete authentication. AuthFlow produces an issued session or a
continuation, and the HTTP operation commits an authenticated cookie.
The default combines sign-in and registration. Invite-only and existing-user-only products must enforce eligibility before start and again before verify; checking only start leaves races and alternate callers.
Valid credentials are consumed first
A wrong secret does not consume the link. A correct secret is consumed before identity resolution, registration, policy, and session creation. If later work fails or the account is disabled, the user needs a fresh link; do not automatically retry verify.
Callback design
Point the email at an application page, not a GET endpoint that verifies immediately. A POST protects against simple GET-only scanners, but not scanners that execute JavaScript, so require an explicit user action.
const params = new URLSearchParams(window.location.hash.slice(1));
const challengeIds = params.getAll("challengeId");
const secrets = params.getAll("secret");
if (challengeIds.length !== 1 || secrets.length !== 1) {
throw new Error("Invalid magic-link credentials");
}
const credentials = {
challengeId: challengeIds[0]!,
secret: secrets[0]!,
};
history.replaceState(null, "", window.location.pathname);
// Call verify once from an explicit Continue/Sign in button handler.Scrub the URL before loading analytics or third-party scripts. Disable prefetch, automatic mutation retries, and replay. Redirect only to a server-validated same-origin destination. A fragment avoids the initial request log and Referer, but it does not protect against JavaScript-capable scanners or client-side telemetry that runs too early.
Handle continuations
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 control of the email, but custom AuthFlow policy can still produce configured continuation states.
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; Core does not validate the URL returned by
makeUrl. - 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 sends to any valid global email.
HTTP errors
| Code | Status | Typical cause |
|---|---|---|
bad_request | 400 | Invalid HTTP payload, challenge ID, or secret shape |
invalid_credentials | 401 | Wrong, expired, consumed, or mismatched link credential |
policy_denied | 403 | Auth or application policy rejected the request |
step_up_required | 403 | Application 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, identity, storage, mail, or auth-flow failure |
Domain failures during start, including an unsupported identity kind or scope, are currently mapped to internal_error. Standard Magic Link operations do not emit step_up_required unless the application adds a corresponding boundary policy.
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 even when identity, policy, or session work later fails.
- Callback credentials are scrubbed before third-party code and verification requires user activation.
- Automatic verify retries, prefetch, and mutation replay are disabled.
- Existing unverified identities 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 use a fixed trusted HTTPS origin 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.