Protecting Serverless APIs from Abuse: Email Validation as a First Line of Defense
Every Bot Signup Costs You Real Money
A traditional server idles between requests. You pay the same monthly bill whether you get ten signups or ten thousand. Serverless flips that. AWS Lambda charges $0.20 per million invocations plus compute time. Vercel bills per function execution and CPU cycle. Cloudflare Workers charge per request past the free tier.
That pricing model is great when your traffic is real. When it’s not, you’re subsidizing bots.
Up to 30% of free-tier signups come from bots, scripted abuse, or users cycling through disposable email addresses. A SaaS app running signup, verification email, and welcome sequence logic on Lambda could burn three to five function invocations per fake registration. At scale, that’s not a rounding error. One startup reported a $100,000 AWS bill after bot traffic hit their unprotected endpoints.
Sound extreme? Sure. But even modest abuse adds up. If 1,000 bot signups per day each trigger three Lambda functions, a DynamoDB write, and an SES transactional email, you’re paying a few dollars a month in pure compute waste. Small on its own, but the real damage is downstream: polluted data, inflated SES bounce rates, and a user table full of tempmail.com addresses nobody will ever open.
Why Serverless Apps Are Uniquely Vulnerable
Traditional servers have a natural ceiling. Your Rails app on a $40/month VPS handles maybe 500 concurrent connections before it starts dropping requests. Bots hit that wall and give up or slow down. Annoying, but self-limiting.
Serverless auto-scales by design. Every request gets its own function execution. There’s no wall. Your infrastructure expands to meet the attack, and your bill expands with it. The feature that makes serverless great for handling legitimate traffic spikes is the same feature that makes it expensive under abuse.
The fix isn’t to disable auto-scaling. That defeats the purpose. The fix is to reject garbage requests before they trigger downstream work. Email validation at the API layer does exactly that.
Middleware Pattern: Validate Before You Process
The cheapest function invocation is the one that returns early. If you validate the email address in the first 50ms of your request handler and reject bad ones, the downstream logic (database writes, transactional emails, third-party API calls) never fires.
Here’s the pattern in Express, deployed as a Lambda function behind API Gateway:
// middleware/validateEmail.js
const validateEmail = async (req, res, next) => {
const { email } = req.body;
if (!email || !email.includes("@")) {
return res.status(422).json({ error: "Valid email required" });
}
// Quick domain check before API call
const domain = email.split("@")[1];
if (DISPOSABLE_DOMAINS.has(domain)) {
return res.status(422).json({ error: "Disposable emails not accepted" });
}
try {
const response = await fetch("https://api.truemail.io/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TRUEMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email, checks: ["syntax", "mx"] }),
});
if (!response.ok) {
return next(); // Fail open if API is down
}
const result = await response.json();
if (result.status === "undeliverable") {
return res.status(422).json({ error: "That email isn't deliverable" });
}
if (result.disposable) {
return res.status(422).json({ error: "Disposable emails not accepted" });
}
req.emailValidation = result;
next();
} catch {
next(); // Fail open on network errors
}
};
Mount it on your signup route:
// routes/auth.js
const express = require("express");
const router = express.Router();
const { validateEmail } = require("../middleware/validateEmail");
router.post("/signup", validateEmail, async (req, res) => {
// Only runs if the email passed validation
const user = await createUser(req.body);
await sendWelcomeEmail(user.email);
res.status(201).json({ user });
});
Three things to notice. The middleware fails open: if MailCop’s API is unreachable, the request proceeds. Never let a third-party outage block your entire signup flow. The disposable domain check happens before the API call, saving you a request for the most obvious offenders. And the validation result attaches to req so downstream handlers can use it without re-fetching.
Fastify uses hooks instead of middleware, but the pattern is identical:
// plugins/emailValidation.js
const fp = require("fastify-plugin");
async function emailValidation(fastify) {
fastify.addHook("preHandler", async (request, reply) => {
if (request.routeOptions.config?.validateEmail !== true) return;
const { email } = request.body || {};
if (!email) return;
const domain = email.split("@")[1];
if (DISPOSABLE_DOMAINS.has(domain)) {
return reply.code(422).send({ error: "Disposable emails not accepted" });
}
try {
const response = await fetch("https://api.truemail.io/v1/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TRUEMAIL_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email, checks: ["syntax", "mx"] }),
});
if (response.ok) {
const result = await response.json();
if (result.status === "undeliverable") {
return reply.code(422).send({ error: "That email isn't deliverable" });
}
request.emailValidation = result;
}
} catch {
// Fail open
}
});
}
module.exports = fp(emailValidation);
Tag the routes that need it:
fastify.post("/signup", { config: { validateEmail: true } }, signupHandler);
This opt-in approach keeps validation off routes that don’t accept email input. Your health check endpoint doesn’t need it. Your webhook receiver doesn’t need it. Only the routes where users submit an email address.
For a deeper walkthrough of the email validation API itself, including response formats and check types, that guide covers the full spec.
The Disposable Email Problem
Disposable email services are the weapon of choice for bot operators and trial abusers. Over 180,000 known disposable domains exist across various tracking lists, with new ones appearing daily. Disposable addresses show up in 10-30% of freemium SaaS signups, depending on the product and industry.
You can’t outrun this with a static blocklist alone. A hardcoded Set of 500 domains catches the big players (Guerrilla Mail, Mailinator, 10 Minute Mail) but misses the long tail. New domains spin up faster than any static list updates.
Two layers work better than one. Use a local disposable email blocklist for the instant, zero-latency check on known domains. Then let the validation API catch newer disposable services it detects through live analysis. The local check is free. The API call is a fraction of a cent. Both together cost far less than processing a fake signup through your entire stack.
// Fast local check (zero latency, zero cost)
const DISPOSABLE_DOMAINS = new Set([
"guerrillamail.com",
"mailinator.com",
"tempmail.com",
"yopmail.com",
"throwaway.email",
"10minutemail.com",
"sharklasers.com",
"guerrillamail.info",
]);
function quickDisposableCheck(email) {
const domain = email.split("@")[1]?.toLowerCase();
return DISPOSABLE_DOMAINS.has(domain);
}
That catches the top offenders in microseconds. Everything else goes to the API.
Rate Limiting: The Other Half of the Equation
Email validation blocks bad addresses. Rate limiting blocks bad behavior. You need both.
A sophisticated bot won’t use tempmail.com. It’ll use freshly registered domains that pass syntax, MX, and even SMTP checks. Email validation can’t catch those. Rate limiting can.
For serverless apps, implement rate limiting at the API Gateway level, not inside your function. AWS API Gateway supports throttling natively: set a rate (requests per second) and a burst (maximum concurrent). Vercel and Cloudflare both offer similar controls. The request gets rejected before your function even cold-starts.
Inside your function, add a secondary check for signup-specific abuse:
// Simple per-IP rate tracking with your cache layer
async function checkSignupRate(ip, cache) {
const key = `signup_rate:${ip}`;
const count = parseInt(await cache.get(key)) || 0;
if (count >= 5) {
return false; // Max 5 signups per hour per IP
}
await cache.set(key, String(count + 1), { ex: 3600 });
return true;
}
Five signups per hour per IP is generous for legitimate users. Nobody’s creating five accounts in sixty minutes for legitimate reasons. Bots burn through that in seconds.
Caching Validation Results
Calling the validation API on every request is wasteful. The same email address submitted twice in ten minutes doesn’t need two API calls.
Cache deliverable results for 24 hours. Cache undeliverable results for 1 hour (the address could get fixed). Cache disposable flags permanently, or close to it. A disposable domain isn’t going to become legitimate overnight.
async function validateWithCache(email, cache) {
const cacheKey = `email:${email.toLowerCase()}`;
const cached = await cache.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const result = await callValidationAPI(email);
const ttl =
result.disposable ? 604800 // 7 days for disposable
: result.status === "deliverable" ? 86400 // 24 hours for valid
: 3600; // 1 hour for invalid
await cache.set(cacheKey, JSON.stringify(result), { ex: ttl });
return result;
}
On a serverless stack, your cache layer is typically Redis (ElastiCache or Upstash). If you’re on Cloudflare, Workers KV works. The Cloudflare Worker email validation tutorial covers this caching pattern in detail, including the TTL strategy and KV-specific gotchas.
For a Next.js app with server actions, the same caching principle applies. Validate once, cache the result, and skip redundant API calls when users resubmit the form.
The Cost Math
Let’s make this concrete.
Say you’re running a SaaS app on Lambda with 1,000 signups per day. About 30% are fake (bots and disposable emails). Each fake signup triggers:
- 1 Lambda invocation for the signup handler ($0.0000002)
- 1 DynamoDB write ($0.00000125)
- 1 SES email for verification ($0.0001)
- 1 Lambda invocation for the verification callback ($0.0000002)
Per fake signup, that’s roughly $0.000102 in request charges alone. Lambda compute time (duration charges) adds more depending on your handler’s memory and runtime, but the per-signup cost stays small. At 300 fake signups per day, you’re burning about $1/month in raw compute. Add a welcome email sequence (3 more SES sends per user) and the number climbs, but the direct cost still isn’t dramatic. The real expense is everything the numbers don’t capture.
The bigger cost isn’t the compute. It’s the data pollution. A database full of undeliverable addresses inflates your active user count, skews conversion metrics, and tanks your SES sending reputation. AWS places your SES account under review at a 5% bounce rate and may pause sending entirely at 10%. If 30% of your “users” have fake emails, every campaign you send pushes you closer to that cliff.
Email validation as middleware catches those 300 daily fakes at the door. The API call costs a fraction of a cent per verification. The direct compute savings alone won’t pay for it. But the value of clean data, accurate conversion metrics, and a sender reputation that stays out of the review zone makes it a clear win. One SES sending pause costs more in lost revenue than a year of validation API calls.
Worth it? Every time.
Where This Fits in Your Stack
Don’t validate everywhere. Validate at the entry points.
Your signup endpoint needs it. Your invite-a-friend endpoint needs it. Your “update my email” endpoint needs it. Your internal admin tools, your webhook receivers, your health checks? No.
The middleware approach makes this easy. Tag the routes that accept user-submitted email addresses. Leave everything else alone. One middleware function, three or four route decorators, and your serverless bill stops subsidizing bots.
If you’re building on a framework that already has validation baked in (like Next.js with Zod schemas), layer the API check on top of the client-side validation. Client-side catches typos. Server-side catches everything else. The combination means bad addresses don’t survive past the first request.
The Practical Checklist
- Add a local disposable domain blocklist. Zero cost, instant rejection for the top 500 offenders.
- Wire email validation into your signup middleware. Fail open if the API is down.
- Cache results. 24 hours for deliverable, 1 hour for undeliverable, 7 days for disposable.
- Set rate limits at the API Gateway level. Five signups per IP per hour is a reasonable starting point.
- Monitor your SES bounce rate weekly. If it’s climbing, your validation layer has gaps.
That’s five changes. You can ship all of them in an afternoon. Your serverless bill will thank you next month.