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.
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
| Responsibility | Owner |
|---|---|
| Cryptographic token, hash storage, TTL, single-use redeem | Your app |
Absolute link host/path (https://app.example.com/...) | Your app |
| Session cookie / JWT after redeem | Your app |
| Rate limits, enumeration-safe responses | Your app |
Accept POST https://api.rovela.dev/emails, queue, sign, retries, bounce/complaint handling | Rovela |
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:
- Normalize the email (
trim+ lowercase). - Generate 32 random bytes and encode as
base64url(crypto.randomBytes(32).toString("base64url")). - 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).
- Set expiry around 30 minutes in the same constant you print in the email copy.
- 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 (orencodeURIComponent(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
htmlandtext, 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:
- 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. - 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/.... - 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. - 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-Keyis 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). Unverifiedfromdomains are refused with400. - 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
200means accepted and queued, not “already in the inbox.” Wire bounce/complaint webhooks if undeliverable auth mail matters (Webhooks).
Common security mistakes
| Mistake | Risk | Fix |
|---|---|---|
| Store plaintext token | DB dump = mass takeover | Store SHA-256 hash only |
| No / soft TTL | Stolen mailbox usable for days | ~30 minutes, enforced in SQL |
| Read-then-delete redeem | Double redeem under concurrency | DELETE … RETURNING |
http:// or wrong host in email | MITM / phishing | Configured HTTPS APP_ORIGIN only |
User-supplied redirect= in emailed URL | Open redirect + phishing | Allowlist same-origin paths after redeem |
| Click tracking on auth domain | Token via extra redirect | Separate subdomain, tracking off |
| Different “user not found” copy | Account enumeration | Identical response + timing |
| API key in the client | Key theft → arbitrary sends | Server-only sending_access |
No Idempotency-Key on retries | Two live links / confusing UX | Key from token-hash prefix |
| HTML-only body | Filters strip the only link | Always include text with full URL |
OTP vs magic link (security lens)
| OTP code | Magic link | |
|---|---|---|
| Docs recipe | Verification codes | Same send shape as password reset |
| Typical expiry in docs examples | ~10 minutes | ~30 minutes |
| Storage | Short codes need HMAC + pepper | SHA-256 of 32-byte token |
| Open-redirect surface | Low (user types digits) | High if URL host/path is attacker-influenced |
| Both use | Same POST /emails delivery path | Same |
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
- Implement token + redeem using the password-reset example pattern; point the landing route at session creation.
- Lock
APP_ORIGIN, ban open redirects on redeem, and turn click tracking off for the security subdomain. - Send with
POST /emails+Idempotency-Keyfrom a verified domain (Quick Start). - 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.