Magic links: secure one-time login URLs and why you're probably implementing them wrong

Magic links have surged in popularity as a passwordless login alternative: the user enters their email, receives a link, clicks it, and is authenticated. The experience is smoother than a password for most users, and there's no password database to breach. But the security model of a magic link is non-obvious, and several common implementation patterns introduce vulnerabilities that make magic links less secure than a properly implemented password flow.

Token entropy: how much is enough?

A magic link token is a bearer credential. Anyone who has the URL can log in as that user. The token therefore needs to be unguessable — statistically impossible to brute-force within its validity window.

128 bits of entropy is the minimum acceptable floor. At 15-minute expiry, an attacker making 1,000 requests per second has about 900,000 attempts. The probability of guessing a 128-bit random token in that window is vanishingly small (~2.6 × 10⁻³¹). Anything less needs justification.

import { randomBytes, createHash } from 'crypto';

function generateMagicLinkToken(): { raw: string; hash: string } {
  // 20 bytes = 160 bits — comfortably above 128-bit minimum
  const raw = randomBytes(20).toString('base64url'); // URL-safe, ~27 chars
  const hash = createHash('sha256').update(raw).digest('hex');
  return { raw, hash };
}

// Full URL: https://app.bastionary.com/auth/magic?token=RAW_TOKEN_HERE
// Store only the hash in the database
async function createMagicLinkToken(email: string, db: DB, redis: Redis): Promise<string> {
  // Rate limit: max 5 magic links per email per 10 minutes
  const rateLimitKey = `ml:rate:${email.toLowerCase()}`;
  const count = await redis.incr(rateLimitKey);
  if (count === 1) await redis.expire(rateLimitKey, 600);
  if (count > 5) throw new RateLimitError('Too many magic link requests');

  const user = await db.users.findByEmail(email);
  if (!user) return; // return without error to prevent email enumeration

  const { raw, hash } = generateMagicLinkToken();

  // Invalidate any existing unexpired token for this user
  await db.magicLinkTokens.invalidateForUser(user.id);

  await db.magicLinkTokens.create({
    userId: user.id,
    tokenHash: hash,
    expiresAt: new Date(Date.now() + 15 * 60 * 1000), // 15 minutes
    usedAt: null,
    createdForIp: null, // don't bind to IP — email may be opened on different device
  });

  return raw; // include in email URL
}

Single-use enforcement and the prefetch problem

The most critical property of a magic link is single-use. Once consumed, the token must be invalidated immediately. However, email clients and security scanners pre-fetch URLs, which can consume the token before the user ever sees it.

The fix is identical to the email verification case: use a two-step flow. The GET request loads a confirmation page; the actual token consumption happens on a POST:

// GET /auth/magic?token=...
// → Show "Click to sign in" page, don't consume token yet
router.get('/auth/magic', async (req, res) => {
  const { token } = req.query as { token: string };
  if (!token) return res.redirect('/login?error=missing_token');

  // Lightly validate the token format without consuming it
  const hash = createHash('sha256').update(token).digest('hex');
  const record = await db.magicLinkTokens.findByHash(hash);

  if (!record || record.usedAt || new Date() > record.expiresAt) {
    return res.render('magic-link-error', {
      message: 'This link has expired or already been used.',
    });
  }

  // Render confirmation page — user must click a button
  res.render('magic-link-confirm', {
    token,
    email: maskEmail(record.email),
  });
});

// POST /auth/magic — actual token consumption
router.post('/auth/magic', async (req, res) => {
  const { token } = req.body;
  const hash = createHash('sha256').update(token).digest('hex');

  const record = await db.magicLinkTokens.findAndConsume(hash);
  if (!record) {
    return res.status(400).render('magic-link-error', {
      message: 'This link is no longer valid.',
    });
  }

  // Issue session
  const sessionId = await createSession(record.userId, req);
  res.cookie('sid', sessionId, { httpOnly: true, secure: true, sameSite: 'lax' });
  res.redirect(record.redirectTo || '/dashboard');
});
-- Atomic find-and-consume to prevent race conditions
CREATE FUNCTION consume_magic_link_token(p_hash CHAR(64))
RETURNS TABLE (user_id UUID, redirect_to TEXT) AS $$
DECLARE
  v_record magic_link_tokens%ROWTYPE;
BEGIN
  SELECT * INTO v_record
  FROM magic_link_tokens
  WHERE token_hash = p_hash
    AND used_at IS NULL
    AND expires_at > NOW()
  FOR UPDATE SKIP LOCKED;  -- prevent concurrent consumption

  IF NOT FOUND THEN
    RETURN;
  END IF;

  UPDATE magic_link_tokens
  SET used_at = NOW()
  WHERE id = v_record.id;

  RETURN QUERY SELECT v_record.user_id, v_record.redirect_to;
END;
$$ LANGUAGE plpgsql;

Expiry: 15 minutes is the maximum

Email is not a secure channel. Email transit can be delayed; mailboxes can be compromised; corporate email forwarding can expose messages to additional parties. A 15-minute expiry limits the window during which a compromised email gives access to your application.

Some implementations use 1-hour or even 24-hour expiry for user experience reasons. The counterargument: if the user doesn't click a 15-minute link, they can simply request a new one. The UX cost of a second request is minimal; the security benefit is significant.

SameSite cookie and cross-device use

Magic links are often opened on different devices than where they were requested — a user requests the link on their laptop, then opens it on their phone. Don't use sameSite: 'strict' for the session cookie set immediately after magic link verification. Use lax — otherwise the redirect from the email client (a different origin) may not set the cookie correctly in some browsers.

Magic links in email represent a significant attack surface if the user's email account is compromised. For high-security applications, don't use magic links as the sole authentication factor — require TOTP as a second step, or use magic links only in combination with an existing session on a known device.

Logging and monitoring

Log every magic link issuance and consumption with IP addresses, user agents, and timestamps. Anomalies to alert on:

  • Token consumed from a different country than where it was requested
  • Token consumed more than 5 minutes after a request from the same IP (suggests a delay/interception)
  • Multiple users requesting magic links from the same IP in a short window (credential stuffing via magic link endpoint)
  • Tokens that were generated but never consumed (abandoned logins — could indicate email delivery issues or OSINT fishing)