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.
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.setfor 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/emailswithAuthorization: Bearer+ asending_accesskey (ROVELA_API_KEYin server env—never commit it, never prefix it withPUBLIC_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
- Issue throttle — e.g. one new link per email per minute; also cap by IP in the request endpoint.
- 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 — the
.astropage may read?token=; only the POSTAPIRoutecreates 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 link | Remix magic link | Next.js magic link | |
|---|---|---|---|---|
| Server entry | src/pages/api/** APIRoute + SSR pages | +page.server.ts actions / load; optional +server.ts | Route action / loader | App Router Route Handlers / Server Actions |
| Session | context.cookies.set (+ your store) | cookies.set (+ your store) | Remix cookie session storage | Next cookies / your auth lib |
| UI form | Progressive <form method="POST"> → API route | Progressive form + use:enhance | Progressive Remix <Form method="post"> | Server Actions / client fetch |
| Focus | Passwordless login in Astro SSR | Passwordless login in SvelteKit | Passwordless login in Remix | Passwordless 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 link | OTP (six digits) | |
|---|---|---|
| Docs shape | Same as password reset | Verification codes |
| Astro placement | /auth/magic + /api/auth/magic/* | Separate OTP API routes |
| 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; for Nuxt OTP see Nuxt 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—neverPUBLIC_ROVELA_API_KEY.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 endpoint; 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, 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
- Implement token + Astro
APIRoutehandlers 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: SvelteKit · Remix · Next.js · NestJS · FastAPI · Rails.
- 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.