Email verification codes (OTP) confirm a user controls an email address during signup, account recovery, or sensitive actions. Send via a transactional email service (Postmark, SendGrid, AWS SES, Resend), not your marketing ESP. Codes should be 6 digits, expire in 5-15 minutes, rate-limit per address, and arrive within 30 seconds. Deliverability is critical — verification codes that arrive late or land in spam break onboarding.
Email Verification Codes: Implementation and Deliverability
Email verification codes are part of the transactional email stack — the messages that have to arrive, fast, every time. When they fail, users can't sign up, can't recover passwords, can't complete sensitive actions. The deliverability requirements are different from marketing email and the infrastructure choices matter.
This guide covers implementing email verification code (OTP) flows that work reliably, with the infrastructure decisions that determine whether the codes actually arrive.
When you need verification codes
Common use cases for email verification codes:
| Use case | Cadence | Sensitivity |
|---|---|---|
| Signup confirmation | One per signup | Medium |
| Password reset | Triggered by user request | High |
| Login 2FA via email | Per login (for some auth flows) | High |
| Sensitive action confirmation (delete account, transfer funds) | Per action | Very high |
| Email change confirmation | Per email change | High |
Verification codes are typically more time-sensitive than verification links — users expect them within seconds, paste them within minutes, and abandon if they don't arrive.
Code generation best practices
The standard pattern:
| Element | Recommendation |
|---|---|
| Length | 6 digits (balance of usability and security) |
| Character set | Numeric only (typed easily, less error-prone) |
| Expiration | 5-15 minutes |
| Storage | Hashed (not plaintext) with expiration timestamp |
| Single-use | Invalidate after successful use |
| Rate limiting | Max 3-5 codes per address per hour |
| Re-send policy | Allow re-send after 30-60 seconds, max 3 attempts |
In code (pseudo):
import random
import hashlib
from datetime import datetime, timedelta
def generate_verification_code():
return ''.join(random.choices('0123456789', k=6))
def send_verification_code(email):
# Generate code
code = generate_verification_code()
# Hash for storage (don't store plaintext)
code_hash = hashlib.sha256(code.encode()).hexdigest()
# Store with expiration
db.store_code(
email=email,
code_hash=code_hash,
expires_at=datetime.utcnow() + timedelta(minutes=10)
)
# Send via transactional ESP
transactional_email.send(
to=email,
subject=f"Your verification code: {code}",
body=verification_template.render(code=code)
)
def verify_code(email, submitted_code):
code_hash = hashlib.sha256(submitted_code.encode()).hexdigest()
record = db.get_active_code(email=email)
if record and record.code_hash == code_hash and record.expires_at > datetime.utcnow():
db.mark_used(record)
return True
return False
Infrastructure for verification codes
Verification codes are transactional email. They need different infrastructure than marketing email:
Use a transactional ESP, not your marketing platform
| Use this | Don't use this |
|---|---|
| Postmark | Marketing platforms (Mailchimp, HubSpot, etc.) |
| SendGrid | Cold outreach tools (Smartlead, Instantly) |
| AWS SES | Personal Gmail accounts |
| Resend | Your corporate mail server |
| Mailgun | |
| Brevo (formerly Sendinblue) |
Transactional ESPs are optimized for fast, reliable, single-recipient sends. Marketing ESPs prioritize bulk send efficiency, which adds latency unacceptable for verification flows.
For ESP selection see Postmark vs SendGrid vs other transactional services.
Use a dedicated transactional subdomain
Send from a subdomain reserved for transactional email:
auth.yourcompany.comort.yourcompany.com(not your marketing subdomain)- Configured with SPF, DKIM, DMARC properly aligned
- Isolated reputation from marketing sends
See sender reputation: domain vs IP for why subdomain isolation matters.
Authenticate fully
For verification codes to consistently reach the inbox:
- SPF aligned with sending domain
- DKIM signing on the sending domain
- DMARC policy (at least
p=quarantine) on the parent domain
See the DMARC setup guide for configuration. Without proper authentication, codes will increasingly land in spam, especially after Gmail/Yahoo's 2024 bulk sender rules.
Email content for verification codes
Best practices for the actual verification email:
Subject line
Clear and specific:
- "Your verification code: 482931" — code in subject for fast scanning
- "Your code for [App]: 482931" — branded variant
- "[App] verification code" — minimal, code only in body
Avoid:
- Generic subjects ("Action required") — pattern-matches as phishing
- Marketing language ("Welcome to [App]!") — triggers Promotions filtering
- All caps or excessive punctuation — spam signals
Body content
Keep it minimal:
Your verification code is:
482931
This code expires in 10 minutes.
If you didn't request this, you can ignore this email.
— [App] team
What to include:
- The code, displayed prominently
- Expiration time
- Brief security note ("if you didn't request this...")
- Single sender identifier
What to exclude:
- Marketing links
- Social media buttons
- Multiple CTAs
- Image-heavy templates
- Long policy/legal footers in the body (link instead)
Heavy templated designs increase Promotions placement and slow rendering on mobile. Minimal designs reach inbox faster.
Delivery speed targets
For verification code delivery:
| Time to delivery | User experience |
|---|---|
| <10 seconds | Excellent — feels instant |
| 10-30 seconds | Good — within expected range |
| 30-60 seconds | Acceptable — some user concern |
| 60-120 seconds | Poor — users start re-sending |
| >120 seconds | Failed — users abandon |
Achieving sub-30-second delivery requires:
- Transactional ESP with established reputation
- Dedicated transactional subdomain
- Proper authentication
- No queue delays in your application
- Recipient's mail provider not throttling
Common deliverability problems
Codes landing in spam
Causes:
- Sending from marketing infrastructure
- Missing or misconfigured authentication
- Generic subject that pattern-matches phishing
- Recipient mail provider filtering all-numeric subjects aggressively
- Sender domain has poor reputation
Fixes:
- Move to transactional ESP and dedicated subdomain
- Verify SPF/DKIM/DMARC configuration
- Use branded subject ("[App] code") instead of generic
- Establish reputation through consistent verification volume
Codes taking too long
Causes:
- Marketing ESP with queue delays
- Authentication failures causing greylist delays
- Recipient mail provider throttling unknown sender
- Application queue delays before send
Fixes:
- Use transactional ESP optimized for fast delivery
- Fix authentication
- Build sender reputation over time
- Audit application code for delays before send
Codes not arriving at all
Causes:
- Hard bounces (typoed address, dead mailbox)
- Recipient mail provider blocking the sender
- Spam filter rejecting silently
- Application bug not actually sending
Fixes:
- Verify address at signup before sending verification
- Monitor delivery rate per provider
- Use ESP delivery webhooks to confirm sends
- Add comprehensive logging
Practitioner note: I worked with a SaaS client whose verification codes were averaging 90+ seconds to deliver because they were sent via their marketing platform (Klaviyo). Signups were dropping at the verification step. Moving verification sends to Postmark on a dedicated transactional subdomain brought delivery to under 8 seconds and signup completion rate jumped 14%. Same email content, much better infrastructure.
Rate limiting and abuse prevention
Verification code endpoints need protection:
- Rate limit per email address: 3-5 codes per hour max
- Rate limit per IP: 10-20 codes per hour max
- Exponential backoff on re-send requests
- CAPTCHA after 3 failed verification attempts
- Lock out address after 10 failed verification attempts in 1 hour
Without these limits, verification endpoints become attack vectors for spam, harassment, and account enumeration.
Verification code vs. verification link
The two patterns compared:
| Aspect | Verification code | Verification link |
|---|---|---|
| User experience on mobile | Better (paste code, stay in app) | Worse (open mail, click link, switch apps) |
| Security for sensitive actions | Better (2FA-style) | Equivalent if link is single-use |
| Email content size | Smaller (code only) | Larger (link + context) |
| Deliverability | Similar | Similar |
| Code expiration | Standard 5-15 min | Standard 24 hours typical |
| Resilience to email forwarding | Worse (recipient must access mailbox) | Better (link works from anywhere) |
| Implementation complexity | Slightly more (verification endpoint) | Slightly less (just URL) |
For most modern signup flows, codes are preferred. For password reset, links are still common.
Compliance considerations
Verification code emails are transactional, not marketing:
- CAN-SPAM (US) treats transactional differently from marketing
- GDPR allows transactional email under "necessary for service" basis
- Don't include marketing content in verification emails (changes them to mixed/marketing)
- Provide unsubscribe link only if mixed-purpose (better to keep purely transactional)
Mixed-purpose emails (verification + marketing content) lose the transactional protections and add deliverability risk. Keep verification emails pure.
If you need help building reliable verification code flows with proper infrastructure, book a consultation. I work with SaaS teams on transactional email infrastructure and authentication setup.
Sources
- Postmark transactional email documentation
- SendGrid transactional email best practices
- AWS SES sending best practices
- RFC 6238 — TOTP (related OTP standard)
- Auth0 email verification documentation
v1.0 · May 2026
Frequently Asked Questions
What is an email verification code?
An email verification code (often called OTP — one-time password) is a short numeric code sent to a user's email address to confirm they control it. Used in signup confirmation, password recovery, login two-factor authentication, and sensitive action confirmation. Typically 4-8 digits, expires in 5-15 minutes, single-use. The alternative is verification via clickable link.
How does email verification with code work?
User enters email address; your system generates a random code (usually 6 digits), stores it with an expiration timestamp, and sends it via transactional email. User receives the email, enters the code into your interface. Your system checks the entered code against the stored value and timestamp. Match within expiration window = verified; otherwise reject and prompt to resend.
Verify email code vs. verify email link — which is better?
Codes work better on mobile (no need to switch apps and back), better when the verification is a step in a flow you don't want to leave, and better for sensitive actions (login 2FA). Links work better when you don't need to keep the user in the original flow, for password reset, and when the action is one-time. Most modern signup flows use codes for the first verification.
How fast should email verification codes arrive?
Target: under 30 seconds, ideally under 10 seconds. Anything over 60 seconds breaks the user experience — users assume it's not coming and either retry or abandon. Achieving fast delivery requires proper sending infrastructure (transactional ESP, not marketing platform), authenticated sending, and dedicated transactional subdomain.
Why are my email verification codes going to spam?
Common causes: sending from marketing infrastructure (Promotions filtering), missing SPF/DKIM/DMARC alignment, generic subject line matching marketing patterns, the recipient hasn't whitelisted your domain, sending from a recently-warmed sender without established reputation, or content that triggers filters. Fix: send from authenticated transactional subdomain via dedicated transactional ESP, with clear subject line and minimal content.
Want this handled for you?
Free 30-minute strategy call. Walk away with a plan either way.