← Back to blog

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.

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

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/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY in 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

  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 — 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 linkWorkers transactional
Server entryRoute action / loaderApp Router Route HandlersWorker fetch handler
SessionRemix cookie session storageNext cookies / your auth libUsually origin or KV—not Remix sessions
UI formProgressive <Form method="post">Server Actions / client fetchOptional Worker HTTP API only
FocusPasswordless login in RemixPasswordless login in NextGeneric 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 linkOTP (six digits)
Docs shapeSame as password resetVerification codes
Remix 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.

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 server secrets—never in Vite public env.
  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 (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

  1. Implement token + Remix 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: Next.js · NestJS · FastAPI · Rails.
  6. 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.