Skip to main content
Two-factor authentication (2FA) emails deliver one-time passcodes (OTPs) that users need to complete a login or sensitive action. These emails have the strictest delivery requirements of any email type — they must arrive within seconds, contain a short-lived code, and resist interception. A failed or delayed 2FA email locks the user out entirely. This guide covers OTP generation, email design, delivery optimization, and security hardening.

Delivery Speed Requirements

2FA emails are the most time-sensitive email type. Users are waiting on a login screen and will abandon the flow if the code doesn’t arrive quickly.
Delivery TargetUser Experience
Under 5 secondsSeamless — user barely notices the wait
5–15 secondsAcceptable — user waits but stays on the page
15–30 secondsFrustrating — user considers requesting a new code
Over 30 secondsFailure — user abandons the login attempt or contacts support

Optimizing for Speed

  • Send immediately — never queue 2FA emails behind other sends. Use the highest priority in your sending pipeline.
  • Keep the email tiny — minimal HTML, no images, no tracking pixels. Every byte adds processing time at each hop in the delivery pipeline.
  • Use a warmed, reputable domain — established sending domains are processed faster by receiving servers. See IP and Domain Warm-Up.
  • Monitor delivery latency — alert if the time between your API call and the email.delivered webhook event exceeds 10 seconds.
Do not include open or click tracking in 2FA emails. Tracking pixels add processing overhead, increase email size, and provide no useful data — you already know the user received the code if they successfully log in.

OTP Generation and Security

Generate Secure Codes

Use a cryptographically secure random number generator. The code should be numeric (for easy entry on mobile) and 6–8 digits:
const crypto = require('crypto');

function generateOTP(length = 6) {
  // Generate a random number with the desired number of digits
  const max = Math.pow(10, length);
  const randomBytes = crypto.randomBytes(4);
  const randomNumber = randomBytes.readUInt32BE(0) % max;

  // Pad with leading zeros
  return randomNumber.toString().padStart(length, '0');
}

const code = generateOTP(6); // e.g., "847203"
Don’t use Math.random() for OTP generation. It’s not cryptographically secure and produces predictable sequences that an attacker could guess.

Set Short Expiry Times

OTP codes should expire quickly. The user is actively waiting to enter the code:
ExpiryRecommendation
5 minutesRecommended default
10 minutesMaximum — only if your users have slow email delivery
30+ minutesToo long — significantly increases the attack window
async function createOTP(userId) {
  const code = generateOTP(6);
  const hashedCode = crypto.createHash('sha256').update(code).digest('hex');

  await db.otpCodes.create({
    userId,
    code: hashedCode,
    expiresAt: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
    used: false,
  });

  return code; // Return the plain code to send in the email
}

Invalidation Rules

  • Single use — invalidate the code immediately after successful verification
  • New code invalidates old — when a user requests a new code, invalidate all previous codes for that user
  • Maximum attempts — lock the account temporarily after 5 failed verification attempts
async function verifyOTP(userId, inputCode) {
  const hashedInput = crypto.createHash('sha256').update(inputCode).digest('hex');

  const otp = await db.otpCodes.findOne({
    userId,
    code: hashedInput,
    used: false,
    expiresAt: { $gt: new Date() },
  });

  if (!otp) {
    await incrementFailedAttempts(userId);

    if (await getFailedAttempts(userId) >= 5) {
      await temporarilyLockAccount(userId);
    }

    return false;
  }

  // Invalidate the code and all other codes for this user
  await db.otpCodes.updateMany({ userId }, { used: true });
  await resetFailedAttempts(userId);

  return true;
}

Email Content

2FA emails should be the simplest emails you send. The user needs the code and nothing else.

Template Structure

<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 600px; margin: 0 auto;">
  <h2 style="color: #1a1a1a; margin-bottom: 8px;">Your verification code</h2>

  <p style="color: #4a4a4a; line-height: 1.6;">
    Enter this code to complete your sign-in:
  </p>

  <!-- The code — large, clear, easy to copy -->
  <div style="background-color: #f5f3ff; border-radius: 8px; padding: 24px; text-align: center; margin: 24px 0;">
    <span style="font-size: 36px; font-weight: 700; letter-spacing: 8px; color: #1a1a1a; font-family: 'Courier New', monospace;">
      {{otpCode}}
    </span>
  </div>

  <p style="color: #6b6b6b; font-size: 14px; line-height: 1.6;">
    This code expires in 5 minutes. If you didn't try to sign in,
    someone may be trying to access your account —
    <a href="{{securitySettingsUrl}}" style="color: #6366F1;">review your security settings</a>.
  </p>
</div>

Design Principles

  • Large, monospaced code — make the code easy to read and distinguish similar characters (0 vs O, 1 vs l)
  • Letter spacing — add space between digits so users can read them in groups
  • No clickable links for the code — the user should type the code manually, not click a link (clicking a magic link is a different authentication pattern)
  • Minimal content — no marketing, no navigation, no images beyond a simple logo
  • Clear expiry notice — tell the user how long the code is valid
  • Security warning — if they didn’t request the code, tell them what to do
Use font-family: 'Courier New', monospace for the OTP code. Monospaced fonts prevent confusion between similar characters like 0 and O, or 1 and l.

Plain-Text Version

Your verification code: {{otpCode}}

This code expires in 5 minutes.

If you didn't try to sign in, someone may be trying to access your
account. Review your security settings: {{securitySettingsUrl}}

Sending with Lettr

async function sendOTPEmail(user, code) {
  const response = await fetch('https://app.lettr.com/api/emails', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.LETTR_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      from: 'security@mail.yourapp.com',
      to: user.email,
      subject: `${code} is your verification code`,
      html: otpEmailHtml(code),
      text: otpEmailText(code),
      tags: ['2fa', 'otp'],
      metadata: {
        userId: user.id,
        action: 'login',
      },
    }),
  });

  return response;
}
Put the code in the email subject line (e.g., “847203 is your verification code”). Many users can read and enter the code directly from their phone’s notification banner without opening the email, reducing friction.

Rate Limiting

2FA emails are a common target for abuse — attackers may trigger OTP sends to flood a user’s inbox or exhaust your sending quota.

Implement Rate Limits

async function requestOTP(email, ipAddress) {
  // Per-email rate limit: max 5 codes per 15 minutes
  const emailCount = await getRecentOTPCount(email, 15 * 60);
  if (emailCount >= 5) {
    throw new Error('Too many verification codes requested. Try again later.');
  }

  // Per-IP rate limit: max 10 requests per 15 minutes
  const ipCount = await getRecentOTPCountByIP(ipAddress, 15 * 60);
  if (ipCount >= 10) {
    throw new Error('Too many requests from this location. Try again later.');
  }

  const code = await createOTP(email);
  await sendOTPEmail({ email }, code);
}
LimitRecommended Value
Per email address5 codes per 15 minutes
Per IP address10 codes per 15 minutes
Global per account20 codes per hour

Fallback Strategies

Email delivery is not guaranteed to be fast enough for 2FA. Plan for failures.

Resend Option

Provide a “Resend code” button on the verification screen, but enforce a cooldown:
  • 30-second cooldown between resend requests
  • Maximum 3 resends per verification session
  • Generate a new code on each resend (invalidating the previous one)

Alternative Channels

Consider offering fallback options when email delivery is slow:
  • Authenticator app (TOTP) — no delivery delay, works offline
  • SMS — faster delivery than email in most cases, but has its own reliability issues
  • Backup codes — pre-generated codes the user stored during setup
Email-based 2FA is less secure than authenticator apps (TOTP) because email can be intercepted or the inbox compromised. If your application handles sensitive data, offer TOTP as the primary option and email as a fallback.

Common Mistakes

A 4-digit code has only 10,000 possible values — an attacker with no rate limiting can brute-force it quickly. Use 6 digits minimum, and enforce strict attempt limits.
A non-expiring OTP gives attackers unlimited time to intercept and use it. Set a 5-minute expiry as the default.
Tracking pixels add latency and size to the email. For 2FA, delivery speed matters more than open tracking. Disable tracking for OTP emails.
A domain without established reputation may experience delivery delays — exactly when you need speed most. Use a warmed sending domain.
Without rate limits, an attacker can trigger thousands of OTP emails, flooding the user’s inbox and burning through your sending quota.
If your database is compromised, raw OTP codes give attackers immediate access. Hash the code before storing it, just like passwords.
An OTP generated for login should not be valid for a password change. Bind each code to a specific action and validate the action context during verification.