Short answer
A magic link is a sign-in link sent by email. When someone enters their address, your app creates a long random token, stores a hash of it with the account and an expiry time, and emails them a URL that contains the token. Opening the link proves they control the inbox: the app checks the token, marks it used and starts a session. There's no password to store, reuse or reset.
Two things decide whether magic links work in practice. The link has to survive the security scanners that open links in incoming mail before the person does, so it should open a page that redeems the token on a POST, never sign anyone in on the GET. And the email has to arrive in seconds: send it from your own authenticated domain, on a transactional stream, with click tracking off.
How a magic link works
- 01The person enters their email addressYour sign-in form posts the address. Show the same "check your email" screen whether or not an account exists, so the form can't be used to find out who has one.
- 02Your app creates a tokenGenerate at least 32 random bytes from a cryptographically secure generator. Store a SHA-256 hash of the token with the user id and an expiry time, never the token itself.
- 03You email a link that carries the tokenSomething like
https://app.example.com/auth/magic?token=..., built from a hard-coded base URL. The email states how long the link lasts and what to do if they didn't ask for it. - 04The link opens a page, not a sessionThe
GETonly renders a page with a sign-in button (or a form that submits itself). It doesn't touch the token. - 05A POST redeems the token onceHash the submitted token, look it up, check it hasn't expired, and claim it in the same step so a second use fails. Then start the session and redirect.
The token rules are the same ones the OWASP Forgot Password Cheat Sheet sets for password reset links: random from a secure generator, long enough to resist guessing, stored securely, tied to one user, invalidated after use and expired after a short period.
| Property | What to do | Why |
|---|---|---|
| Randomness | 32 bytes from crypto.randomBytes or Python's secrets | A guessable token is a guessable login |
| Storage | Keep only a hash (SHA-256 is fine for random tokens) | A database leak can't be turned into working links |
| Lifetime | 10 to 15 minutes for sign-in, stated in the email | People are waiting on your sign-in screen; a short window limits a leaked link |
| Single use | Claim the token atomically, for example DELETE ... RETURNING | Two tabs or a replayed request can't both sign in |
| Binding | Tie it to one user, and if you can to the browser that asked | A forwarded link can't sign in someone else |
| Scope | It signs in. It doesn't change the email address, password or 2FA | Sensitive changes should ask again |
import { createHash, randomBytes } from "node:crypto";
export function createMagicLinkToken() {
const token = randomBytes(32).toString("base64url"); // goes in the email
const tokenHash = createHash("sha256").update(token).digest("hex"); // goes in the database
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
return { token, tokenHash, expiresAt };
}Link scanners: why links say "expired" on the first click
Many business mail systems check every link in incoming mail. Microsoft's documentation for Safe Links in Defender for Office 365, for example, says URLs are scanned before the message is delivered, and that links without a valid reputation are "detonated", opened in a sandbox, in the background. Other security products and link previews can do the same.
If your magic link signs the person in on the GET, that scanner is the first visitor. It uses up the single-use token, gets a session nobody will ever use, and the real person clicks a link that says it has already been used or has expired. It looks like a bug in your app, it only happens at some companies, and it's hard to reproduce.
// GET: render a page. Never redeem the token here.
app.get("/auth/magic", (req, res) => {
res.set("Referrer-Policy", "no-referrer");
res.send(`<form method="post" action="/auth/magic">
<input type="hidden" name="token" value="${escapeHtml(String(req.query.token ?? ""))}">
<button type="submit">Sign in to Example</button>
</form>`);
});
// POST: claim the token once, then start the session.
app.post("/auth/magic", express.urlencoded({ extended: false }), async (req, res) => {
const tokenHash = sha256(String(req.body.token ?? ""));
const { rows } = await db.query(
"DELETE FROM magic_links WHERE token_hash = $1 AND expires_at > now() RETURNING user_id",
[tokenHash],
);
if (!rows[0]) return res.redirect(303, "/login?error=link_expired");
await startSession(res, rows[0].user_id);
res.redirect(303, "/");
});The DELETE ... RETURNING finds, checks and claims the token in one statement, so two requests racing with the same link can't both succeed. escapeHtml, sha256 and startSession stand in for your own helpers.
You have a choice about the button. Requiring a click is the strictest option: only a person gets through. Some apps submit the form with a line of JavaScript as soon as the page loads, with the button as a fallback, so people don't have to click twice. That stops scanners that only fetch the URL, but not ones that run pages in a headless browser. Pick the click for anything sensitive.
Other security details that matter
- Same response for every address. Return the same message whether or not the account exists, as OWASP recommends, and send nothing (or a "no account" email) when it doesn't.
- Rate-limit the form. Per address and per IP, for example one link a minute per address. It stops inbox flooding and keeps junk sends from hurting your sender reputation.
- Don't build the URL from the request's Host header. An attacker can send a request with their own host and receive a link that points to their server. Use a hard-coded base URL.
- Keep the token out of logs and analytics. Set
Referrer-Policy: no-referreron the landing page, don't load third-party scripts on it, and strip the query string from access logs if you can. - Expire links when the email address changes. A link sent to the old address shouldn't sign in to the account once it has moved.
- Know what a magic link proves. It proves access to an inbox, nothing more. NIST's digital identity guidelines (SP 800-63B-4) say email shall not be used for out-of-band authentication, so don't count a magic link as a second factor. For accounts that need more, add passkeys or an authenticator app.
Deliverability: make it arrive in seconds
Someone is staring at your "check your email" screen. If the link takes a minute, they ask for another one, and now two links are racing. If it lands in spam, they give up. Treat the magic link email as part of your login page.
- Send from your own authenticated domain. SPF, DKIM and DMARC passing and aligned with the From address. Mail that fails is filtered hard or rejected. Setup is in SPF, DKIM and DMARC for transactional email.
- Send it from the request, not a batch queue. Call your email API in the sign-in request, or from a queue that only carries urgent mail. A magic link stuck behind a newsletter export is a failed login.
- Keep it on a transactional stream. Sign-in mail should never share IPs or a sending reputation with marketing. See what transactional email is and why the streams are kept apart.
- Turn click tracking off. Tracking rewrites the link through a redirect domain, which adds a hostname for filters to judge, slows the click, and puts the token in another system's logs.
- One link, on your own domain. The link's domain should match the brand in the From address. No URL shorteners, no extra links competing with the sign-in button.
- Always include a plain-text part with the full URL, so it works in clients that block buttons and on a second device.
- Use an idempotency key per token, so a retried request returns the original send instead of emailing the person twice.
Magic link vs one-time code
A one-time code is the other common form of email sign-in: the email carries a 6-digit code and the person types it into the screen they're on. Both prove the same thing, access to the inbox, and both need the same delivery care. They fail in different places.
| Topic | Magic link | One-time code |
|---|---|---|
| What the person does | Clicks a link | Reads a code and types it in |
| Email on phone, sign-in on laptop | Awkward: the link opens on the phone | Works: the code can be typed anywhere |
| Link scanners | Need a confirm page and a POST | Nothing to click, so nothing to pre-fetch |
| Phishing | Harder to hand over: the person would have to forward the link | A fake sign-in page can simply ask for the code |
| Guessing | Not practical with a 32-byte token | Needs attempt limits: 6 digits is a million options |
| Typical lifetime | 10 to 15 minutes | About 10 minutes |
| Browser binding | Opens in whatever browser the mail app picks | Entered in the browser that asked |
Many apps send both in one email: a button for people who read mail on the same device, and a code for people who don't. If you pick one, codes suit apps used across devices and mobile apps; links suit web apps where the inbox and the app sit in the same browser. Ready-made emails for each are in the free magic link template and one-time code template.
Send a magic link with Groundmail
These send one magic link with the official SDKs: a link straight to your own domain, a text part with the full URL, a tag so you can find these emails later, and an idempotency key tied to the token so retries can't send twice. Install with npm install groundmail or pip install groundmail.
import { Groundmail } from "groundmail";
const gm = new Groundmail(process.env.GROUNDMAIL_API_KEY!);
export async function sendMagicLink(email: string, linkId: string, token: string) {
const url = `https://app.example.com/auth/magic?token=${token}`;
return gm.emails.send({
from: { email: "no-reply@mail.example.com", name: "Example" },
to: email,
subject: "Your Example sign-in link",
text: `Sign in to Example:\n\n${url}\n\nThis link works once and expires in 15 minutes. If you didn't try to sign in, ignore this email.`,
html: `<p><a href="${url}">Sign in to Example</a></p>
<p>Or paste this link into your browser:<br>${url}</p>
<p>This link works once and expires in 15 minutes. If you didn't try to sign in, ignore this email.</p>`,
tags: [{ name: "type", value: "magic_link" }],
idempotencyKey: `magic-link:${linkId}`,
});
}import hashlib
import os
import secrets
from groundmail import Groundmail
client = Groundmail(os.environ["GROUNDMAIL_API_KEY"])
def create_token() -> tuple[str, str]:
token = secrets.token_urlsafe(32) # goes in the email
return token, hashlib.sha256(token.encode()).hexdigest() # store the hash
def send_magic_link(email: str, link_id: str, token: str) -> dict:
url = f"https://app.example.com/auth/magic?token={token}"
return client.emails.send(
from_="Example <no-reply@mail.example.com>",
to=email,
subject="Your Example sign-in link",
text=(
f"Sign in to Example:\n\n{url}\n\n"
"This link works once and expires in 15 minutes. "
"If you didn't try to sign in, ignore this email."
),
html=(
f'<p><a href="{url}">Sign in to Example</a></p>'
f"<p>Or paste this link into your browser:<br>{url}</p>"
"<p>This link works once and expires in 15 minutes. "
"If you didn't try to sign in, ignore this email.</p>"
),
tags=[{"name": "type", "value": "magic_link"}],
idempotency_key=f"magic-link:{link_id}",
)Sends are transactional unless you say otherwise, and transactional, product and newsletter email each go out from their own IPs (how the streams work). Click tracking is off by default on new domains. To see what happened to a link someone says never came, use the email.delivered, email.delivery_delayed, email.bounced and email.suppressed webhooks.
Using an auth library? The groundmail-better-auth package gives Better Auth a ready-made sendMagicLink, and groundmail-authjs is a drop-in email provider for Auth.js. In end-to-end tests, the hosted test inbox catches the email and testInbox.waitFor({ to }) returns it with its links extracted, so your test can open the magic link.
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Signing in on the GET | Scanners use the token first; people at some companies always see "expired" | Confirm page, redeem on POST |
| Storing the raw token | A database read gives an attacker working sign-in links | Store a SHA-256 hash |
| Checking, then marking used, in two steps | Two fast requests can both sign in | Claim the token atomically |
| Hours-long expiry on sign-in links | A forwarded or leaked link works long after it should | 10 to 15 minutes for sign-in; longer only for cross-device hand-offs |
| Each resend cancels the previous link | The slow first email arrives and fails | Keep earlier links valid until they expire; clear them all on sign-in |
| Click tracking on | A redirect domain in an auth email, and the token in tracking logs | Turn click tracking off for auth mail |
| Building the link from the Host header | A crafted request can mail a link to an attacker's domain | Hard-code the base URL |
| Sending from a batch queue | Links arrive after people have given up | Send from the request, or from an urgent-only queue |
| Treating it as a second factor | Inbox access counts twice | Add passkeys or an authenticator app for higher assurance |
Frequently asked questions
What is a magic link?
A one-time sign-in link sent by email. The link contains a random token tied to the account. Opening it, and confirming on the page it opens, proves the person controls the inbox, so the app signs them in without a password.
Are magic links secure?
They're as secure as the inbox they're sent to, provided the token is random, stored as a hash, single use and short-lived, and the link opens a page that redeems the token on a POST. They remove password reuse and weak passwords. They are a single factor: NIST's SP 800-63B-4 says email shall not be used for out-of-band authentication.
How long should a magic link be valid?
About 10 to 15 minutes for sign-in, and say so in the email. Use longer lifetimes only for links people are expected to open later, such as a hand-off to another device or an invitation, and still make them single use.
Why does my magic link say it has expired or was already used?
Usually a security scanner or link preview opened the URL before the person did, and your app redeemed the token on that request. Make the link open a page with a sign-in button and redeem the token on the POST. Other causes: a resend that cancelled the earlier link, or a queue that delivered the email after it expired.
Magic link or one-time code: which is better?
Codes work across devices and can't be pre-clicked by scanners, but a fake sign-in page can ask for them. Links are one click on the same device and harder to hand over, but need a confirm page. Many apps send both in one email.
Why do magic link emails go to spam or arrive late?
The same reasons password resets do: the domain isn't authenticated and aligned for DMARC, the email shares IPs with marketing mail, links go through tracking redirects or shorteners, or the email sits in a batch queue. Fix authentication first, send it on a transactional stream and turn click tracking off.
Sources
Facts on this page about other companies' products, prices and policies come from these public pages. They change, so check them before you decide.