Skip to content

Backend

Rate Limiting at the Edge — Netlify Functions Pattern for Form Spam + Brute Force Defense

Middleware + Postgres TTL + sliding-window counters. No Redis, no Upstash, no third-party SaaS.

🚫 ⏱️ 🔒

You deploy a contact form to production. By day two, a bot has submitted 8,000 copies. By day five, someone's brute-forcing your OAuth callback endpoint at 500 req/sec. By day six, your Stripe webhook receiver is processing duplicate events because you didn't rate-limit the sender. Rate limiting isn't nice-to-have; it's the fence around your infrastructure.

The default move? Buy Upstash Redis or run your own Redis cluster. Both are overkill. Velocity X uses a simpler pattern: Netlify Edge Middleware + Supabase Postgres. Store rate-limit counters in a single `rate_limits` table with a TTL. Check the count before responding. If you're over quota, reject. If you're under, increment and let through. No external dependency, no SaaS billing, latency measured in single-digit milliseconds.

Why Rate Limiting Matters (More Than You Think)

Form spam fills your database with garbage; you waste manual time deleting it. Brute-force login attacks can crack weak passwords if given enough time; rate limiting forces attackers to slow down. OAuth redirect attacks flood your callback endpoint, creating billing spikes on serverless functions. Webhook replay attacks (e.g., Stripe retrying a failed webhook 5 times) can double-process charges if your handler isn't idempotent. Rate limits are the guard rail.

The math: a single form submission on Netlify Functions costs ~$0.000000183 per invocation. If 10,000 spam bots hit your endpoint daily, that's $0.0018/day, or $0.66/year. Sounds free. But Supabase storage scales; each spam record adds up; you pay egress when exporting. Brute-force login attempts: a single attacker doing 100 req/sec for 1 hour = 360,000 function invocations. Even on a free tier, the database bloat is a problem. Rate limiting costs nothing and saves both money and sanity.

Architecture: Edge Middleware + Postgres Sliding Window

Netlify Edge Middleware runs on the CDN before your function receives the request. You check a rate-limit counter in Postgres, decide to allow or deny, and either call the function or return a 429. The counter table is minimal:

{`create table rate_limits (
  key text primary key, -- "form_submission:ip" or "oauth_callback:session_id" or "login:email"
  count int default 1,
  expires_at timestamp default (now() + interval '1 hour'),
  created_at timestamp default now()
);

create index idx_rate_limits_expires on rate_limits(expires_at);`}

Sliding Window Logic: On each request, you look up the counter by key. If it doesn't exist or is expired, create it with count=1 and ttl=1 hour. If it exists and hasn't expired, increment it. Check if count > limit (e.g., 10 submissions per hour). If yes, return 429. If no, proceed and call the function. Postgres TTL (via expires_at + a background job, or pg_cron) cleans expired rows automatically. No Redis client library, no connection pooling headache.

Implementation: Edge Middleware + Supabase

Velocity X uses this pattern across three endpoints. Here's the form-submission example:

{`// netlify/edge-functions/rate-limit-form.ts
import { Context } from 'https://edge.netlify.com';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.104.0';

export default async (req: Request, ctx: Context) => {
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  );

  const ip = req.headers.get('x-forwarded-for') || 'unknown';
  const key = \`form_submission:\${ip}\`;
  const limit = 10; // 10 submissions per hour
  const ttl = 3600; // 1 hour in seconds

  // Check current count
  const { data: existing } = await supabase
    .from('rate_limits')
    .select('count, expires_at')
    .eq('key', key)
    .single();

  if (existing && new Date(existing.expires_at) > new Date()) {
    // Key exists and not expired
    if (existing.count >= limit) {
      return new Response('Too many requests', { status: 429 });
    }

    // Increment
    await supabase
      .from('rate_limits')
      .update({ count: existing.count + 1 })
      .eq('key', key);
  } else {
    // Create new counter
    await supabase.from('rate_limits').insert({
      key,
      count: 1,
      expires_at: new Date(Date.now() + ttl * 1000)
    });
  }

  // Allow request; call the actual form handler
  return ctx.next();
};`}

Deploy to Netlify with netlify functions:deploy. Add the middleware to netlify.toml:

{`[[edge_functions]]
path = "/api/contact"
function = "rate-limit-form"`}

Three endpoints, three rate-limit patterns:

1. Form Submissions (10/hour per IP). Catches bots immediately. If a real user submits twice, they see the rate-limit message and back off. No harm.

2. Login Attempts (5/hour per email). Replace ip with email from the POST body. A brute-force attacker trying 100 passwords/hour hits the wall at 5. They have to slow down, and log entries start piling up in your auth table (you DO log login attempts, right?), making it obvious something's wrong.

3. OAuth Redirect Callback (1/minute per session_id). The OAuth provider calls your callback with ?code=... and ?state=.... If the same session ID hits your callback 2+ times in 1 minute (a replay attack or accidental double-submit), the second request gets 429. Your frontend shows a "please try logging in again" error. Simple, transparent.

vs. Upstash Redis vs. Local Redis

Upstash: Managed Redis as a service. Costs $3-10/month minimum, billed per request. Latency: 10-50ms round-trip to their infrastructure. You gain: battle-tested Redis, atomic operations, TTL built-in. You lose: vendor lock-in, another payment processor to manage, cold starts if you hit them during off-peak.

Self-hosted Redis: Run Redis in Docker on your server. Costs $0 if you already have infra, but adds operational burden: monitoring, backups, security patches. Latency: 1-5ms on the same VPC. You win on cost; you lose on operational overhead.

Postgres + Netlify Edge: Costs $0 (Postgres is free on Supabase's Starter tier; 50k row limit but rate limits table is maybe 1k rows max). Latency: 5-15ms (same as Upstash, different geography). You lose atomicity (Postgres doesn't guarantee ACID between the SELECT and UPDATE), but for rate limiting it doesn't matter—a few requests slip through occasionally, NBD. You win on simplicity: one fewer external dependency.

Unless you're rate-limiting millions of requests/day across 100 endpoints, Postgres is the right call. Upstash is the backup play if you hit Redis-specific needs (like sorted sets for leaderboards) or need sub-millisecond latency.

Cleaning Up Expired Counters

Postgres rows don't auto-delete by default. Either add a Postgres cron job or a scheduled Netlify Function that runs hourly. We use a scheduled Edge Function (see Supabase Edge Functions) to clean stale rows every hour:

{`export default async (req: Request) => {
  const supabase = createClient(
    Deno.env.get('SUPABASE_URL')!,
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
  );

  const { error } = await supabase
    .from('rate_limits')
    .delete()
    .lt('expires_at', new Date().toISOString());

  return new Response(JSON.stringify({ cleaned: true }));
};`}

Common Questions

What if two requests hit at the same time?

Race condition: both see count=0, both create count=1. You'll miss one increment. For rate limiting, a few slips are acceptable—the goal is to stop spam, not achieve perfect atomicity. If you need hard guarantees, use Redis or add a pessimistic lock: SELECT ... FOR UPDATE.

Can I rate-limit by user ID instead of IP?

Yes. Any stable identifier works: user ID, email, session ID, API key. Just change the key generation. For public endpoints (forms), IP is safest because it doesn't require authentication. For authenticated endpoints, use user ID.

How do I whitelist internal traffic?

Check the IP before rate-limiting. If it's from your office (e.g., 192.168.x.x), skip the check. Or use a request header: x-internal-bypass: true signed with a secret. In production, use the header so you don't have to reconfigure IPs when you move offices.

What about DDoS attacks?

Rate limiting protects against application-layer attacks (spam, brute force, webhook replays). DDoS attacks (volumetric, network-level) happen before your code runs. Netlify's CDN blocks obvious DDoS at the edge. For real DDoS protection, layer on Cloudflare or AWS Shield.

Can I customize the error message?

Yes. Instead of returning new Response('Too many requests', { status: 429 }), return JSON: new Response(JSON.stringify({ error: 'Too many submissions. Please try again in 1 hour.' }), { status: 429, headers: { 'content-type': 'application/json' } }).

How do I test rate limiting locally?

Use netlify dev to spin up a local dev server with Netlify Functions and Middleware. Hit the endpoint in a loop: for i in {1..15}; do curl http://localhost:8888/api/contact; done. The 11th request should return 429.

The Bottom Line

Rate limiting is table-stakes infrastructure, not a nice-to-have. Postgres + Edge Middleware is simpler and cheaper than Redis SaaS, and fast enough for any reasonable traffic. Start with 10 req/hour per IP for public endpoints, 5 per hour per email for login, 1 per minute per session for OAuth. Watch your logs. Adjust limits as you learn what's normal. Ship it, and sleep better knowing bots can't drain your account in a single night.

Let us make some quick suggestions?
Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.