A magic link is a one-time sign-in URL sent by email: the user types their address, clicks the link in their inbox, and is logged in without a password. The same machinery covers email OTP codes (a six-digit code instead of a link) and "verify your email" messages. All three are only as safe as the token inside them and only as useful as their delivery speed. This guide covers how magic link authentication works, the security rules from OWASP and NIST that the token must follow, how to keep auth email out of spam, and verification email text you can copy.
What is a magic link?
A magic link is a URL with a random, single-use secret in it, for example https://app.example.com/auth/magic?token=Q2x…. Having the link proves the user can read that mailbox, and that proof stands in for a password. Magic links are also called passwordless email login or email link authentication.
Three message types use the same pattern:
| Message | Proves | Typical lifetime | Typical use |
|---|---|---|---|
| Magic link (sign-in) | Control of the mailbox, right now | Minutes | Passwordless login |
| Email OTP code | The same, typed instead of clicked | Minutes | Login on a different device, mobile apps |
| Verification email | The address belongs to this account | Hours to days | Signup, email change, double opt-in |
All three are transactional email: one recipient, triggered by an action, expected within seconds.
How magic link authentication works
- Request. The user submits their email address. Whether or not an account exists, the server shows the same response: "If that address has an account, we sent a link."
- Issue. The server generates a random token, stores a hash of it with the user id, purpose (
login), expiry and aused_atcolumn, then emails a link containing the raw token. - Land. The user clicks. The
GETpage shows a "Sign in" button and does nothing else (see link scanners below). - Redeem. The button
POSTs the token. The server hashes it, atomically marks the matching unused, unexpired row as used, and creates a session. - Clean up. Redirect to a URL without the token so it does not stay in history, logs or
Refererheaders.
The redeem step should be one atomic statement so that two concurrent clicks cannot both succeed:
UPDATE login_tokens
SET used_at = now()
WHERE token_hash = $1
AND purpose = 'login'
AND used_at IS NULL
AND expires_at > now()
RETURNING user_id;
No row returned means expired, already used or never valid. Show one generic message for all three.
Security requirements for magic links and OTP codes
OWASP's Forgot Password Cheat Sheet is written for reset tokens, but magic links are the same kind of object: a URL token sent by email that grants access. Its rules apply directly.
Entropy: generate tokens with a CSPRNG
OWASP says tokens must be "generated using a cryptographically secure random number generator" and be "sufficiently long to protect against brute-force attacks." For a comparable bearer secret, OWASP's session guidance sets a floor of "at least 64 bits of entropy." 32 random bytes (256 bits), encoded as base64url, is a common choice and costs nothing. Never derive the token from the user id, email, timestamp or Math.random().
OTP codes are the exception: six digits is only a million possibilities. That is acceptable only because the code expires quickly and attempts are limited. NIST SP 800-63B-4 requires out-of-band secrets to be "at least six decimal digits," valid for at most 10 minutes, and accepted only once.
Store a hash, not the token
OWASP asks for tokens to be "stored in a secure manner." Store SHA-256(token) and look it up by hash. Then a database leak or a support engineer's query cannot be replayed as a login.
Single use and short expiry
Tokens should be "single use and expire after an appropriate period" (OWASP). In practice:
- Magic links and OTP codes: 10–15 minutes. NIST's 10-minute limit for out-of-band secrets is a good default.
- Verification links: longer, because the user may confirm hours later. 24 hours is common. Verifying an address grants less than logging in does.
- Issuing a new token invalidates the old one for the same purpose, so only the latest email works.
Bind the token to who and what
Bind each token to one user and one purpose (login, verify_email, change_email:new@example.com). A verification token must never work as a login token. For extra protection, you can also bind a login link to the browser that requested it with a short-lived cookie nonce. That stops a forwarded or intercepted link from working elsewhere. The tradeoff: users who request on a laptop and click on a phone need a fallback, and an OTP code is usually that fallback.
Rate limit both sending and guessing
- Sending: limit requests per account and per IP. OWASP recommends "rate-limiting on a per-account basis" for reset requests. Without it, your login form becomes a free way to flood any inbox, and those complaints hit your sender reputation.
- Guessing OTP codes: count failed attempts per code and per account, and lock after a handful. NIST caps consecutive failures at 100 as an upper bound and adds that "generating a new authentication secret SHALL NOT reset the failed authentication count." For a six-digit code, a limit of about 5 attempts per code is far safer.
Don't leak account existence
OWASP: "Return a consistent message for both existent and non-existent accounts" and keep response times uniform. Send the email from a background job so that the "account exists" path does not take visibly longer.
Build the URL from config, not the request
"Don't rely on the Host header while creating the reset URLs" (OWASP). An attacker who can change the Host header can make your server email a valid token pointing at their domain. Hard-code the base URL or check it against an allowlist. Serve the landing page with Referrer-Policy: no-referrer.
Survive link scanners
Some mailbox providers and security gateways fetch links in incoming mail before a human clicks them. Supabase's docs describe the result: the one-time link is "consumed instantly," and the user sees "Token has expired or is invalid." There are two fixes. Make the GET an intermediate page whose button POSTs the token, as in step 3 above. Or offer an OTP code, which a scanner cannot type.
Know what a magic link is not
NIST SP 800-63B-4 states that "Email SHALL NOT be used for out-of-band authentication" because mail can be read with only the mailbox password, intercepted, or rerouted. NIST explicitly excludes confirmation codes that only validate an email address. So a magic link is a reasonable password replacement for many consumer and SaaS apps. It is no stronger than the user's email account, though, and it does not count as a second factor. For admin consoles, payments or regulated data, add passkeys or another MFA method.
Magic link vs OTP code vs password
| Magic link | Email OTP | Password | |
|---|---|---|---|
| User effort | One click | Copy six digits | Remember, type |
| Works across devices | Poorly (link opens where clicked) | Yes | Yes |
| Hurt by link scanners | Yes, unless you use a confirm page | No | No |
| Phishing resistance | Low (any email can imitate it) | Low | Low |
| Depends on email delivery speed | Every login | Every login | Only for resets |
Many products send both in one email.
Deliverability of auth email
A login email that arrives in four minutes is a failed login.
Give it its own sending stream. Google's sender guidelines say "messages of the same category should have the same From: email address," and, if you use several IPs, "use a different IP address for each message type." Send auth mail from a dedicated address such as login@auth.example.com, and keep newsletters on a different address or subdomain. A complaint spike from a promotion then cannot slow down your sign-in codes.
Keep the content plain. Google also advises: "Don't mix different types of content in the same message." No promotions, no product tour, no social icons, no images that carry meaning. One sentence of context, the button or code, the expiry, and a "didn't request this" line.
Don't wrap the link in click tracking. Tracking redirects put a second domain in front of your link, add a hop scanners can trip on, and teach users to click links that do not match your domain. If your provider lets you disable click tracking per message, do it here.
Send the plain-text part too, with the full URL on its own line so clients that wrap long lines do not break it.
Check the address before you need it. A typo at signup means a verification email that never arrives. Validate the address at signup, for example with Volanea's email verifier or the POST /v1/verify endpoint. An "API for email verification" can mean either this kind of address validation or sending a verification email. You usually want both.
For the broader picture, see transactional email security and, if auth emails are missing entirely, why your emails are not sending.
Verification email text and templates
These are short on purpose. Replace the bracketed values, escape any user-supplied name before inserting it into HTML, and keep the preview text from repeating the subject.
Verify email template
Subject: Confirm your email for [Product]
Preview: One click to finish setting up your account.
Hi [first name],
Confirm that [email] is your address to finish creating your [Product] account.
[Confirm email] -> https://app.example.com/verify?token=...
This link expires in 24 hours. If you didn't create an account, you can ignore this email.
— The [Product] team
Magic link email template
Subject: Your sign-in link for [Product]
Preview: This link works once and expires in 15 minutes.
Click the button below to sign in to [Product].
[Sign in to Product]
Or paste this link into your browser:
https://app.example.com/auth/magic?token=...
This link expires in 15 minutes and can only be used once.
Requested from [browser] on [OS] at [time, timezone]. If this wasn't you, ignore this email. Your account is safe.
OTP email template
Subject: [Product] verification code
Preview: Enter this code to continue. It expires in 10 minutes.
Your verification code is:
482 913
Enter it in the window where you started signing in. It expires in 10 minutes.
[Product] will never ask you for this code by phone, chat or email.
Leave the code out of the subject line unless you accept the tradeoff. It is convenient, but it then shows on lock screens and in notification previews. The "we will never ask for this code" line is a cheap defence against phishing by people pretending to be support. More layouts for receipts, resets and alerts are in transactional email templates.
When "email verification failed": resend and recovery
Most "email verification failed" reports have one of four causes: the link expired, it was already used (often by a link scanner), a newer email replaced it, or the user opened it on another device that lacks the binding cookie. Handle them in the product:
- Show one clear page: "This link has expired or was already used," with a Resend button that reuses the email already on the session.
- Put a cooldown on resend (for example 60 seconds) and count it against the same per-account send limit.
- Mention the spam folder and the sending address ("from login@auth.example.com") on the "check your inbox" screen.
- Offer the OTP path when a magic link fails on a second device.
If you use Auth0, its Management API resends the verification email with POST /api/v2/jobs/verification-email and a user_id, and requires the update:users scope. To send Auth0's or Better Auth's emails through your own provider, see /docs/send-with-auth0 and /docs/send-with-better-auth.
Send a magic link email with Volanea's API
This Node.js (18+) example issues a token, stores only its hash, and sends the email. db.loginTokens.insert stands in for your own data layer.
import crypto from "node:crypto";
export async function sendMagicLink(user, db) {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = crypto.createHash("sha256").update(token).digest("hex");
const tokenId = crypto.randomUUID();
await db.loginTokens.insert({
id: tokenId,
userId: user.id,
purpose: "login",
tokenHash,
expiresAt: new Date(Date.now() + 15 * 60 * 1000),
usedAt: null,
});
const url = `https://app.example.com/auth/magic?token=${token}`; // base URL from config, never the Host header
const res = await fetch("https://api.volanea.com/v1/send", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.VOLANEA_API_KEY,
"Content-Type": "application/json",
"Idempotency-Key": `magic-link-${tokenId}`,
},
body: JSON.stringify({
from: "login@auth.example.com",
fromName: "Example",
to: user.email,
subject: "Your sign-in link for Example",
html: `<p>Click to sign in to Example. This link expires in 15 minutes and works once.</p>
<p><a href="${url}">Sign in to Example</a></p>
<p>If you didn't request this, ignore this email.</p>`,
text: `Sign in to Example (expires in 15 minutes, works once):\n${url}\n\nIf you didn't request this, ignore this email.`,
type: "transactional",
}),
});
if (!res.ok) throw new Error(`Send failed (${res.status}): ${await res.text()}`);
}
The Idempotency-Key ties the send to the token, so a retry after a network timeout replays the first result instead of sending a second link. Call this function from a background job so the "account exists" path takes the same time as the "no account" path. The full request schema is in the send API reference.
How Volanea handles auth email
Volanea sends transactional email over a REST API or SMTP, built on Amazon SES. For auth flows, the useful parts are idempotency keys on sends, a suppression list and bounce handling, so you stop emailing dead addresses, and delivery webhooks that show whether a code was delivered or bounced.
FAQ
What is a magic link?
A magic link is a single-use URL containing a random secret, emailed to a user so they can sign in or confirm an action by clicking it, without typing a password.
How do magic links work?
The server stores a hash of a random token for the user, emails a link containing the token, and when the link is redeemed it marks the token as used and starts a session. Expired, reused or unknown tokens are rejected with one generic message.
Are magic links secure?
They are as secure as the user's mailbox, provided the token is random, single-use, short-lived, hashed at rest and rate limited. NIST does not accept email as an out-of-band authenticator, so add passkeys or MFA for high-risk accounts.
How long should a magic link be valid?
Ten to fifteen minutes for sign-in links and OTP codes, in line with NIST's 10-minute limit for out-of-band secrets. Email verification links can last longer, often 24 hours, because they grant less.
Why does my verification link say expired or invalid?
Usually because it was already used, often by a security scanner that opened the link before you did, or because a newer email replaced it. A confirm button on the landing page and an OTP fallback prevent most of these failures.
How do I resend a verification email?
Give users a Resend button on the "check your inbox" and "link expired" pages, with a short cooldown and a per-account limit. Issue a fresh token and invalidate the previous one.
Send your first login code on the free plan: create a Volanea account, 1,000 emails a month with no card, or compare plans.