ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeTutorialPasswordless Login With Magic Links in Node.js
Tutorial

Passwordless Login With Magic Links in Node.js

Build token generation, Redis storage, Nodemailer email, and JWT sessions from scratch — no auth framework, no passwords, no shortcuts.

ZyVOP
ZyVOP
Senior Developer
July 3, 2026
8 min read
Passwordless Login With Magic Links in Node.js
Article
👍1

I've seen developers implement magic link auth four different ways and get it wrong three of them. The broken versions all share the same flaw: they use a regular get + del to verify tokens, which means two simultaneous requests with the same token both pass.

User A clicks the link in Gmail. The prefetch scanner in their email client hits it half a second earlier. Server issues two sessions. Neither user knows, but one of them is now authenticated in a context they didn't control.

This post builds it right. By the end you'll have a working Node.js implementation with proper atomic token verification, email enumeration protection, an Ethereal fallback so you can see the full flow without any SMTP configuration, and 19 tests that run without Redis or a real mail server.

Source code: http://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/magic-link-auth


The one decision that matters most

Before touching any code: the difference between a safe magic link implementation and a broken one is a single Redis command.

The broken pattern:

const email = await redis.get(key);
if (email) await redis.del(key);
return email;

Between the get and the del, another request can read the same key. Both get a valid email back, both get sessions issued.

getDel collapses that into one atomic operation:

const email = await redis.getDel(key);
return email ?? null;

Redis executes this as a single command. No window — one request gets the email, any concurrent request gets null.

That's why Redis fits this problem better than a Postgres row. You'd need a transaction and an advisory lock to get the same guarantee from a relational database.

The full verify function adds a length check before the Redis call. A token that's too short or too long gets rejected immediately, no round-trip needed:

// src/lib/token.js
import crypto from "node:crypto";

const TOKEN_PREFIX = "magic:";
const TOKEN_TTL_SECONDS = 15 * 60;
const TOKEN_BYTES = 32;

export async function createToken(redis, email) {
  const token = crypto.randomBytes(TOKEN_BYTES).toString("hex");
  await redis.set(TOKEN_PREFIX + token, email.toLowerCase(), { EX: TOKEN_TTL_SECONDS });
  return token;
}

export async function verifyToken(redis, token) {
  if (!token || typeof token !== "string" || token.length !== TOKEN_BYTES * 2) {
    return null;
  }
  return (await redis.getDel(TOKEN_PREFIX + token)) ?? null;
}

crypto.randomBytes(32) gives 256 bits of entropy — 64 hex characters. Brute-forcing that against a 15-minute window isn't happening. The email gets lowercased on creation so [email protected] and [email protected] don't end up as separate keys that never match.

Redis also handles expiry natively via the EX option. No cron job to clean up stale tokens, no WHERE expires_at < NOW() queries — they disappear on their own.


What the user actually sees

The sign-in page is minimal HTML with one real piece of JavaScript: it handles the ?error=link_invalid query param that the server redirects to when a token has expired or already been used.

<!-- public/index.html (abbreviated) -->
<form id="form">
  <input type="email" id="email" placeholder="[email protected]" required>
  <button type="submit">Send sign-in link</button>
</form>
<div id="msg" class="message"></div>

<script>
  const ERRORS = {
    link_invalid: "That link has expired or already been used. Please request a new one.",
    default: "Something went wrong. Please try again."
  };

  // Show error from redirect after a failed verify attempt
  const errKey = new URLSearchParams(location.search).get("error");
  if (errKey) showMsg(ERRORS[errKey] ?? ERRORS.default, "error");

  document.getElementById("form").addEventListener("submit", async (e) => {
    e.preventDefault();
    const email = document.getElementById("email").value.trim();
    const btn = e.target.querySelector("button");

    btn.disabled = true;
    btn.textContent = "Sending…";

    const res = await fetch("/auth/request", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email })
    });

    const data = await res.json();

    if (res.ok) {
      showMsg("Check your inbox — a sign-in link is on its way.", "success");
      document.getElementById("form").style.display = "none";
    } else {
      showMsg(data.error ?? ERRORS.default, "error");
      btn.disabled = false;
      btn.textContent = "Send sign-in link";
    }
  });
</script>

The ?error=link_invalid flow matters more than it looks. When verification fails — expired token, already used, someone typed a URL wrong — it redirects to /?error=link_invalid instead of returning a JSON 400.

Users arrive at /auth/verify by clicking a link in their email client, not via a fetch call. A JSON error on a blank page is a dead end; a redirect back to the sign-in form with a clear message is something a person can act on.


Sending the email without configuring SMTP

Production email uses SMTP_HOST, SMTP_PORT, SMTP_USER, and SMTP_PASS. Without those, the mailer silently creates an Ethereal test account and logs a preview URL. Ethereal is a real catch-all SMTP service — emails don't deliver anywhere, but you can open the URL and see the full rendered email, including the magic link button, right in your browser.

// src/lib/mailer.js
import nodemailer from "nodemailer";

let transporter = null;

async function getTransporter() {
  if (transporter) return transporter;

  if (process.env.SMTP_HOST) {
    transporter = nodemailer.createTransport({
      host: process.env.SMTP_HOST,
      port: Number(process.env.SMTP_PORT) || 587,
      secure: process.env.SMTP_SECURE === "true",
      auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }
    });
  } else {
    const testAccount = await nodemailer.createTestAccount();
    transporter = nodemailer.createTransport({
      host: "smtp.ethereal.email",
      port: 587,
      auth: { user: testAccount.user, pass: testAccount.pass }
    });
    console.log(`[mailer] Ethereal test account: ${testAccount.user}`);
  }

  return transporter;
}

export async function sendMagicLink(to, magicUrl) {
  const transport = await getTransporter();

  const info = await transport.sendMail({
    from: process.env.EMAIL_FROM || '"Magic Link Auth" <[email protected]>',
    to,
    subject: "Your sign-in link",
    text: `Sign-in link (expires in 15 minutes, single use):\n\n${magicUrl}`,
    html: `
      <p>Click below to sign in. Expires in <strong>15 minutes</strong>, single use.</p>
      <p style="margin:24px 0">
        <a href="${magicUrl}" style="background:#111;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none">
          Sign in
        </a>
      </p>
      <p style="color:#999;font-size:12px">Didn't request this? Safe to ignore.</p>
    `
  });

  if (!process.env.SMTP_HOST) {
    console.log(`[mailer] Preview URL: ${nodemailer.getTestMessageUrl(info)}`);
  }
}

The transporter is cached in a module-level variable. nodemailer.createTestAccount() makes a real HTTP call to Ethereal — you don't want that per-request.

For production, Resend and Postmark are the cleaner options over raw SMTP. They handle deliverability, bounce handling, and SPF/DKIM automatically. Hook them in via the SMTP_HOST and friends in .env.


Sessions, cookies, and why SameSite: lax is correct here

After verification, the token is gone and the user needs something that persists across requests. A JWT in an httpOnly cookie is the right shape: stateless (no server-side session table), survives page refreshes, inaccessible to JavaScript running on the page.

// src/lib/session.js
import jwt from "jsonwebtoken";

const COOKIE_NAME = "session";
const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;

function getSecret() {
  const secret = process.env.JWT_SECRET;
  if (!secret || secret.length < 32) {
    throw new Error("JWT_SECRET must be set and at least 32 characters long");
  }
  return secret;
}

export function issueSession(res, email) {
  const token = jwt.sign({ email }, getSecret(), { expiresIn: SESSION_TTL_SECONDS });
  res.cookie(COOKIE_NAME, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: SESSION_TTL_SECONDS * 1000
  });
}

export function requireAuth(req, res, next) {
  const token = req.cookies?.[COOKIE_NAME];
  if (!token) return res.status(401).json({ error: "Not authenticated." });

  try {
    const payload = jwt.verify(token, getSecret());
    req.user = { email: payload.email };
    next();
  } catch {
    res.clearCookie(COOKIE_NAME);
    return res.status(401).json({ error: "Session expired. Please sign in again." });
  }
}

sameSite: "lax" needs explaining here because it interacts directly with how magic links work. When a user clicks a link in their email client, that's a top-level navigation — the browser follows it like a normal page load. Lax allows the cookie to be sent on those top-level navigations from external origins.

Strict would block that, requiring another sign-in if a user arrives from any external link. None requires HTTPS everywhere and opens CSRF exposure. Lax is the right call for this pattern.

secure only goes on in production. Without that conditional, local development over HTTP would silently fail to set the cookie and you'd spend an hour wondering why sessions don't persist.


The two routes that do the work

// src/routes/auth.js
import { Router } from "express";
import { createToken, verifyToken } from "../lib/token.js";
import { sendMagicLink as defaultSendMagicLink } from "../lib/mailer.js";
import { issueSession } from "../lib/session.js";

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function createAuthRouter(redis, { sendMagicLink = defaultSendMagicLink } = {}) {
  const router = new Router();

  router.post("/request", async (req, res) => {
    const email = (req.body?.email ?? "").trim().toLowerCase();

    if (!EMAIL_RE.test(email)) {
      return res.status(400).json({ error: "A valid email address is required." });
    }

    try {
      const token = await createToken(redis, email);
      const appUrl = process.env.APP_URL || `http://localhost:${process.env.PORT || 3000}`;
      await sendMagicLink(email, `${appUrl}/auth/verify?token=${token}`);
      res.json({ ok: true, message: "Check your inbox for a sign-in link." });
    } catch (err) {
      console.error("[auth] request failed:", err);
      res.status(500).json({ error: "Failed to send the sign-in link. Please try again." });
    }
  });

  router.get("/verify", async (req, res) => {
    const email = await verifyToken(redis, req.query.token);
    if (!email) return res.redirect("/?error=link_invalid");
    issueSession(res, email);
    res.redirect("/dashboard");
  });

  router.post("/logout", (req, res) => {
    res.clearCookie("session");
    res.redirect("/");
  });

  return router;
}

/request returns 200 whether the email exists in your system or not. A 404 for unknown emails would tell anyone who tries that an address isn't registered — a user enumeration leak. The response is always "check your inbox," whether you're a real user or someone probing your database.

The sendMagicLink function is passed in as a default parameter, not imported at the top. That's the dependency injection hook tests use — swap it for a no-op, no SMTP connection required.


Running the whole thing

# Start Redis (Docker is fastest)
docker run -d -p 6379:6379 redis:alpine

# Install deps and start
npm install && cp .env.example .env && npm start

The server needs at minimum a JWT_SECRET in .env. Generate one:

node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

Request a link:

curl -X POST http://localhost:3000/auth/request \
  -H "Content-Type: application/json" \
  -d '{"email": "[email protected]"}'
{ "ok": true, "message": "Check your inbox for a sign-in link." }

Your server console will print something like:

[mailer] Preview URL: https://ethereal.email/message/WaQKMgKddxQDoou

Open that URL, click the "Sign in" button in the rendered email. You'll land on the dashboard. The token is gone — clicking the link again redirects to /?error=link_invalid.

Check your session after clicking (saves cookie to cookies.txt):

curl -c cookies.txt -b cookies.txt http://localhost:3000/api/me
{ "email": "[email protected]" }

Invalid email format:

curl -s -X POST http://localhost:3000/auth/request \
  -H "Content-Type: application/json" \
  -d '{"email": "notanemail"}'
{ "error": "A valid email address is required." }

Rate limiter — 5 requests per 15 minutes per IP, sixth gets a 429:

for i in $(seq 1 6); do
  curl -s -o /dev/null -w "request $i -> %{http_code}\n" \
    -X POST http://localhost:3000/auth/request \
    -H "Content-Type: application/json" \
    -d '{"email":"[email protected]"}'
done
request 1 -> 200
request 2 -> 200
request 3 -> 200
request 4 -> 200
request 5 -> 200
request 6 -> 429

Five is tight enough to stop inbox flooding and loose enough that a real user who typo'd their address gets a few retries.

Expired or already-used token:

curl -v "http://localhost:3000/auth/verify?token=$( python3 -c 'print("a"*64)')" 2>&1 | grep "Location:"
< Location: /?error=link_invalid

Tests

19 tests, no Redis or SMTP connection needed. The token suite runs against a plain in-memory Map mimicking the Redis interface; the session suite sets JWT_SECRET in before() and cleans up after; the route suite injects the no-op mailer:

npm test
# tests 19
# pass  19
# fail   0

If you want to verify the atomic single-use behavior beyond the unit test, run the server with a real Redis instance and hit /auth/verify with the same token from two curl commands fired in parallel:

TOKEN="paste-a-real-token-from-console-here"
curl "http://localhost:3000/auth/verify?token=$TOKEN" &
curl "http://localhost:3000/auth/verify?token=$TOKEN" &
wait

One will redirect to /dashboard. The other will redirect to /?error=link_invalid. That's getDel doing its job.


Before going live

Set NODE_ENV=production — the Secure cookie flag only activates in production, and without HTTPS the cookie won't be sent by browsers at all. Point REDIS_URL at a managed instance (Upstash has a free tier and works well with this setup). Swap Ethereal for a transactional email provider; Resend has a generous free tier and a clean Node.js SDK. Put the whole thing behind nginx or Caddy for TLS termination.

One thing the repo doesn't include but you'll want eventually: a users table or equivalent. Right now any email can request a link and get a session — there's no concept of "registered users." Adding a check in /request that verifies the email exists in your database before sending the link is one line, but the shape of that check depends on your stack.

Get the code: http://github.com/zyvop27-cmyk/zyvop-blogs/tree/main/magic-link-auth

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter
Passwordless Login With Magic Links in Node.js

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1