Magic Link Login in Remix (2026) with a Plain HTTP Email API
Build passwordless magic-link login in Remix: crypto tokens hashed in your DB, route action + loader, cookie session on POST redeem, fetch POST /emails with Idempotency-Key—Rovela delivers only.
Why Remix + a plain HTTP email API
Remix (the React Router framework) already gives passwordless login a clean shape: a form that POSTs to a route action, a small server module that mints a token and upserts a row, and a verify route whose loader reads ?token= while a separate action spends it and commits a session cookie. You do not need a Rovela Remix plugin, a special product endpoint for magic links, or a Cloudflare Worker in front just to send mail.
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 Remix app owns cryptography, the database row (or session-adjacent store), expiry, single-use redeem, and the session cookie. Rovela only delivers the mail.
This article is Remix / TypeScript focused (route action + loader, createCookieSessionStorage, Node crypto, server-side fetch to Rovela). It is not a rehash of Magic Link Login in Next.js (App Router Route Handlers) or Send transactional email from Cloudflare Workers (edge fetch without Remix loaders). 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 Remix 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) + session cookie via Remix session storage
- 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_KEYin server env—never commit it, never put it in client bundles or public 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 the Node/Bun process env that serves Remix—not in import.meta.env values that ship to the browser:
// app/lib/env.server.ts
export function requireEnv(name: string): string {
const v = 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
// app/lib/rovela.server.ts
import { requireEnv } from "./env.server";
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 Response("send_failed", { status: 502 });
}
return (await res.json()) as { object: string; id: string };
}
Auth: Authentication · wire format: API: emails · first send: Quick Start.
Session cookie (Remix)
// app/sessions.server.ts
import { createCookieSessionStorage } from "@remix-run/node"; // or react-router/node
import { requireEnv } from "./lib/env.server";
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: "__acme_session",
httpOnly: true,
path: "/",
sameSite: "lax",
secrets: [requireEnv("SESSION_SECRET")],
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 14,
},
});
export async function getSession(request: Request) {
return sessionStorage.getSession(request.headers.get("Cookie"));
}
export async function commitSession(session: Awaited<ReturnType<typeof getSession>>) {
return sessionStorage.commitSession(session);
}
Token helper: hash only, short TTL
// app/auth/magic-token.server.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
// app/auth/magic.server.ts
import { createMagicToken, hashToken } from "./magic-token.server";
import { sendEmail } from "../lib/rovela.server";
import { requireEnv } from "../lib/env.server";
// import { db } from "../db.server"; // your Prisma / Drizzle / SQL
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 db.user.findUnique({ where: { email } });
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 db.magicLogin.upsert({
// where: { userId: user.id },
// create: { userId: user.id, tokenHash, expiresAt },
// update: { tokenHash, expiresAt },
// });
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
// so two parallel clicks cannot both succeed.
return await consumeMagicLogin(tokenHash); // your atomic delete-and-return
}
// 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.
Route action: request a link
// app/routes/auth.magic.request.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { requestMagicLink } from "../auth/magic.server";
export async function action({ request }: ActionFunctionArgs) {
if (request.method !== "POST") {
throw new Response("method_not_allowed", { status: 405 });
}
// 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 json({ error: "invalid_email" }, { status: 400 });
}
const payload = await requestMagicLink(email);
return json(payload);
}
export default function MagicRequestPage() {
const data = useActionData<typeof action>();
const nav = useNavigation();
const busy = nav.state !== "idle";
return (
<Form method="post">
<label>
Work email
<input name="email" type="email" required autoComplete="email" />
</label>
<button type="submit" disabled={busy}>
Email me a sign-in link
</button>
{data && "message" in data ? <p>{data.message}</p> : null}
{data && "error" in data ? <p>{String(data.error)}</p> : null}
</Form>
);
}
Remix runs this action on the server. The Rovela key never leaves that process. Prefer a progressive-enhancement <Form method="post"> so the flow works without client JS; you can still fetch the same action from a SPA-style client if you need JSON.
Verify route: loader shows token, action redeems (POST)
Prefer POST redeem over creating a session on GET: email scanners and link prefetchers often hit GET URLs.
// app/routes/auth.magic.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useLoaderData, useActionData } from "@remix-run/react";
import { redeemMagicToken } from "../auth/magic.server";
import { commitSession, getSession } from "../sessions.server";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const token = url.searchParams.get("token") ?? "";
// Do NOT create a session here — scanners prefetch GET.
return json({ token });
}
export async function action({ request }: ActionFunctionArgs) {
const form = await request.formData();
const token = String(form.get("token") ?? "");
const userId = await redeemMagicToken(token);
if (!userId) {
return json({ error: "expired_or_used" }, { status: 400 });
}
const session = await getSession(request);
session.set("userId", userId);
// Optional: session.unset("…") / regenerate by creating a fresh session storage commit
return redirect("/app", {
headers: { "Set-Cookie": await commitSession(session) },
});
}
export default function MagicVerifyPage() {
const { token } = useLoaderData<typeof loader>();
const data = useActionData<typeof action>();
return (
<div>
<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">
<input type="hidden" name="token" value={token} />
<button type="submit" disabled={!token}>
Continue
</button>
</Form>
{data && "error" in data ? <p>This link expired or was already used.</p> : null}
</div>
);
}
If you must support a GET landing page without a button, still treat GET as display-only and issue the session only after an explicit user action (the form above).
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 Remix 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 — loader may read
?token=; only the POST action creates the session.
curl equivalent (agents / smoke tests)
Same wire format your Remix 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).
Remix vs Next.js vs Workers (clarity check)
| This article (Remix) | Next.js magic link | Workers transactional | |
|---|---|---|---|
| Server entry | Route action / loader | App Router Route Handlers | Worker fetch handler |
| Session | Remix cookie session storage | Next cookies / your auth lib | Usually origin or KV—not Remix sessions |
| UI form | Progressive <Form method="post"> | Server Actions / client fetch | Optional Worker HTTP API only |
| Focus | Passwordless login in Remix | Passwordless login in Next | Generic edge send (OTP/receipt/etc.) |
All three call the same POST /emails. Do not confuse Remix magic-link routes with Cloudflare Email Workers.
Magic link vs OTP in the same Remix app
| Magic link | OTP (six digits) | |
|---|---|---|
| Docs shape | Same as password reset | Verification codes |
| Remix 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.
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 server secrets—never in Vite public env.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 (OAuth2, Remix Auth deep-dive, MFA).
- Not a duplicate of the 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 + Remix 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: Next.js · NestJS · FastAPI · Rails.
- Limits: Pricing.
Bottom line: put magic login in Remix route actions that hash crypto.randomBytes tokens, commit 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.