Email 2FA codes exist somewhere on the deliverability difficulty spectrum between password resets and phishing. They are short, urgent, numeric, and triggered repeatedly for the same recipient. Every one of those traits hits a spam heuristic.
When a 2FA email lands in spam, your user cannot log in. Full stop. There is no graceful degradation. No retry that magically works. And because the code expires in 5-10 minutes, even a minor delivery delay is effectively a failure.
The heuristics working against you
- Short body. "Your verification code is 382910. It expires in 10 minutes." Two sentences, one number, urgency language. Reads exactly like the phishing templates Bayesian filters were trained on.
- Numeric code in subject line. "Your code: 382910" triggers an anti-phishing feature in Gmail that specifically looks for credential-like strings in subjects.
- Rapid repeat sends. If a user clicks "resend code" three times, you send three nearly-identical short emails from the same sender to the same recipient in 60 seconds. Frequency heuristics flag this.
- Urgency vocabulary. "verify", "secure", "expires", "immediately" — all phishing fingerprint words.
When 2FA fails, the user does not think "my spam filter ate the email". They think "this app is broken". They uninstall, chargeback, or leave a review. The cost of a missed 2FA email is measured in lost users and security complaints, not support tickets.
Measuring 2FA placement in practice
Fire the production 2FA flow with seed mailboxes, capture placement per provider, record time-to-inbox. A code that arrives 3 minutes after the 5-minute expiry is useless even if it is technically in the inbox.
# Trigger real 2FA flow to seed:
for seed in seeds.list(group='2fa-test'):
start = time.now()
trigger_2fa(seed.address)
delivery = poll_placement(seed.address, timeout=120)
print({
"provider": seed.provider,
"folder": delivery.folder,
"seconds": (delivery.arrived_at - start).total_seconds()
})
# Fail the build if:
# - any seed shows "spam"
# - any delivery > 30 secondsAcceptable metrics
- Placement. 99%+ inbox at Gmail, Outlook, Yahoo, iCloud.
- Latency. Under 15 seconds p95, under 30 seconds p99.
- Retry delivery. Same placement and latency on the second and third send to the same recipient.
Fixes ordered by impact
- Dedicated subdomain and sender.
auth.yourapp.comwith its own DKIM selector. Never share with marketing or notifications. - Rewrite the template. Include context that phishing mail does not have: the user's name, the name of the device or browser triggering the login, the IP city. This differentiates legitimate 2FA from phishing in the body.
- Do not put the code in the subject line. Subject: "Sign-in request for your Acme account". Body contains the code. Gmail's anti-phishing heuristic that looks for codes in subjects will flag if you put it there.
- Add a plain-text alternative. Short HTML-only emails look thin. Include a text/plain part with the code, a one-line explanation, and a support contact.
- Use a dedicated IP if volume allows. Shared IPs at transactional ESPs bounce reputation around — one misconfigured customer on the same IP can tank your 2FA placement for an hour.
Email 2FA should not be your only second factor. Offer TOTP (Google Authenticator, Authy) as a primary option, with email as fallback. TOTP has zero delivery risk. For high-value accounts, offer WebAuthn too.
Template that tends to inbox
Subject: Sign-in request from Chrome on macOS (London, UK)
Hi {{first_name}},
We received a sign-in request for your Acme account from:
Device: Chrome 121 on macOS
Location: London, UK (approximate, from IP)
Time: {{timestamp_iso}}
If this was you, enter this code on the Acme sign-in page:
{{code}}
The code expires at {{expiry_iso}} ({{expiry_human}}).
If you did NOT try to sign in, change your password now:
https://yourapp.com/account/security
--
Acme Security Team
123 Example Street, City, Country
support@yourapp.comNotice: full text-heavy body, context about the login request, named support team, physical address, plain-text URL. Structurally the opposite of phishing despite containing a one-time code.
Infrastructure checks
- SPF
~all, not-allinitially. If you miss an include, hard fail (-all) will reject legitimate 2FA. Start with soft fail, monitor DMARC reports, then tighten to-allonce clean. - DKIM key length 2048 bits. 1024-bit keys still work but are increasingly penalised by Gmail and Outlook.
- DMARC
p=quarantineorp=reject. A missing DMARC policy is a deliverability handicap for critical auth mail. - MTA-STS and TLS reporting. Publish
_mta-sts.yourapp.com. Providers are increasingly requiring TLS-enforced delivery for transactional mail.
Your 2FA template gets touched more than you think — i18n updates, branding tweaks, legal footer changes. Every one is a potential placement regression. Wire an automated test into the auth service CI.