← Back to blog

Magic Link Security Best Practices (2026): TTL, Single-Use, HTTPS, Open Redirects

Security checklist for passwordless magic links: 32-byte tokens, ~30-minute TTL, atomic single-use redeem, HTTPS-only APP_ORIGIN, open-redirect prevention—your app owns tokens; Rovela POST /emails delivers.

Updated November 4, 2026
Magic Link Security Best Practices (2026): TTL, Single-Use, HTTPS, Open Redirects

Magic links are auth credentials delivered by email

A magic link is a temporary credential: whoever opens it can create a session. Rovela is not an identity provider and does not issue login tokens. Your application generates the secret, stores a hash with an expiry, builds an absolute HTTPS URL on your origin, and calls POST /emails so Rovela can queue, DKIM-sign, and deliver the message. The closest official recipe is password reset links—magic login is the same token shape with a redeem route that creates a session instead of setting a password. Product walkthroughs live in Magic login email links; this article is the security checklist (TTL, single-use, HTTPS, open-redirect prevention) so the email API does not become an account-takeover channel.

Boundary: app owns tokens, Rovela delivers

ResponsibilityOwner
Cryptographic token, hash storage, TTL, single-use redeemYour app
Absolute link host/path (https://app.example.com/...)Your app
Session cookie / JWT after redeemYour app
Rate limits, enumeration-safe responsesYour app
Accept POST https://api.rovela.dev/emails, queue, sign, retries, bounce/complaint handlingRovela

Call Rovela only from your backend with a Bearer re_… key. Prefer sending_access for production senders; never embed an API key in a browser or mobile client (Authentication). Plans and volume limits belong on Pricing—this post invents none of those numbers.

Token entropy, hash storage, and TTL

Follow the password-reset docs for link-shaped secrets:

  1. Normalize the email (trim + lowercase).
  2. Generate 32 random bytes and encode as base64url (crypto.randomBytes(32).toString("base64url")).
  3. Store only the SHA-256 hex of the token—never the raw token. Thirty-two bytes are not brute-forceable from the hash, so plain SHA-256 is enough (short OTP digits are different and need HMAC + pepper).
  4. Set expiry around 30 minutes in the same constant you print in the email copy.
  5. Put the raw token only in the emailed URL query string—not in logs, analytics, or the database.

Sketch (placeholders; full send wiring in the password-reset example):

import crypto from "node:crypto"

const token = crypto.randomBytes(32).toString("base64url")
const tokenHash = crypto.createHash("sha256").update(token).digest("hex")

await db.magicLogins.upsert({
  userId: user.id,
  tokenHash,
  expiresAt: new Date(Date.now() + 30 * 60 * 1000),
})

Shorter TTLs (for example 10–15 minutes) reduce mailbox-theft windows; longer ones raise support volume. Whatever you choose, enforce expiry in the redeem query, not only in copy.

Single-use under concurrency

Single-use fails when you SELECT then DELETE. Two parallel clicks can both pass the read. Spend the token in one statement, as the docs show for resets:

const row = await db.query(
  `DELETE FROM magic_logins
   WHERE token_hash = $1 AND expires_at > NOW()
   RETURNING user_id`,
  [tokenHash]
)
if (row.rowCount === 0) return null // expired, unknown, or already used
// create session for row.rows[0].user_id

On failure, show a generic “link expired or already used” page. Do not distinguish unknown vs expired vs spent if that helps an attacker. Invalidate prior unused tokens when issuing a new one for the same user so an older email in the thread cannot still sign in.

HTTPS-only absolute URLs on your origin

  • Build the link yourself: https://app.acme.com/login/magic?token= + token (or encodeURIComponent(token) if you expect special characters).
  • Require HTTPS in production. An http:// magic link is credential theft waiting for a network attacker.
  • Put the token in the query string, not a URL fragment. Fragments never reach the server; server-side redeem cannot read #token=….
  • Always send both html and text, with the full URL in the text part—corporate filters often strip HTML links (password-reset example).

Open-redirect prevention (do not email attacker-controlled hosts)

The emailed URL must never be assembled from an untrusted next, redirect, return_to, or continue query parameter. If an attacker can make you send https://evil.example/phish?token=… (or wrap your path so the browser lands off-origin after redeem), they turn your transactional mail into a phishing delivery system—and may steal the token from the URL bar or Referer.

Hard rules:

  1. Host allowlist in code. The scheme + host (and usually the path prefix) come from a server config constant (APP_ORIGIN=https://app.acme.com), not from the request body or query.
  2. No open redirects on the redeem route. After a successful redeem, send the user to a fixed post-login page, or allowlist relative paths on the same origin (/dashboard, /settings). Reject absolute URLs to other hosts; reject protocol-relative //evil.example/....
  3. Do not put post-login destinations in the emailed link unless they are fixed path constants you control. Prefer: email contains only token; your redeem handler decides where to navigate.
  4. Validate the Origin/Host of the redeem request the same way you would for any session-creating POST.

Bad:

// Attacker-controlled host ends up in the mailbox
const link = req.query.next + "?token=" + token

Good:

const APP_ORIGIN = "https://app.acme.com" // config, not user input
const link = APP_ORIGIN + "/login/magic?token=" + token

function safeInternalPath(candidate) {
  if (typeof candidate !== "string" || !candidate.startsWith("/")) return "/dashboard"
  if (candidate.startsWith("//") || candidate.includes("://")) return "/dashboard"
  return candidate.split("?")[0] // optional: strip query; still same-origin only
}

Enumeration-safe request handler

Return the same message and status whether or not the address has an account. Ideally remove timing differences by enqueueing the send and returning immediately (password-reset example). Rate-limit “send me a link” per address and per IP so your product is not a free spam cannon against someone else’s inbox.

Send path: Idempotency-Key, subdomain, tracking off

await fetch("https://api.rovela.dev/emails", {
  method: "POST",
  headers: {
    Authorization: "Bearer " + process.env.ROVELA_API_KEY,
    "Content-Type": "application/json",
    "Idempotency-Key": "magic-" + tokenHash.slice(0, 32),
  },
  body: JSON.stringify({
    from: "Acme <security@mail.acme.com>",
    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.</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.",
  }),
})

Docs-backed send details (API: emails, password-reset example):

  • Idempotency-Key is up to 256 characters; a retry within 24 hours returns the original response instead of sending again. Derive it from a stable prefix of the token hash for that logical send. A user tapping “resend” is a new logical send—new token, new key.
  • Use a verified sending subdomain dedicated to security mail (for example security@mail.acme.com). Unverified from domains are refused with 400.
  • Turn click tracking off on that domain. Tracking rewrites links through a redirect host; the login URL still works, but the token takes an extra hop. Prefer a straight path to your app for security mail (password-reset example). Deliverability context: Why OTP and magic-link emails go to spam.
  • A 200 means accepted and queued, not “already in the inbox.” Wire bounce/complaint webhooks if undeliverable auth mail matters (Webhooks).

Common security mistakes

MistakeRiskFix
Store plaintext tokenDB dump = mass takeoverStore SHA-256 hash only
No / soft TTLStolen mailbox usable for days~30 minutes, enforced in SQL
Read-then-delete redeemDouble redeem under concurrencyDELETE … RETURNING
http:// or wrong host in emailMITM / phishingConfigured HTTPS APP_ORIGIN only
User-supplied redirect= in emailed URLOpen redirect + phishingAllowlist same-origin paths after redeem
Click tracking on auth domainToken via extra redirectSeparate subdomain, tracking off
Different “user not found” copyAccount enumerationIdentical response + timing
API key in the clientKey theft → arbitrary sendsServer-only sending_access
No Idempotency-Key on retriesTwo live links / confusing UXKey from token-hash prefix
HTML-only bodyFilters strip the only linkAlways include text with full URL

OTP vs magic link (security lens)

OTP codeMagic link
Docs recipeVerification codesSame send shape as password reset
Typical expiry in docs examples~10 minutes~30 minutes
StorageShort codes need HMAC + pepperSHA-256 of 32-byte token
Open-redirect surfaceLow (user types digits)High if URL host/path is attacker-influenced
Both useSame POST /emails delivery pathSame

Keep code verify and link redeem as separate tables and routes. Decision framing: Email verification code vs link.

What this post is not

  • Not a framework tutorial (see NestJS / FastAPI / Remix / SvelteKit / Astro / Rails / Next.js magic-link posts on the blog).
  • Not a password-reset deep dive (Secure password reset emails).
  • Not a claim that Rovela validates tokens, enforces TTL, or prevents open redirects—those are application controls. Rovela delivers the message you submit.
  • Not invented size limits, MCP tools, or China-domestic ESP topics.

Next steps

  1. Implement token + redeem using the password-reset example pattern; point the landing route at session creation.
  2. Lock APP_ORIGIN, ban open redirects on redeem, and turn click tracking off for the security subdomain.
  3. Send with POST /emails + Idempotency-Key from a verified domain (Quick Start).
  4. For the product-shaped walkthrough, read Magic login email links.

Bottom line: treat every magic link like a short-lived password. Your app owns entropy, TTL, single-use, HTTPS, and open-redirect defenses; Rovela’s job is reliable transactional delivery of the URL you already made safe.