Magic Link Login in SvelteKit (2026) with a Plain HTTP Email API
Build passwordless magic-link login in SvelteKit: form actions and +server routes, hashed tokens in your DB, cookie session on POST redeem, fetch POST /emails with Idempotency-Key—Rovela delivers only.
Why SvelteKit + a plain HTTP email API
SvelteKit already has a passwordless shape that fits magic links: a page with a progressive <form method="POST">, a +page.server.ts form action that mints a hashed token and sends mail, and a verify page whose load only reads ?token= while a second form action (or a small +server.ts POST) spends the token and sets an httpOnly session cookie. You do not need a Rovela SvelteKit plugin, a product endpoint named “magic link,” or a Remix-style loader/action export pair.
What you need is the same token recipe as password reset links: your app creates a single-use token with crypto.randomBytes, stores a SHA-256 of it, puts the raw token only in the email URL, and fetch POST /emails delivers the message. Rovela queues and DKIM-signs; it does not mint sessions or validate tokens.
Rovela does not provide a magic-link feature. Your SvelteKit app owns cryptography, the database row, expiry, single-use redeem, and the cookie. Rovela only delivers the mail.
This article is SvelteKit / TypeScript focused (actions in +page.server.ts, optional +server.ts, $env/dynamic/private, cookies.set, Node crypto, server-side fetch). It is not a rehash of Magic Link Login in Remix (Remix loader/action modules) or Magic Link Login in Next.js (App Router Route Handlers / Server Actions). For Nest modules see NestJS magic link; for FastAPI see FastAPI magic link; for Rails see Rails magic link. Product-agnostic recipe: Magic Login Email Links. Code-vs-link choice: Email verification: code vs link.
What you own vs what Rovela sends
Your SvelteKit app owns:
crypto.randomBytes(32).toString("base64url")as the raw token- SHA-256 hex of the token in your database (never store the raw token)
- Expiry (the docs example uses ~30 minutes for reset-style links)
- Absolute URL on your origin:
https://app.example.com/auth/magic?token=… - Atomic redeem (delete-and-return) +
cookies.setfor the session - Identical action responses whether or not the email is registered
- Rate limits on request and redeem actions
Rovela owns:
POST https://api.rovela.dev/emailswithAuthorization: Bearer+ asending_accesskey (ROVELA_API_KEYvia$env/dynamic/privateor process env—never commit it, never put it in$env/static/publicor any client-visible Vite env)- Queueing, signing, retries, bounce/complaint handling
A 200 from the send endpoint means accepted and queued, not “in the inbox.”
Env and a thin Rovela client (fetch)
Keep secrets in private env that only server modules can read:
// src/lib/server/env.ts
import { env } from "$env/dynamic/private";
export function requireEnv(name: string): string {
const v = env[name] ?? process.env[name];
if (!v) throw new Error(`missing_env:${name}`);
return v;
}
// ROVELA_API_KEY=re_... // sending_access
// MAGIC_FROM=Acme <security@mail.acme.com>
// MAGIC_LINK_BASE_URL=https://app.example.com
// MAGIC_TTL_MINUTES=30
// SESSION_SECRET=long-random-string
// src/lib/server/rovela.ts
import { requireEnv } from "./env";
export async function sendEmail(opts: {
to: string;
subject: string;
html: string;
text: string;
idempotencyKey: string;
}) {
const apiKey = requireEnv("ROVELA_API_KEY"); // sending_access
const from = process.env.MAGIC_FROM ?? "Acme <security@mail.acme.com>";
const res = await fetch("https://api.rovela.dev/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
"Idempotency-Key": opts.idempotencyKey.slice(0, 256),
},
body: JSON.stringify({
from,
to: opts.to,
subject: opts.subject,
html: opts.html,
text: opts.text,
}),
});
if (!res.ok) {
// Do not log the API key.
console.error("rovela_send_failed", res.status);
throw new Error("send_failed");
}
return (await res.json()) as { object: string; id: string };
}
Auth: Authentication · wire format: API: emails · first send: Quick Start.
Session cookie (SvelteKit cookies)
SvelteKit does not force Remix’s createCookieSessionStorage. A signed opaque session id (or a sealed cookie) is enough for this recipe—store the mapping server-side if you use a random id:
// src/lib/server/session.ts
import type { Cookies } from "@sveltejs/kit";
import crypto from "node:crypto";
const COOKIE = "__acme_session";
export function setSessionCookie(cookies: Cookies, userId: string) {
// Prefer a random session id in a DB table; embedding userId raw is illustrative only.
const sessionId = crypto.randomBytes(32).toString("base64url");
// await db.session.create({ data: { id: sessionId, userId, ... } });
void userId;
cookies.set(COOKIE, sessionId, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 14,
});
return sessionId;
}
Token helper: hash only, short TTL
// src/lib/server/magic-token.ts
import crypto from "node:crypto";
export function createMagicToken(ttlMinutes = 30) {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000);
return { token, tokenHash, expiresAt };
}
export function hashToken(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}
Upsert by user so only the latest unused link works. Plain SHA-256 is enough for a high-entropy URL-safe token (unlike six-digit OTPs, which need HMAC + pepper—see verification codes).
Service: create, store, send
// src/lib/server/magic.ts
import { createMagicToken, hashToken } from "./magic-token";
import { sendEmail } from "./rovela";
import { requireEnv } from "./env";
const SAFE_MSG =
"If that address has an account, a sign-in link is on its way.";
export async function requestMagicLink(rawEmail: string) {
const email = rawEmail.trim().toLowerCase();
const user = await findUserByEmail(email); // your store
if (user) {
const ttl = Number(process.env.MAGIC_TTL_MINUTES ?? "30");
const { token, tokenHash, expiresAt } = createMagicToken(ttl);
await upsertMagicLogin(user.id, tokenHash, expiresAt);
const base = requireEnv("MAGIC_LINK_BASE_URL").replace(/\/$/, "");
const link = `${base}/auth/magic?token=${token}`;
await sendEmail({
to: email,
subject: "Your Acme sign-in link",
html:
`<p>Use this link to sign in to Acme.</p>` +
`<p><a href="${link}">Sign in</a></p>` +
`<p>The link works once and expires in 30 minutes. ` +
`If you did not request it, ignore this email.</p>`,
text:
`Use this link to sign in to Acme.\n\n` +
`Sign in: ${link}\n\n` +
`The link works once and expires in 30 minutes.`,
idempotencyKey: `magic-${tokenHash.slice(0, 32)}`,
});
}
// Same answer whether or not the account exists.
return { message: SAFE_MSG };
}
export async function redeemMagicToken(token: string) {
if (!token || token.length < 16) return null;
const tokenHash = hashToken(token);
// Prefer DELETE … WHERE token_hash = $1 AND expires_at > NOW() RETURNING user_id
return await consumeMagicLogin(tokenHash);
}
// Placeholders — replace with your DB:
declare function findUserByEmail(email: string): Promise<{ id: string } | null>;
declare function upsertMagicLogin(
userId: string,
tokenHash: string,
expiresAt: Date,
): Promise<void>;
declare function consumeMagicLogin(tokenHash: string): Promise<string | null>;
Always send both html and text, with the full URL in the text part. Use a verified subdomain for auth (security@mail.…). Derive Idempotency-Key from the token hash so a timeout retry does not enqueue a second message; a deliberate “resend” means a new token and a new key.
Form action: request a link (+page.server.ts)
// src/routes/auth/magic/request/+page.server.ts
import type { Actions } from "./$types";
import { fail } from "@sveltejs/kit";
import { requestMagicLink } from "$lib/server/magic";
export const actions: Actions = {
default: async ({ request }) => {
// Also throttle by email + IP (Redis / Upstash / your rate limiter).
const form = await request.formData();
const email = String(form.get("email") ?? "");
if (!email.includes("@")) {
return fail(400, { error: "invalid_email" });
}
const payload = await requestMagicLink(email);
return payload; // { message }
},
};
<!-- src/routes/auth/magic/request/+page.svelte -->
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData } from "./$types";
export let form: ActionData;
</script>
<form method="POST" use:enhance>
<label>
Work email
<input name="email" type="email" required autocomplete="email" />
</label>
<button type="submit">Email me a sign-in link</button>
{#if form?.message}<p>{form.message}</p>{/if}
{#if form?.error}<p>{form.error}</p>{/if}
</form>
The form action runs on the server. The Rovela key never leaves that process. Prefer a progressive-enhancement <form method="POST"> (optionally with use:enhance) so the flow works without client JS. If you need a JSON API for a SPA island, add a +server.ts that calls the same requestMagicLink helper—do not duplicate crypto.
Verify page: load shows token, action redeems (POST)
Prefer POST redeem over creating a session on GET: email scanners and link prefetchers often hit GET URLs.
// src/routes/auth/magic/+page.server.ts
import type { Actions, PageServerLoad } from "./$types";
import { fail, redirect } from "@sveltejs/kit";
import { redeemMagicToken } from "$lib/server/magic";
import { setSessionCookie } from "$lib/server/session";
export const load: PageServerLoad = async ({ url }) => {
const token = url.searchParams.get("token") ?? "";
// Do NOT create a session here — scanners prefetch GET.
return { token };
};
export const actions: Actions = {
default: async ({ request, cookies }) => {
const form = await request.formData();
const token = String(form.get("token") ?? "");
const userId = await redeemMagicToken(token);
if (!userId) {
return fail(400, { error: "expired_or_used" });
}
setSessionCookie(cookies, userId);
throw redirect(303, "/app");
},
};
<!-- src/routes/auth/magic/+page.svelte -->
<script lang="ts">
import { enhance } from "$app/forms";
import type { ActionData, PageData } from "./$types";
export let data: PageData;
export let form: ActionData;
</script>
<h1>Continue sign-in</h1>
<p>Click once to finish signing in. This step stops email scanners from burning the link.</p>
<form method="POST" use:enhance>
<input type="hidden" name="token" value={data.token} />
<button type="submit" disabled={!data.token}>Continue</button>
</form>
{#if form?.error}<p>This link expired or was already used.</p>{/if}
Optional: JSON redeem via +server.ts
Some clients prefer fetch JSON instead of a form POST. Keep the same redeem helper—do not spend the token in GET:
// src/routes/auth/magic/redeem/+server.ts
import { json, error } from "@sveltejs/kit";
import type { RequestHandler } from "./$types";
import { redeemMagicToken } from "$lib/server/magic";
import { setSessionCookie } from "$lib/server/session";
export const POST: RequestHandler = async ({ request, cookies }) => {
const body = (await request.json().catch(() => null)) as { token?: string } | null;
const token = body?.token ?? "";
const userId = await redeemMagicToken(token);
if (!userId) throw error(400, "expired_or_used");
setSessionCookie(cookies, userId);
return json({ ok: true });
};
For ownership confirmation (not login), the same service shape applies with a different table and path—see Email verification links. Password reset is the docs twin: Secure password reset email and password-reset example.
Keep click tracking off for the login URL
Open/click tracking is a per-domain setting. When click tracking is on, Rovela rewrites links through a tracking host. The login still works after the redirect, but the token takes an extra hop.
For magic links (and password resets / invite tokens), prefer a separate sending domain or subdomain with click tracking off, so the token goes straight to your SvelteKit verify route. That is the same guidance as the password-reset example. If auth mail still lands in spam, use the checklist in Why OTP and Magic-Link Emails Go to Spam.
Rate limit / single-use / expiry checklist
- Issue throttle — e.g. one new link per email per minute; also cap by IP in the request action.
- Short TTL — ~15–30 minutes for login; shorter is fine for step-up.
- Hash at rest — store SHA-256 hex only; raw token lives in the email URL.
- Upsert — new request invalidates the previous unused hash for that user.
- Consume atomically —
DELETE … RETURNING(or locked delete) so double-click races fail safely. - Enumeration-safe request — same status and body whether the address exists.
- Idempotency-Key — stable per logical send (
magic-+ hash prefix); new token → new key. - Prefetch-safe redeem —
loadmay read?token=; only the POST action /+server.tscreates the session.
curl equivalent (agents / smoke tests)
Same wire format your SvelteKit server posts—useful for local smoke tests and AI agents that call HTTP directly (AI agent send email):
curl -X POST https://api.rovela.dev/emails \
-H "Authorization: Bearer re_your_sending_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: magic-smoke-2026-11-05" \
-d '{
"from": "Acme <security@mail.acme.com>",
"to": "alice@example.com",
"subject": "Your Acme sign-in link",
"html": "<p><a href=\"https://app.acme.com/auth/magic?token=TEST\">Sign in</a></p><p>Expires in 30 minutes.</p>",
"text": "Sign in: https://app.acme.com/auth/magic?token=TEST\n\nExpires in 30 minutes."
}'
Replace TEST with a real token only in non-production. Production keys need sending_access; from must be on a verified domain (Quick Start).
SvelteKit vs Remix vs Next.js (clarity check)
| This article (SvelteKit) | Remix magic link | Next.js magic link | |
|---|---|---|---|
| Server entry | +page.server.ts actions / load; optional +server.ts | Route action / loader | App Router Route Handlers / Server Actions |
| Session | cookies.set (+ your store) | Remix cookie session storage | Next cookies / your auth lib |
| UI form | Progressive <form method="POST"> + use:enhance | Progressive Remix <Form method="post"> | Server Actions / client fetch |
| Focus | Passwordless login in SvelteKit | Passwordless login in Remix | Passwordless login in Next |
All three call the same POST /emails. Do not confuse SvelteKit form actions with Cloudflare Email Workers (Workers transactional).
Magic link vs OTP in the same SvelteKit app
| Magic link | OTP (six digits) | |
|---|---|---|
| Docs shape | Same as password reset | Verification codes |
| SvelteKit placement | /auth/magic + /auth/magic/request | Separate OTP routes / actions |
| UX | One click (+ Continue) | Type digits |
| Storage | SHA-256 of high-entropy token | HMAC of email:code + pepper |
| Typical TTL | ~30 minutes | ~10 minutes |
| Good for | Desktop/mobile browser sign-in | Shared screens, step-up |
Both call the same POST /emails. Keep separate tables—do not reuse an OTP row as a magic-link token. Product choice: code vs link. For a Hono-style OTP edge, see Hono OTP email API.
Deploy checklist
- Domain verified; SPF/DKIM/DMARC green for the auth subdomain.
- Click tracking off on that subdomain (or a dedicated security domain).
ROVELA_API_KEYwithsending_accessonly in private server env—never in$env/static/public.MAGIC_FROMuses the verified subdomain; html + text both present; full URL in text.Idempotency-Keyon every logical send; redeem is delete-and-return.- Enumeration-safe request action; rate-limit request (and redeem).
- Treat send
200as queued; use webhooks for bounces/complaints if you need delivery truth (Webhooks).
What this post is not
- Not a full production auth framework (Lucia Auth deep-dive, OAuth2, MFA).
- Not a duplicate of the Remix, Next.js, NestJS, FastAPI, Rails, or Workers posts.
- Not a claim that Rovela stores, validates, or expires your tokens.
- Not China-domestic ESP comparison or invented product features.
Next steps
- Implement token + SvelteKit form actions from the password-reset example.
- Send with POST /emails · Quick Start · Authentication.
- Harden deliverability: OTP / magic-link spam.
- Compare UX primitives: code vs link.
- Sibling stacks: Remix · Next.js · NestJS · FastAPI · Rails.
- Limits: Pricing.
Bottom line: put magic login in SvelteKit form actions that hash crypto.randomBytes tokens, set a cookie session only on POST redeem, and fetch one POST /emails to Rovela—keep click tracking off on the security domain, and never pretend the email API is an identity provider.