Passwordless email authentication: magic links vs email OTP vs code

Passwordless email authentication shifts the credential from "something you know" (a password) to "something you have" (access to your email inbox). It eliminates a large class of credential theft vulnerabilities — password reuse, brute force, credential stuffing — while replacing them with a single point of failure: whoever has access to your email has access to your account. For most users, that's an acceptable or even better tradeoff. There are three common implementations: magic links, email OTP (a code you type), and numeric short codes.

The three approaches compared

Magic links contain a full authentication token in the URL. The user clicks the link, your server validates the token, and the user is logged in. No typing required. Works well on desktop; works less well when the user opens email on mobile but needs to authenticate on desktop.

Email OTP sends a longer alphanumeric code (8-10 characters). The user copies and pastes or types the code into the login form. Handles the cross-device case well — user sees code on phone, types it on desktop. More friction than a magic link click.

Numeric short codes are typically 6-8 digits. Easiest to type, hardest to brute force when rate-limited. The UX pattern is borrowed from SMS OTP and is very familiar to users. The shorter code window means you must enforce strict rate limiting and a short validity window.

Magic link implementation

import crypto from 'crypto';

async function sendMagicLink(email: string, db: DB, redis: Redis): Promise {
  const user = await db.users.findByEmail(email.toLowerCase());
  if (!user) {
    // Don't reveal whether the account exists — always respond with success
    return;
  }

  // Rate limit: 3 magic links per 15 minutes per email
  const rateLimitKey = `magic_link_rate:${email.toLowerCase()}`;
  const attempts = await redis.incr(rateLimitKey);
  if (attempts === 1) await redis.expire(rateLimitKey, 900);
  if (attempts > 3) {
    throw new AuthError('RATE_LIMITED', 'Too many magic link requests');
  }

  const token = crypto.randomBytes(32).toString('hex');
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

  await redis.setex(
    `magic_link:${tokenHash}`,
    600,  // 10-minute expiry
    JSON.stringify({ userId: user.id, email: user.email, createdAt: Date.now() })
  );

  const magicLinkUrl = `https://app.example.com/auth/magic?token=${token}`;
  await sendEmail(user.email, 'Your login link', {
    template: 'magic_link',
    data: { url: magicLinkUrl, expiresMinutes: 10 },
  });
}

async function consumeMagicLink(token: string, redis: Redis, db: DB): Promise {
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  const cacheKey = `magic_link:${tokenHash}`;

  const stored = await redis.get(cacheKey);
  if (!stored) {
    throw new AuthError('INVALID_TOKEN', 'Link has expired or already been used');
  }

  const { userId } = JSON.parse(stored);

  // Single-use: delete immediately
  await redis.del(cacheKey);

  return userId;
}

Email OTP implementation

import secrets
import string
import time
import hashlib
import json

def generate_email_otp(length: int = 8) -> str:
    """Generate a readable alphanumeric OTP, excluding ambiguous characters."""
    alphabet = ''.join(
        c for c in (string.ascii_uppercase + string.digits)
        if c not in '0O1IL'
    )
    return ''.join(secrets.choice(alphabet) for _ in range(length))


async def send_email_otp(email: str, redis, db) -> None:
    user = await db.users.find_by_email(email.lower())
    if not user:
        return  # silent return — don't enumerate accounts

    code = generate_email_otp(8)
    code_hash = hashlib.sha256(code.encode()).hexdigest()

    # Store hash, not plaintext
    await redis.setex(
        f"email_otp:{user.id}",
        600,  # 10 minutes
        json.dumps({
            "code_hash": code_hash,
            "attempts": 0,
            "created_at": int(time.time()),
        })
    )

    await send_email(user.email, "Your login code", code=code)


async def verify_email_otp(user_id: str, submitted_code: str, redis) -> bool:
    cache_key = f"email_otp:{user_id}"
    data_json = await redis.get(cache_key)

    if not data_json:
        raise AuthError("INVALID_CODE", "Code has expired or already been used")

    data = json.loads(data_json)

    # Max 5 attempts before invalidating the code
    if data["attempts"] >= 5:
        await redis.delete(cache_key)
        raise AuthError("MAX_ATTEMPTS", "Too many failed attempts")

    expected_hash = data["code_hash"]
    submitted_hash = hashlib.sha256(submitted_code.upper().encode()).hexdigest()

    if submitted_hash != expected_hash:
        data["attempts"] += 1
        # Update attempt count without extending TTL
        ttl = await redis.ttl(cache_key)
        await redis.setex(cache_key, ttl, json.dumps(data))
        raise AuthError("INVALID_CODE", "Incorrect code")

    # Success: invalidate the code
    await redis.delete(cache_key)
    return True
For short numeric codes (6-8 digits), rate limit aggressively. A 6-digit numeric code has 1,000,000 possible values. With 10 guesses per minute allowed, an attacker has a 1-in-100,000 chance per minute. That's not acceptable without additional signals. Use alphanumeric codes of 8+ characters, or numeric codes of 8+ digits, and limit to 5 attempts per code before invalidation.

The cross-device problem and fallback strategy

The classic cross-device failure: user requests a magic link on their laptop, but their email client opens on their phone. Clicking the link on the phone logs in on the phone, not the laptop tab that's waiting.

The standard solution: use a short-lived session identifier generated in the waiting tab, and poll or use server-sent events to detect completion. When the magic link is consumed on any device, the waiting session receives the authenticated user and completes the login there.

// The waiting tab polls for completion
async function waitForMagicLink(requestId: string): Promise {
  const maxAttempts = 60;  // 5 minutes at 5-second intervals
  for (let i = 0; i < maxAttempts; i++) {
    await new Promise(resolve => setTimeout(resolve, 5000));

    const response = await fetch(`/auth/magic/status?requestId=${requestId}`);
    const result = await response.json();

    if (result.status === 'completed') {
      return { userId: result.userId, token: result.token };
    }
    if (result.status === 'expired') {
      throw new Error('Magic link request expired');
    }
  }
  throw new Error('Timed out waiting for magic link');
}

As a fallback for users who struggle with magic links (email clients that rewrite URLs, corporate firewalls that pre-click links invalidating the token), always offer an email OTP code as an alternative. Show "Or enter the 8-character code from the email" alongside the "Check your email for a link" message. One-click for most users, type-a-code for the edge cases.