Rate limit identities, not IP addresses.
The rate limiting most tutorials teach keys on the caller's IP address, and agent
traffic breaks both of its assumptions at once. Requests from one assistant vendor
arrive from a large, shifting pool of cloud addresses, so a per-IP limit set for a
human with one browser never triggers. Meanwhile hundreds of real mobile users can
share one carrier NAT address, so the same limit set aggressively enough to catch
agents blocks paying humans. The unit that behaves consistently is the identity:
the API key, the session, the signed-in user. Key your limits on that, and fall
back to IP only for anonymous routes.
In nginx, that is a two-line change from the tutorial version:
# Key on the Authorization header when present, IP otherwise
map $http_authorization $rl_key {
"" $binary_remote_addr;
default $http_authorization;
}
limit_req_zone $rl_key zone=per_client:10m rate=5r/s;
server {
location /api/ {
limit_req zone=per_client burst=20 nodelay;
limit_req_status 429;
}
}
If your app is on Supabase or another serverless stack with no nginx in front, do
it in the database, where every request already lands. A fixed-window counter is
one table and one function:
create table if not exists request_counts (
identity text not null,
window_start timestamptz not null,
requests int not null default 0,
primary key (identity, window_start)
);
create or replace function take_token(p_identity text, p_limit int)
returns boolean
language plpgsql
security definer
as $$
declare
current_count int;
begin
insert into request_counts (identity, window_start, requests)
values (p_identity, date_trunc('minute', now()), 1)
on conflict (identity, window_start)
do update set requests = request_counts.requests + 1
returning requests into current_count;
return current_count <= p_limit;
end;
$$;
Call take_token(auth.uid()::text, 60) at the top of your RPC or edge
function and return HTTP 429 when it comes back false; agents, unlike some humans,
read the status code and back off. Delete rows older than an hour on a schedule so
the table stays small. Return the limit decision before you do the expensive work,
not after: on serverless, every request a crawler makes is a function invocation
you pay for, and if the route calls a language model the meter spins faster still,
which is the failure mode our
runaway API bills guide covers, with
spending caps to match. Machine traffic does not get tired, does not get bored, and
does not sleep in your timezone, so the limits, not your attention, have to be the
thing that holds.