Josh Davies

Senior Software Engineer

I build beautiful, engaging, and perfectly executed frontend and backend experiences. While finding time to write, hike and read.

Blog

Every Endpoint Needs a Speed Limit

  • software-engineering
  • laravel
  • php
  • typescript
  • node
  • security
  • api

Every endpoint you ship has an unwritten assumption baked into it: people will call this a reasonable number of times.

Then a script finds your login form. Or a customer's integration gets stuck in a loop. Or somebody discovers that your "send verification code" button fires a paid SMS every time they click it.

None of that is a bug in the endpoint. The endpoint did exactly what it was told. It just never got told when to stop.

The login form is the obvious one

If your login route has no limit, it's an unlimited guessing game. Laravel's starter kits already handle this, and the pattern is worth knowing even if you rolled your own auth:

$key = Str::transliterate(Str::lower($request->input('email')).'|'.$request->ip());
 
if (RateLimiter::tooManyAttempts($key, 5)) {
    $seconds = RateLimiter::availableIn($key);
 
    throw ValidationException::withMessages([
        'email' => "Too many attempts. Try again in {$seconds} seconds.",
    ]);
}
 
if (! Auth::attempt($request->only('email', 'password'))) {
    RateLimiter::hit($key);
 
    throw ValidationException::withMessages(['email' => 'These credentials do not match our records.']);
}
 
RateLimiter::clear($key);

Note the key. It's the email and the IP. Key on IP alone and one office behind a shared IP locks itself out. Key on email alone and anyone can lock a specific user out on purpose by guessing wrong five times.

There's no perfect key. There's just the one that hurts the attacker more than it hurts your users.

Name your limits

For everything else, define limiters once and attach them to routes. In Laravel 11 that goes in AppServiceProvider::boot():

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
 
RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
 
RateLimiter::for('exports', function (Request $request) {
    return [
        Limit::perMinute(2)->by('minute:'.$request->user()->id),
        Limit::perDay(20)->by('day:'.$request->user()->id),
    ];
});
Route::middleware(['auth:sanctum', 'throttle:exports'])
    ->post('/reports/export', ExportReportController::class);

Logged-in users get keyed by user id, guests by IP. The export endpoint gets two limits at once: no hammering it, and no running it all day. The minute: and day: prefixes matter. Without them both limits share one key and count against each other.

Limit by what it costs, not by what's fair

Sixty a minute is a fine default for reading data. It's a terrible default for:

  • Sending email or SMS. Every call costs money, and a code-request endpoint is a great way for someone else to spend it.
  • Password resets. Laravel's password broker has its own throttle setting in config/auth.php for exactly this.
  • Exports, report generation, image processing. Anything that ties up a worker for a while.
  • Anything that calls a paid third-party API on the user's behalf.

Look at each route and ask what it costs you when someone calls it a thousand times in a minute. That answer is the limit, not a number you copied from the docs.

Count in one place

A rate limiter is just a counter with an expiry. Where that counter lives is the part people get wrong.

Laravel's limiter uses your default cache store. If that's file and you run three servers, each server keeps its own count. Your limit of 5 is now a limit of 15. If it's array, it resets every request and you have no limit at all.

Point it at something shared. In config/cache.php you can give the limiter its own store:

'limiter' => 'redis',

Node has the same trap. An in-memory limiter in an Express app counts per process. Run it under PM2 with four instances or behind a load balancer and every instance has its own idea of how many requests you've made.

A shared counter in Redis is a few lines:

import Redis from "ioredis";
 
const redis = new Redis(process.env.REDIS_URL!);
 
export async function hit(key: string, limit: number, windowSeconds: number) {
  const window = Math.floor(Date.now() / 1000 / windowSeconds);
  const bucket = `rl:${key}:${window}`;
 
  const results = await redis.multi().incr(bucket).expire(bucket, windowSeconds).exec();
  const count = Number(results?.[0]?.[1] ?? 0);
 
  return {
    allowed: count <= limit,
    retryAfter: windowSeconds - (Math.floor(Date.now() / 1000) % windowSeconds),
  };
}

INCR and EXPIRE go out together in one MULTI, so a crash between them can't leave a counter that never expires. This is a fixed window, which means someone can squeeze a burst in right at the edge of two windows. For most endpoints that's fine. If it isn't, use a library that does sliding windows instead of writing one at 11pm.

Say no properly

When you reject a request, return a 429 Too Many Requests and tell the caller when to come back:

app.post("/api/codes", async (req, res, next) => {
  const result = await hit(`codes:${req.ip}`, 3, 600);
 
  if (!result.allowed) {
    res.set("Retry-After", String(result.retryAfter));
    return res.status(429).json({ message: "Too many requests. Try again later." });
  }
 
  next();
});

Laravel's throttle middleware does this for you. It sends the 429 along with Retry-After and X-RateLimit-* headers, which makes life a lot easier for anyone writing a client against your API.

A 429 isn't a 500. It's not an error to page someone about. It's the API working as designed.

Make sure the IP is actually the IP

If your app sits behind a load balancer or a CDN, $request->ip() and req.ip might be the load balancer's address for every single request. Congratulations, your entire user base now shares one rate limit.

Tell the framework which proxies to trust. In Laravel 11:

->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '10.0.0.0/8');
})

In Express, app.set("trust proxy", 1) trusts one hop.

Don't just trust everything to make the problem go away. If the app is reachable without going through the proxy, anyone can send their own X-Forwarded-For header and pick a fresh IP for every request.

Your outbound calls need one too

Rate limits aren't only for protecting yourself. The APIs you call have limits, and a queue with twenty workers will find them fast.

Laravel lets you put a limiter on the job itself:

use Illuminate\Queue\Middleware\RateLimited;
 
RateLimiter::for('crm-sync', fn () => Limit::perMinute(100));
 
class SyncContactToCrm implements ShouldQueue
{
    public function middleware(): array
    {
        return [new RateLimited('crm-sync')];
    }
}

When the limit is hit, the job gets released back onto the queue instead of slamming the API and collecting a pile of 429s. Released jobs still count as attempts, so give these jobs a retryUntil() instead of a small $tries.

Test that it says no

A limit you never tested is a limit you're hoping exists:

$user = User::factory()->create();
 
for ($i = 0; $i < 2; $i++) {
    $this->actingAs($user)->postJson('/reports/export')->assertAccepted();
}
 
$this->actingAs($user)
    ->postJson('/reports/export')
    ->assertTooManyRequests()
    ->assertHeader('Retry-After');

It's a short test, and it's the one that catches the day someone moves the route out of the throttled group.

The habit

When you add an endpoint, ask the same question you'd ask about any input: what happens when someone does this way more than I expected?

Sometimes the answer is "nothing much." A lot of the time it's an email bill, a locked-out user, or a worker pool that's busy doing nothing useful. Put a speed limit on it before you find out which.

← All posts