← All news
security5 min read

Rate limiting for solo builders who have never thought about it

By Seaworthy · 12 June 2026

Speed limit 55 road sign in a Mojave Desert landscape under a blue sky

Photo by Ludovic Charlet on Unsplash

Your project has ten users. You know all of them. Why would you bother with rate limiting?

Here is why: because those ten users are not the only people who can reach your API. Anyone with a URL can hit it. Bots scan the whole public internet constantly, and when they find an unauthenticated or weakly authenticated endpoint, they hammer it. Not maliciously, often. Just automatically.

I have seen a side project rack up $400 in OpenAI API bills overnight because a single endpoint was both unprotected and hooked up to an LLM. The builder had six real users. The bill came from exactly one IP address in Ashburn, Virginia.

What actually happens without rate limiting

The boring case: someone writes a script that calls your /api/contact form 10,000 times and you get 10,000 emails. Your inbox is full. Your Resend or SendGrid quota is gone.

The expensive case: every request triggers an LLM call, a database write, or an external API call you pay per-use for. One determined person, or one misconfigured scraper, can empty your account.

The sneaky case: credential stuffing. An attacker has a list of 500,000 email/password pairs from old breaches, and your login endpoint will happily attempt all 500,000 with no friction whatsoever. This is how accounts get compromised at small apps nobody thinks is a target.

None of these require a sophisticated attacker. A teenager with a for loop in Python can do all of them.

The fix is not complicated

Rate limiting works by tracking how many requests a given identifier (usually an IP address, sometimes a user ID) has made in a sliding window, and returning a 429 Too Many Requests response when they exceed the limit.

The actual implementation for most apps is about ten lines.

Express

import rateLimit from 'express-rate-limit'

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
})

app.use('/api/', limiter)

That uses express-rate-limit, which stores state in memory by default. Fine for a single-process app. If you run multiple instances, you need a Redis store, but worry about that when you get there.

Next.js API routes

Next.js does not have a built-in rate limiter, so you pick one. The Upstash @upstash/ratelimit package is what most people reach for because it works in Edge runtime and connects to a managed Redis instance with a free tier.

import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { NextRequest, NextResponse } from 'next/server'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
})

export async function POST(req: NextRequest) {
  const ip = req.headers.get('x-forwarded-for') ?? '127.0.0.1'
  const { success } = await ratelimit.limit(ip)

  if (!success) {
    return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
  }

  // your actual handler logic
}

One thing to note: Upstash creates an in-memory ephemeral cache that sits in front of Redis. If you delete keys directly in Redis to unblock something, the block may persist until the process restarts. So when you are debugging, restart the container, not just the Redis key.

Hono

Hono has a built-in rate limiter middleware that ships with the library:

import { Hono } from 'hono'
import { rateLimiter } from 'hono/rate-limiter'

const app = new Hono()

app.use(
  '/api/*',
  rateLimiter({
    windowMs: 15 * 60 * 1000,
    limit: 100,
    standardHeaders: 'draft-6',
    keyGenerator: (c) => c.req.header('x-forwarded-for') ?? 'unknown',
  })
)

Clean. No extra dependencies.

Things people get wrong

The IP address is a proxy for identity, not identity itself. Behind a corporate NAT, all your users might share one IP. A limit of 100 requests per 15 minutes will block your entire company. For authenticated routes, rate limit on user ID, not IP. For unauthenticated ones, IP is the best you can do, but set the threshold high enough that normal browser use is impossible to trigger.

The x-forwarded-for header is set by the proxy in front of your app, but it can also be set by the client. If you are not behind a trusted reverse proxy, an attacker can spoof it to bypass IP-based limits. Trust x-forwarded-for only when you control the infrastructure in front of your app.

429 responses should include a Retry-After header. Legitimate clients, automated or otherwise, will back off and retry. Scrapers usually do not care, but it is good practice and it is in the spec.

You do not need a perfect solution

The goal is not to stop a nation-state actor. The goal is to make abuse expensive enough that it moves on to someone easier. A basic rate limiter does that. A free Redis instance from Upstash or Railway is enough. You can ship this in an afternoon.

The projects that get hit are the ones with zero friction. Add a little friction. Your LLM bill will thank you.


Seaworthy flags missing rate limiting on API routes as part of its resilience checks.

This article was generated by AI and summarises publicly available sources.

From the makers of Seaworthy

Seaworthy scans your repo for the issues covered in articles like this one.

Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.

$ npx seaworthycode