x-forwarded-for spoofing and IP rate limits in Next.js: the header is only as honest as the proxy in front of it
IP rate limits in Next.js trust x-forwarded-for. Tested: Vercel overwrites it, an appending proxy leaves the client in charge of the first entry. The fix.
The kit's public demo lets anonymous visitors chat with Claude, and the only thing between them and your Anthropic bill is an identity made from their IP address. That identity carries a 20-requests-a-minute rate limit and a 25-requests-a-month free quota. If a visitor can change their IP with a header, they can reset both on every request. Whether they can depends entirely on what sits in front of your Next.js server, because the IP comes from x-forwarded-for, and x-forwarded-for spoofing is one header away. I tested the kit's route both ways. Here is where it is safe and where it is not.
Where the IP comes from
Next.js 15 removed request.ip. The upgrade guide says these values "are provided by your hosting provider", so a route handler reads a header instead. The kit's chat route:
const ip = (req.headers.get("x-forwarded-for") ?? "local")
.split(",")[0]
.trim();
let userId = `ip:${ip}`;
// ...signed-in users replace this with their account id
const limit = rateLimit(`chat:${userId}`);
The same ip:<addr> identity is what checkQuota counts usage_events against, so it gates the monthly free allowance as well as the per-minute limit. The design is in preventing free-tier abuse in an AI SaaS.
The question is who wrote that header. Next's own server only fills it in when it is missing: req.headers['x-forwarded-for'] ??= socket.remoteAddress in base-server.js. Anything the client sends goes through untouched.
What I measured
I ran the production build locally with nothing in front of it and sent invalid JSON to /api/chat. The rate limit runs before body validation, so each request costs nothing and returns either 400 (passed the limiter) or 429 (blocked):
- 21 requests with
x-forwarded-for: 203.0.113.7: twenty 400s, then a 429. The limiter works. - One more with
203.0.113.8: 400. A new header value is a fresh identity, with a fresh minute and a fresh month of free requests. 203.0.113.7, 10.0.0.1: 429. The route reads only the first entry.198.51.100.9, 203.0.113.7: 400. Put any new value first and the limited address behind it no longer matters.
That last line is the one that matters off Vercel. A reverse proxy like nginx, configured the usual way with $proxy_add_x_forwarded_for, appends. Per the nginx docs, the variable is "the X-Forwarded-For client request header field with the $remote_addr variable appended to it". The real address lands at the end, and the client still owns the start.
Who gets to write the header
On Vercel, the platform rewrites it
Vercel's request-header docs are explicit: "we currently overwrite the X-Forwarded-For header and do not forward external IPs. This restriction is in place to prevent IP spoofing." x-real-ip and x-vercel-forwarded-for carry the same value. So on Vercel the kit's first-entry read is the client's real public IP, and a forged header gets you nothing.
Two caveats from the same page. If you put another proxy, such as a CDN, in front of Vercel, "x-forwarded-for could be overwritten" by it, while x-vercel-forwarded-for stays Vercel's own view. And forwarding a custom client IP through a trusted proxy is an Enterprise feature, so a front proxy cannot simply hand Vercel the original address.
Off Vercel, count trusted hops from the right
Each proxy you control appends the address it received the connection from. So read the chain from the right, skip past the proxies you trust, and ignore everything to the left. That part came from the client.
/**
* The client IP as seen by the outermost proxy you trust. Each trusted proxy
* appends what it saw, so the answer is `trustedHops` entries from the right;
* anything further left was supplied by the client.
*/
export function clientIp(headers: Headers, trustedHops = 1): string {
const chain = (headers.get("x-forwarded-for") ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
return chain.at(-trustedHops) ?? "unknown";
}
With one nginx in front, 198.51.100.9, 203.0.113.7 resolves to 203.0.113.7, the address nginx actually saw. With a CDN and nginx, use trustedHops = 2. On Vercel the header holds a single value, so the default of 1 returns it unchanged. The number has to match your real topology. Set it too high and you are trusting the client again.
The one setup with no good answer is next start exposed directly to the internet. Nothing trustworthy writes the header there: Next only uses the socket address when the client sent no header, and route handlers never see the socket directly. Put a proxy in front.
Things that bite
- The in-memory limiter is per instance.
src/lib/ratelimit.tssays so in its own header: correct for one Node process, but "on serverless or multi-instance deploys each instance keeps its own counters". Spoof-proof IPs do not help if requests spread across instances. Back it with a shared store (the file names Upstash Redis) for real enforcement. - IPs are shared. Offices, campuses and mobile carriers put many people behind one address, and they share one 25-request month. An IP is fine for a demo and a weak identity for a product.
- Accounts are the real fix. The chat route's own comment says production apps should require auth there. Then the limit and quota key on a user id that no header can change. Account farming is the next problem: the kit ships with
requireEmailVerification: false, so turn verification on when the free tier starts to matter. - Log what you trust. When a limit looks wrong, log the raw header next to the address you chose. A mismatch tells you which hop is lying.
Shipwright, the Next.js and Claude starter kit this blog documents, ships this chat route, the limiter and the quota check. On Vercel, the IP read is safe as written. You can try the anonymous path in the live demo.