Magic link email: passwordless sign-in done safely

Updated

A magic link logs a user in by proving they control an inbox. It removes passwords, but it makes the email the credential, so the flow has to be careful.

Workflow

  1. The user enters their email on the sign-in page.
  2. You create a random token, store its hash with a 15 minute expiry, and set a browser-binding cookie holding a second random value.
  3. You email the link.
  4. When the link is opened, you check the token, the expiry, and that the cookie matches the one set in step 2. Then you start the session and delete the token.

Step 2's cookie stops a link from working in a different browser from the one that asked for it, which defeats forwarding and some phishing tricks.

Implementation (Python with the package)

import hashlib, os, secrets
from datetime import datetime, timedelta, timezone

from inboxili import Inboxili

client = Inboxili(os.environ["INBOXILI_API_KEY"])


def request_magic_link(db, email: str, response):
    # Always behave the same whether or not the account exists.
    user = db.find_user(email)
    if user is None:
        return

    token = secrets.token_urlsafe(32)
    binder = secrets.token_urlsafe(16)
    db.save_login_token(
        user_id=user.id,
        token_hash=hashlib.sha256(token.encode()).hexdigest(),
        binder_hash=hashlib.sha256(binder.encode()).hexdigest(),
        expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
    )
    response.set_cookie("login_binder", binder, max_age=900, httponly=True, secure=True, samesite="Lax")

    link = f"https://app.yourdomain.com/login/verify?token={token}"
    client.emails.send(
        to=user.email,
        from_email="login@yourdomain.com",
        from_name="Acme",
        subject="Your Acme sign-in link",
        html_body='<p><a href="{{link}}">Sign in to Acme</a>. This link works once and expires in 15 minutes. If you did not ask for it, ignore this email.</p>',
        text_body=f"Sign in: {link}",
        template_data={"link": link},
    )

The verify page should show a Sign in button that sends a POST. Do not log in on the GET itself, because mail scanners open links to check them and would use up the token.

Things that go wrong

  • Scanners consuming the token. Use the button-and-POST pattern above.
  • Reusable links. Delete the token on first use.
  • Account enumeration. Return the same message and a similar response time whether or not the address exists.
  • Unlimited requests. Rate-limit per address and per IP, or someone can flood a victim's inbox with your sign-in emails.
  • Host-header links. Build the URL from configuration, never from the request.

Delivery events

A bounce on a sign-in email means the address is wrong or gone. Tell the user to check it.

Deliverability notes

Send from a dedicated login@ address. The subject and body should be nearly identical every time so mailbox providers learn to trust the pattern.

Frequently asked questions

Magic link or OTP code?
A link is one tap on the same device. A code works across devices and survives email clients that rewrite links. Many products offer a link and show the code too.
How long should a magic link last?
Ten to fifteen minutes, single use.

Official SDKs

Send your first magic link

Create a workspace, verify a domain, and make your first API call.

Related