← Back to blog

Magic Link Login in Astro (2026) with a Plain HTTP Email API

Build passwordless magic-link login in Astro SSR: APIRoute endpoints, 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 Astro (2026) with a Plain HTTP Email API

Why Astro + a plain HTTP email API

Astro (with SSR / output: "server") already has a passwordless shape that fits magic links: a page with a progressive <form method="POST">, a server endpoint under src/pages/api/… that exports APIRoute handlers, and a verify page whose frontmatter only reads ?token= while a second POST endpoint spends the token and sets an httpOnly session cookie. You do not need a Rovela Astro plugin, a product endpoint named “magic link,” or a SvelteKit-style +page.server.ts form-action file.

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 Astro app owns cryptography, the database row, expiry, single-use redeem, and the cookie. Rovela only delivers the mail.

This article is Astro / TypeScript focused (APIRoute in src/pages/api, server-only modules, Node crypto, adapter env, server-side fetch). It is not a rehash of Magic Link Login in Remix (Remix loader/action), Magic Link Login in SvelteKit (+page.server.ts actions), 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 Astro 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 endpoint responses whether or not the email is registered
  • Rate limits on request and redeem endpoints

Rovela owns:

  • POST https://api.rovela.dev/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY in server env—never commit it, never prefix it with PUBLIC_ so it cannot leak into the client bundle)
  • 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)

Enable SSR (output: "server") with a Node (or compatible) adapter. Keep secrets in process env that only server modules and APIRoute handlers can read—never PUBLIC_*:

// src/lib/server/env.ts
export function requireEnv(name: string): string {
  const v = process.env[name] ?? (import.meta as any).env?.[name];
  if (!v) throw new Error(`missing_env:${name}`);
  return v;
}

// ROVELA_API_KEY=re_...          // sending_access — NOT PUBLIC_
// 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 (Astro APIContext.cookies)

Astro endpoints expose cookies on the request context. 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 { APIContext } from "astro";
import crypto from "node:crypto";

const COOKIE = "__acme_session";

export function setSessionCookie(cookies: APIContext["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: import.meta.env.PROD,
    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.

API route: request a link (APIRoute POST)

// src/pages/api/auth/magic/request.ts
import type { APIRoute } from "astro";
import { requestMagicLink } from "../../../../lib/server/magic";

export const prerender = false;

export const POST: APIRoute = async ({ request }) => {
  // Also throttle by email + IP (Redis / Upstash / your rate limiter).
  const ct = request.headers.get("content-type") ?? "";
  let email = "";
  if (ct.includes("application/json")) {
    const body = (await request.json().catch(() => null)) as { email?: string } | null;
    email = String(body?.email ?? "");
  } else {
    const form = await request.formData();
    email = String(form.get("email") ?? "");
  }
  if (!email.includes("@")) {
    return new Response(JSON.stringify({ error: "invalid_email" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }
  const payload = await requestMagicLink(email);
  return new Response(JSON.stringify(payload), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
};

A progressive form can post to the same endpoint without client JS:

---
// src/pages/auth/magic/request.astro
export const prerender = false;
---
<html lang="en">
  <body>
    <form method="POST" action="/api/auth/magic/request" enctype="application/x-www-form-urlencoded">
      <label>
        Work email
        <input name="email" type="email" required autocomplete="email" />
      </label>
      <button type="submit">Email me a sign-in link</button>
    </form>
  </body>
</html>

For a JSON SPA island, fetch("/api/auth/magic/request", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email }) }) calls the same handler—do not duplicate crypto. The Rovela key never leaves the server process.

Verify page: frontmatter shows token, POST redeems

Prefer POST redeem over creating a session on GET: email scanners and link prefetchers often hit GET URLs.

---
// src/pages/auth/magic/index.astro
export const prerender = false;
const token = Astro.url.searchParams.get("token") ?? "";
// Do NOT create a session here — scanners prefetch GET.
---
<html lang="en">
  <body>
    <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" action="/api/auth/magic/redeem">
      <input type="hidden" name="token" value={token} />
      <button type="submit" disabled={!token}>Continue</button>
    </form>
  </body>
</html>
// src/pages/api/auth/magic/redeem.ts
import type { APIRoute } from "astro";
import { redeemMagicToken } from "../../../../lib/server/magic";
import { setSessionCookie } from "../../../../lib/server/session";

export const prerender = false;

export const POST: APIRoute = async ({ request, cookies, redirect }) => {
  const ct = request.headers.get("content-type") ?? "";
  let token = "";
  if (ct.includes("application/json")) {
    const body = (await request.json().catch(() => null)) as { token?: string } | null;
    token = String(body?.token ?? "");
  } else {
    const form = await request.formData();
    token = String(form.get("token") ?? "");
  }
  const userId = await redeemMagicToken(token);
  if (!userId) {
    return new Response(JSON.stringify({ error: "expired_or_used" }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    });
  }
  setSessionCookie(cookies, userId);
  // Form posts: redirect into the app. JSON clients can prefer 200 + { ok: true }.
  if (ct.includes("application/json")) {
    return new Response(JSON.stringify({ ok: true }), {
      status: 200,
      headers: { "Content-Type": "application/json" },
    });
  }
  return redirect("/app", 303);
};

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 Astro 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 endpoint.
  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 — the .astro page may read ?token=; only the POST APIRoute creates the session.

curl equivalent (agents / smoke tests)

Same wire format your Astro 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).

Astro vs SvelteKit vs Remix vs Next.js (clarity check)

This article (Astro)SvelteKit magic linkRemix magic linkNext.js magic link
Server entrysrc/pages/api/** APIRoute + SSR pages+page.server.ts actions / load; optional +server.tsRoute action / loaderApp Router Route Handlers / Server Actions
Sessioncontext.cookies.set (+ your store)cookies.set (+ your store)Remix cookie session storageNext cookies / your auth lib
UI formProgressive <form method="POST"> → API routeProgressive form + use:enhanceProgressive Remix <Form method="post">Server Actions / client fetch
FocusPasswordless login in Astro SSRPasswordless login in SvelteKitPasswordless login in RemixPasswordless login in Next

All four call the same POST /emails. Do not confuse Astro server endpoints with Cloudflare Email Workers (Workers transactional).

Magic link vs OTP in the same Astro app

Magic linkOTP (six digits)
Docs shapeSame as password resetVerification codes
Astro placement/auth/magic + /api/auth/magic/*Separate OTP API routes
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; for Nuxt OTP see Nuxt 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 PUBLIC_ROVELA_API_KEY.
  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 endpoint; 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, Auth.js, OAuth2, MFA).
  • Not a duplicate of the SvelteKit, 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 + Astro APIRoute handlers 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: SvelteKit · Remix · Next.js · NestJS · FastAPI · Rails.
  6. Limits: Pricing.

Bottom line: put magic login in Astro SSR APIRoute endpoints 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.