Skip to content
Supra Builds

Building a Zero-Cost Enterprise Email API: Complete Guide to Timing Attack and Header Injection Protection

A step-by-step guide to building a free, secure email notification API using Cloudflare Workers and Email Routing — with real security hardening.

黃小黃

黃小黃

· 10 min read

Building a Zero-Cost Enterprise Email API: Complete Guide to Timing Attack and Header Injection Protection

Have you ever found yourself in this situation: your project needs to send system notifications, but SendGrid charges monthly fees, AWS SES setup is complicated, and self-hosting an email server is a maintenance nightmare?

In this article, I'll share how I built a completely free email notification API using Cloudflare Workers + Email Routing. More importantly, I'll dive deep into two often-overlooked security attacks: Timing Attacks and Email Header Injection—and how to defend against them.

This isn't just theory. I've open-sourced the entire project: worker-email-notifier. Feel free to use it!


🤔 Why Build Your Own Email Notification System?

The Cost Problem with Paid Services

Let's look at the pricing of mainstream email services:

ServiceFree TierBeyond Free Tier
SendGrid100 emails/day (60-day trial only)Starting at $19.95/month
AWS SES3,000 emails/month (12-month trial)$0.10/1000 emails
Mailgun100 emails/dayStarting at $15/month
Postmark100 emails/monthStarting at $15/month

For personal projects or small teams, these costs add up. More importantly—I just want to send a system notification. Why does it have to be this complicated?

Cloudflare's Free Tier

Cloudflare Workers + Email Routing offers:

  • 100,000 API requests/day

  • Generous email sending limits

  • No credit card required

  • Global edge network with ultra-low latency

For system notifications, monitoring alerts, and CI/CD notifications, this quota is more than enough.

Use Cases: What It's For and What It's Not

Before diving in, let's clarify what this system is designed for:

✅ Good fit:

  • Server monitoring alerts (high CPU, service down)

  • Application event notifications (new orders, payment success)

  • CI/CD pipeline notifications (build success/failure)

  • IoT device alerts

  • Internal team notifications

❌ Not suitable for:

  • Marketing emails / newsletters (Email Routing has whitelist restrictions)

  • User-to-user messaging

  • Transactional emails to arbitrary external users

Clear boundaries are important—this is a design decision, not a limitation.


🏗️ Technology Stack and Architecture Design

Why Cloudflare Workers?

FeatureCloudflare WorkersAWS Lambda
Cold startAlmost noneCan be seconds
Global deploymentAutomatic (edge network)Manual configuration
Free tier100,000 req/day1M req/month
Email integrationNative Email RoutingRequires SES
Setup complexityLowMedium-High

The biggest advantage of Workers is native integration with Email Routing—no additional email service needed. Just configure DNS and you're ready to send.

System Architecture Overview

flowchart TD
    A[👤 Client] -->|REST API Request| B[⚡ Cloudflare Worker]
    B --> C[1. CORS Validation]
    C --> D[2. API Key Check 🔒]
    D -->|Timing Attack Protection| E[3. Input Validation 🔒]
    E -->|Header Injection Protection| F[4. Email Sending]
    F --> G[📧 Email Routing]
    G -->|Recipient Whitelist| H[✅ Recipient Inbox]

Key design decisions:

  1. Multi-platform isolation: Each platform has its own sender, API key, and recipient whitelist

  2. Security-first: Multiple validation layers before sending any email

  3. Flexible configuration: All settings managed via wrangler.toml


💻 Core Implementation

Project Structure

worker-email-notifier/
├── src/
│   └── index.js          # Main code (~450 lines)
├── wrangler.toml         # Workers configuration
├── wrangler.toml.example # Configuration template
└── package.json

Platform Configuration (wrangler.toml)

[[send_email]]
name = "MAILER_A"
destination_address = "boss@gmail.com"
allowed_destination_addresses = ["boss@gmail.com", "admin@company.com"]

[vars.PLATFORMS.platform-a]
senderEmail = "noreply@your-domain.com"
senderName = "Platform A Notifications"
mailer = "MAILER_A"

Each platform binds to a MAILER, and each MAILER has its own whitelist—that's the key to isolation.

Email Sending Logic

import { createMimeMessage } from "mimetext";

async function sendEmail(mailer, from, fromName, to, subject, content, html) {
  const msg = createMimeMessage();
  msg.setSender({ name: fromName, addr: from });
  msg.setRecipient(to);
  msg.setSubject(subject);

  // Provide both plain text and HTML versions
  msg.addMessage({
    contentType: "text/plain",
    data: content,
  });

  if (html) {
    msg.addMessage({
      contentType: "text/html",
      data: html,
    });
  }

  const message = new EmailMessage(from, to, msg.asRaw());
  await mailer.send(message);
}

Using mimetext to create MIME-compliant email format, supporting both plain text and HTML emails.


🔐 Security Protection (Part 1): Timing Attack Defense

This is one of the most important sections of this article. You may have never heard of "timing attacks," but they're a hidden killer for API security.

What is a Timing Attack?

Imagine a combination lock: every time you get a digit right, the lock makes a subtle "click" sound. A thief can listen to the sounds and guess the combination one digit at a time.

Timing attacks work exactly the same way—attackers measure server response times to deduce your API key.

Why is === Not Safe?

JavaScript's string comparison uses "short-circuit comparison":

// Assume the correct API key is "secret123"
apiKey === "secret123"

// Comparison process:
// "a" vs "s" → 1st char differs, immediately returns false (very fast)
// "s" vs "s" → same, continue comparing
// "sa" vs "se" → 2nd char differs, returns false (slightly slower)
// "se" vs "se" → same, continue...
// ...and so on

What's the problem?

  • First character wrong: comparison time ~0.1ms

  • First five characters correct: comparison time ~0.5ms

  • All correct: comparison time ~1ms

Attackers can:

  1. Try "a000000..." → measure time

  2. Try "b000000..." → measure time

  3. Try "s000000..." → this one's slower! First char is "s"

  4. Try "sa00000..." → measure time

  5. ...repeat until the entire API key is guessed

This is why you should never use === to compare secrets.

Constant-Time Algorithm Implementation

The solution is "constant-time comparison"—the comparison takes the same amount of time regardless of whether the strings match:

function timingSafeEqual(a, b) {
  const encoder = new TextEncoder();
  const aBytes = encoder.encode(a);
  const bBytes = encoder.encode(b);

  // Even when lengths differ, perform full comparison
  // to avoid leaking length information
  if (aBytes.length !== bBytes.length) {
    // Compare bBytes against itself to consume constant time
    // proportional to input length, then return false
    let result = 1;
    for (let i = 0; i < bBytes.length; i++) {
      result |= aBytes[i % aBytes.length] ^ bBytes[i];
    }
    return false;
  }

  // Use XOR operation, accumulate all differences
  let result = 0;
  for (let i = 0; i < aBytes.length; i++) {
    result |= aBytes[i] ^ bBytes[i];
  }

  // result is 0 only if strings are identical
  return result === 0;
}

Why does this work?

  1. XOR operation: Same = 0, different = non-zero

  2. OR accumulation: If any bit differs, result won't be 0

  3. Full iteration: Loop runs completely regardless of match

  4. Constant time: Execution time depends only on string length, not content

Practical Application

function validateApiKey(providedKey, env, platformId) {
  // Try to get platform-specific key
  const apiKeys = parseApiKeys(env.API_KEYS);
  const platformKey = apiKeys[platformId];

  if (platformKey) {
    // Use constant-time comparison!
    return timingSafeEqual(providedKey, platformKey);
  }

  // Fall back to shared key
  if (env.API_KEY) {
    return timingSafeEqual(providedKey, env.API_KEY);
  }

  return false;
}

💡 Note: Cloudflare Workers now supports crypto.subtle.timingSafeEqual() natively, and also supports crypto.timingSafeEqual() from node:crypto with the nodejs_compat flag enabled. The custom implementation above is kept for educational purposes—in production, prefer the built-in API.


🛡️ Security Protection (Part 2): Email Header Injection Defense

The second attack to defend against is "email header injection"—more common but equally overlooked.

What is Header Injection?

SMTP email structure uses \r\n to separate different headers:

From: sender@example.com\r\n
To: recipient@example.com\r\n
Subject: Hello\r\n
\r\n
Email body...

If an attacker can inject \r\n into the subject, they can insert arbitrary headers:

// Malicious input
const subject = "Hello\r\nBcc: victim1@example.com, victim2@example.com\r\n\r\nSpam content";

// Actual generated email
/*
Subject: Hello
Bcc: victim1@example.com, victim2@example.com

Spam content
*/

The attacker successfully added their own recipients!

Impact of the Attack

  • 📧 Spam distribution: Send massive spam using your domain

  • 🎭 Phishing: Forge the From field for phishing attacks

  • 📛 Domain reputation damage: Your domain may be blacklisted

  • 🔓 Data leakage: Secretly BCC sensitive information to attackers

Defense Strategies

Strategy 1: Strict Newline Detection

// Check if subject contains newline characters
if (/[\r\n]/.test(subject)) {
  return jsonResponse(
    { success: false, error: "Invalid subject: contains forbidden characters" },
    400
  );
}

Simple but effective—reject any subject containing \r or \n.

Strategy 2: Strict Email Format Validation

function isValidEmail(email) {
  // Basic length check
  if (!email || email.length > 254) {
    return false;
  }

  // Prevent consecutive dots (possible path traversal)
  if (/\.\./.test(email)) {
    return false;
  }

  // Prevent leading or trailing dots
  if (email.startsWith(".") || email.endsWith(".")) {
    return false;
  }

  // Practical email validation (RFC 5322-inspired)
  const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;

  return emailRegex.test(email);
}

This validation function:

  • Limits maximum length (254 characters per RFC)

  • Blocks consecutive dots (prevents ../ style attacks)

  • Uses strict regex validation

Strategy 3: HTML Content Escaping (XSS Prevention)

If allowing HTML emails, also prevent XSS:

function escapeHtml(text) {
  const map = {
    "&": "&amp;",
    "<": "&lt;",
    ">": "&gt;",
    '"': "&quot;",
    "'": "&#039;",
  };
  return text.replace(/[&<>"']/g, (char) => map[char]);
}

Error Message Sanitization

Another easily overlooked point—error messages can also leak sensitive information:

function sanitizeErrorMessage(message) {
  if (typeof message !== "string") {
    return "An error occurred";
  }

  return message
    // Remove stack traces
    .replace(/at\s+.*:\d+:\d+/g, "")
    // Remove file paths
    .replace(/\/[\w/.-]+/g, "[path]")
    // Remove sensitive keywords
    .replace(/password|secret|key|token/gi, "[redacted]")
    .trim()
    .substring(0, 200);
}

Never expose internal implementation details in error messages.


🚀 Deployment and Testing

Deployment Steps

# 1. Install dependencies
npm install

# 2. Copy and modify configuration
cp wrangler.toml.example wrangler.toml
# Edit wrangler.toml to set your domain and platforms

# 3. Login to Cloudflare
wrangler login

# 4. Generate and set API Key
npm run generate-key
wrangler secret put API_KEY

# 5. Deploy
npm run deploy

Testing the API

curl -X POST https://email-notifier.your-subdomain.workers.dev \
  -H "Content-Type: application/json" \
  -H "X-API-Key: your-api-key" \
  -d '{
    "platformId": "platform-a",
    "to": ["boss@gmail.com"],
    "subject": "🔔 System Notification",
    "content": "This is a test email",
    "html": "<h1>System Notification</h1><p>This is a test email</p>"
  }'

Success response:

{
  "success": true,
  "message": "Email sent: 1 success, 0 failed",
  "platform": "platform-a",
  "details": [
    { "to": "boss@gmail.com", "status": "fulfilled" }
  ]
}

Common Issues Troubleshooting

IssuePossible CauseSolution
401 UnauthorizedWrong API KeyVerify header name is X-API-Key
400 Invalid platformplatformId doesn't existCheck platform config in wrangler.toml
500 Email failedRecipient not in whitelistAdd to allowed_destination_addresses
CORS errorOrigin not allowedSet ALLOWED_ORIGINS environment variable

📦 Open Source Project and Recommendations

Project Information

Free Tier Limits

ItemFree QuotaSuitable For
API requests100,000/dayMost small-medium applications
Email sendingGenerous daily limitMore than enough for system notifications

Extension Suggestions

Want to extend the functionality? Here are some directions:

  1. Add platforms: Add new [[send_email]] and PLATFORMS config in wrangler.toml

  2. Email templates: Store HTML templates in KV Storage

  3. Rate limiting: Integrate with Cloudflare WAF Rate Limiting rules

  4. Logging: Use Workers Analytics or Logpush


📝 Conclusion

This article shared how to build a zero-cost email notification system using Cloudflare Workers + Email Routing, and more importantly, explored two commonly overlooked security attacks in depth.

Key Takeaways

  1. Zero-cost doesn't mean low-quality Cloudflare's free tier is sufficient for most notification scenarios

  2. Timing attacks are a hidden risk Never use === to compare secrets—use constant-time algorithms

  3. Input validation is fundamental Header injection attacks are simple but dangerous—validate all inputs strictly

  4. Security is not optional—it's essential Even for small features, the cost of security measures is far less than post-incident remediation

Final Thoughts

The full project is open-sourced on GitHub — feel free to use it, fork it, or contribute.


References:

黃小黃

黃小黃

Full-stack product engineer and open source contributor based in Taiwan. I specialize in building practical solutions that solve real-world problems with focus on stability and user experience. Passionate about Product Engineering, Solutions Architecture, and Open Source collaboration.

More Posts

Your API Wasn't Built for AI Agents — Here's How to Fix It

Your API Wasn't Built for AI Agents — Here's How to Fix It

By 2026, over 30% of API traffic will come from AI agents rather than human-driven applications. That number will keep climbing. Here's the uncomfortable truth: most APIs were designed for human developers who read documentation, interpret ambiguous ...

黃小黃 黃小黃 · · 16 min