Skip to content
Groundmail

Guides

Magic links: how passwordless email sign-in works (and how to make them arrive)

How magic link authentication works: token, expiry and single use, why link scanners break links, magic link vs one-time code, and Node and Python code.

By GroundmailUpdated All guides →

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

  1. 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.
  2. 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.
  3. 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.
  4. 04The link opens a page, not a sessionThe GET only renders a page with a sign-in button (or a form that submits itself). It doesn't touch the token.
  5. 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.

PropertyWhat to doWhy
Randomness32 bytes from crypto.randomBytes or Python's secretsA guessable token is a guessable login
StorageKeep only a hash (SHA-256 is fine for random tokens)A database leak can't be turned into working links
Lifetime10 to 15 minutes for sign-in, stated in the emailPeople are waiting on your sign-in screen; a short window limits a leaked link
Single useClaim the token atomically, for example DELETE ... RETURNINGTwo tabs or a replayed request can't both sign in
BindingTie it to one user, and if you can to the browser that askedA forwarded link can't sign in someone else
ScopeIt signs in. It doesn't change the email address, password or 2FASensitive changes should ask again
Node: create a token
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 };
}

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-referrer on 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.

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.

Node
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}`,
  });
}
Python
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

MistakeWhat happensFix
Signing in on the GETScanners use the token first; people at some companies always see "expired"Confirm page, redeem on POST
Storing the raw tokenA database read gives an attacker working sign-in linksStore a SHA-256 hash
Checking, then marking used, in two stepsTwo fast requests can both sign inClaim the token atomically
Hours-long expiry on sign-in linksA forwarded or leaked link works long after it should10 to 15 minutes for sign-in; longer only for cross-device hand-offs
Each resend cancels the previous linkThe slow first email arrives and failsKeep earlier links valid until they expire; clear them all on sign-in
Click tracking onA redirect domain in an auth email, and the token in tracking logsTurn click tracking off for auth mail
Building the link from the Host headerA crafted request can mail a link to an attacker's domainHard-code the base URL
Sending from a batch queueLinks arrive after people have given upSend from the request, or from an urgent-only queue
Treating it as a second factorInbox access counts twiceAdd 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.

Email for apps. Set up by your agent.

Sign-in codes, receipts and alerts kept separate from marketing mail, with inbound, templates and automations included. Start free with 3,000 emails a month, and move to a dedicated IP or server without changing your code.