← Back to blog

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.

Updated November 4, 2026
Magic Link Login in SvelteKit (2026) with a Plain HTTP Email API

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.set for 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/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY via $env/dynamic/private or process env—never commit it, never put it in $env/static/public or 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

  1. Issue throttle — e.g. one new link per email per minute; also cap by IP in the request action.
  2. Short TTL — ~15–30 minutes for login; shorter is fine for step-up.
  3. Hash at rest — store SHA-256 hex only; raw token lives in the email URL.
  4. Upsert — new request invalidates the previous unused hash for that user.
  5. Consume atomically — DELETE … RETURNING (or locked delete) so double-click races fail safely.
  6. Enumeration-safe request — same status and body whether the address exists.
  7. Idempotency-Key — stable per logical send (magic- + hash prefix); new token → new key.
  8. Prefetch-safe redeem — load may read ?token=; only the POST action / +server.ts creates 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 linkNext.js magic link
Server entry+page.server.ts actions / load; optional +server.tsRoute action / loaderApp Router Route Handlers / Server Actions
Sessioncookies.set (+ your store)Remix cookie session storageNext cookies / your auth lib
UI formProgressive <form method="POST"> + use:enhanceProgressive Remix <Form method="post">Server Actions / client fetch
FocusPasswordless login in SvelteKitPasswordless login in RemixPasswordless 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 linkOTP (six digits)
Docs shapeSame as password resetVerification codes
SvelteKit placement/auth/magic + /auth/magic/requestSeparate OTP routes / actions
UXOne click (+ Continue)Type digits
StorageSHA-256 of high-entropy tokenHMAC of email:code + pepper
Typical TTL~30 minutes~10 minutes
Good forDesktop/mobile browser sign-inShared 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

  1. Domain verified; SPF/DKIM/DMARC green for the auth subdomain.
  2. Click tracking off on that subdomain (or a dedicated security domain).
  3. ROVELA_API_KEY with sending_access only in private server env—never in $env/static/public.
  4. MAGIC_FROM uses the verified subdomain; html + text both present; full URL in text.
  5. Idempotency-Key on every logical send; redeem is delete-and-return.
  6. Enumeration-safe request action; rate-limit request (and redeem).
  7. Treat send 200 as 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

  1. Implement token + SvelteKit form actions from the password-reset example.
  2. Send with POST /emails · Quick Start · Authentication.
  3. Harden deliverability: OTP / magic-link spam.
  4. Compare UX primitives: code vs link.
  5. Sibling stacks: Remix · Next.js · NestJS · FastAPI · Rails.
  6. 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.