> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lettr.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Two-Factor Authentication Emails

> OTP delivery best practices, delivery speed requirements, security considerations, and implementation patterns for 2FA emails

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 Target     | User Experience                                               |
| ------------------- | ------------------------------------------------------------- |
| **Under 5 seconds** | Seamless — user barely notices the wait                       |
| **5–15 seconds**    | Acceptable — user waits but stays on the page                 |
| **15–30 seconds**   | Frustrating — user considers requesting a new code            |
| **Over 30 seconds** | Failure — 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](/knowledge-base/best-practices/ip-domain-warmup).
* **Monitor delivery latency** — alert if the time between your API call and the `email.delivered` webhook event exceeds 10 seconds.

<Warning>
  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.
</Warning>

***

## 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:

```javascript theme={null}
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"
```

<Note>
  Don't use `Math.random()` for OTP generation. It's not cryptographically secure and produces predictable sequences that an attacker could guess.
</Note>

### Set Short Expiry Times

OTP codes should expire quickly. The user is actively waiting to enter the code:

| Expiry          | Recommendation                                        |
| --------------- | ----------------------------------------------------- |
| **5 minutes**   | Recommended default                                   |
| **10 minutes**  | Maximum — only if your users have slow email delivery |
| **30+ minutes** | Too long — significantly increases the attack window  |

```javascript theme={null}
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

```javascript theme={null}
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

```html theme={null}
<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

<Tip>
  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`.
</Tip>

### 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

```javascript theme={null}
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;
}
```

<Tip>
  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.
</Tip>

***

## 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

```javascript theme={null}
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);
}
```

| Limit              | Recommended Value       |
| ------------------ | ----------------------- |
| Per email address  | 5 codes per 15 minutes  |
| Per IP address     | 10 codes per 15 minutes |
| Global per account | 20 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

<Note>
  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.
</Note>

***

## Common Mistakes

<AccordionGroup>
  <Accordion title="Code is too short">
    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.
  </Accordion>

  <Accordion title="Code doesn't expire">
    A non-expiring OTP gives attackers unlimited time to intercept and use it. Set a 5-minute expiry as the default.
  </Accordion>

  <Accordion title="Including tracking pixels">
    Tracking pixels add latency and size to the email. For 2FA, delivery speed matters more than open tracking. Disable tracking for OTP emails.
  </Accordion>

  <Accordion title="Sending from a cold or shared domain">
    A domain without established reputation may experience delivery delays — exactly when you need speed most. Use a warmed sending domain.
  </Accordion>

  <Accordion title="Not rate limiting OTP requests">
    Without rate limits, an attacker can trigger thousands of OTP emails, flooding the user's inbox and burning through your sending quota.
  </Accordion>

  <Accordion title="Storing the raw OTP in the database">
    If your database is compromised, raw OTP codes give attackers immediate access. Hash the code before storing it, just like passwords.
  </Accordion>

  <Accordion title="Using the same code for multiple actions">
    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.
  </Accordion>
</AccordionGroup>

***

## Related Topics

<CardGroup cols={2}>
  <Card title="Password Reset Emails" icon="key" href="/knowledge-base/use-cases/password-reset-emails">
    Secure password reset flows and token management
  </Card>

  <Card title="Security Best Practices" icon="shield-check" href="/knowledge-base/best-practices/security">
    API key security, webhook verification, and account protection
  </Card>

  <Card title="Deliverability Best Practices" icon="inbox" href="/knowledge-base/best-practices/deliverability">
    Maximize inbox placement and delivery speed
  </Card>

  <Card title="Sending Reputation" icon="star" href="/knowledge-base/best-practices/sending-reputation">
    Build and protect your sender reputation
  </Card>
</CardGroup>
