Loading…
Loading…
Limiting by IP means one office behind a shared NAT burns through the quota together. And middleware never sees a Server Action.

Rate limiting by IP is what everybody reaches for first, because an IP is always there. It is also wrong in two directions at once.
An office behind a single NAT looks like one address from the server side, so the whole building shares a bucket. The first person to click spends the quota and the second is blocked having done nothing. In the other direction, somebody acting in bad faith can change IP faster than you can finish this paragraph.
If the person is signed in you have something far better than an IP: their user id. It survives a move from wifi to mobile data, and it is not shared with the colleague at the next desk.
export async function personIdentifier(userId) {
if (userId) return `user:${userId}`;
return `ip:${await getClientIp()}`;
}
Four lines, and it is the key every client-facing API route on this site buckets by. Signed in, you are counted as an account. Signed out, it falls back to IP. Anonymous traffic still has a ceiling, and real users do not suffer for their neighbours.
The user: and ip: prefixes are not decoration. Without them a user id that happens to match an IP string shares a bucket with it, and you will spend an afternoon working out why.
export async function getClientIp() {
const h = await headers();
return (
h.get("x-forwarded-for")?.split(",")[0]?.trim() ??
h.get("x-real-ip") ??
"127.0.0.1"
);
}
Behind a proxy, x-forwarded-for is a list rather than a value. Each proxy along the way appends its own address. The first entry is the original client, which is what split(",")[0] is for.
Take the last entry by mistake and you are rate limiting your own infrastructure, with every request in the world sharing one bucket.
The header is also worth treating as a claim rather than a fact. Anyone can send x-forwarded-for with any value they like, and a client sitting in front of your proxy can therefore write whatever first entry it wants. It only becomes trustworthy once a proxy you control overwrites it, which is what a managed platform does before the request reaches your code. Read it directly from a server you exposed to the internet yourself and it is a suggestion, not an identity.
This is where I lost the most time, because there is no symptom.
The project's middleware matches on path, and it covers every Route Handler under /api/*. That looks like full coverage of the write surface. But a Server Action does not POST to /api/. It POSTs to the path of the page it is rendered on. A contact form action living on /en/contact POSTs to /en/contact.
So the /api/* routes are protected, the actions are wide open, and the middleware config looks thorough while you read it.
The fix is to call the limiter inside the action rather than at the routing layer:
const { success } = await rateLimit("contact", {
tokens: 3,
window: "1 h",
identifier: await personIdentifier(user?.id),
});
if (!success) return { error: "That was quick. Give it a minute." };
When the Redis environment variables are absent this function returns success rather than blocking. That is a choice, and it is not the right one for every endpoint.
For a contact form or a reaction button, failing open is correct: losing Redis should not also take down the feature, because that turns a small incident into a large one. A developer machine should not need Redis running just to try things.
For other things the answer inverts. The scheduled jobs on this site authenticate with an environment variable and fail closed: without it the endpoint answers 503 rather than quietly becoming a public one. The question worth asking per endpoint is whether removing this layer leaves you with a broken feature or an open door.
No comments yet — be the first!