Skip to content

Identifying clients

A limit of five requests per minute means nothing until you say five per what.

Rate limiting only works if you can consistently tell who is making a request, and the identifier you pick decides four things at once:

  • Fairness. Are you limiting the right entity?
  • Effectiveness. Can someone step around it?
  • Experience. Are legitimate users being punished?
  • Security. Can the identifier itself be abused?

The question underneath every rate limit: who or what are you actually trying to limit?

IP address

The default, and the one you get without asking. express-rate-limit uses the client's address unless told otherwise, which is what the limiter in the previous chapter has been doing.

Good for public endpoints with no login, basic abuse prevention, and blunting simple floods.

The appeal is that it always exists. No authentication needed, it works for anonymous traffic, it's simple, and an ordinary user cannot change it on a whim.

The problem is that addresses are shared and addresses move. Hundreds of employees behind one office connection look like one client, as does everyone in a library and large numbers of mobile users behind a carrier.

Meanwhile a user on a changing address gets a fresh budget every time it moves, and IPv6 hands out many addresses within one subnet.

Making the choice explicit instead of inheriting it:

js
import { rateLimit, ipKeyGenerator } from 'express-rate-limit'

const limiter = rateLimit({
  limit: 5,
  windowMs: 60000,
  keyGenerator: (req, res) => ipKeyGenerator(req.ip),
})

keyGenerator returns the string a request is counted against. That's the hook everything below uses.

JunoIP address The trap is that an address feels like a person and isn't. It identifies a connection to the internet, and a connection can carry one person or a whole building.

Every problem with this strategy comes from that gap.

JunoIP address Behind a proxy or load balancer, req.ip is the proxy's address, so every request looks like one client and your limit applies to the whole world at once.

Express needs app.set('trust proxy', ...) to read the forwarded address instead. Set it to the number of proxies you actually run, because trusting the header blindly lets a client spoof any address it likes and step around the limit entirely.

JunoIP address IPv6 makes per-address limiting close to useless if applied naively, because a single subscriber is routinely handed a /64, which is more addresses than the entire IPv4 internet. Limiting per address means limiting per address they have not used yet.

The answer is limiting per prefix. express-rate-limit 8 exposes ipv6Subnet for this, defaulting to 56, where lower numbers group more aggressively.

The ipKeyGenerator helper exists for the same reason: it normalises addresses so IPv4 and IPv6 clients are keyed consistently. Writing req.ip straight into the key is what quietly stops working the day IPv6 traffic arrives.

Authenticated user id

Once someone has logged in, you know exactly who they are, from a session, a JWT claim or a database lookup.

Good for authenticated endpoints, per-user quotas, and tiered plans.

Every real user gets their own budget, so nobody interferes with anyone else. It follows them across devices and networks, and changing address does nothing, because the identity is not the connection.

What it cannot do is protect anything before login. Signup, password reset and public endpoints all happen before a user id exists, and those are exactly the endpoints abuse tends to find.

js
const limiter = rateLimit({
  limit: 3,
  windowMs: 10000,
  keyGenerator: (req) => req.user.id,
})

app.get('/api/data', authenticate, limiter, handler)

Order matters. Express runs middleware left to right, so authentication has to set req.user before the limiter reads it. Put the limiter first and it reads undefined for everyone, keying every request to the same bucket.

JunoAuthenticated user id Run three users against a limit of three per ten seconds and the effect is immediate. Each gets their own three, and one hitting the wall does nothing to the others.

That's fairness working. With addresses, three people in the same office would be sharing one budget of three.

JunoAuthenticated user id Middleware order is the bug worth expecting here, and it fails quietly. A limiter reading req.user.id before authentication runs will either throw on undefined or key everyone to the same value, which is a single shared limit for your entire user base.

Neither shows up in a happy-path test, because with one user in the test there is no difference to see.

JunoAuthenticated user id User-based limits move the abuse one step earlier: if accounts are free and signup is unlimited, the identifier is as cheap to acquire as an address. Protecting signup with an address-based limit is what closes that loop, which is a good argument for running both rather than choosing.

Limits also become a product surface once they are per user. Free and paid tiers with different budgets need the limit to be a function of the request, not a constant, and the tier lookup then sits on the hot path of every request. Cache it.

API key and session id

Two more identifiers, each suited to a different kind of caller.

API keySession id
Good forThird-party integrations, business APIs, tiered plansWeb apps with sessions, carts, checkout and form flows
StrengthsTies to a specific application, supports tiers, revocable if abused, simple to trackWorks for logged-in and guest users alike, more persistent than an address, follows a whole journey
WeaknessesKeys get shared and leaked, one key may serve thousands of end users, and it needs key managementClearing cookies resets it, session fixation applies, and it doesn't exist for stateless APIs

An API key makes tiering natural, because limit can be a function of the request:

js
import { getUserTier } from './services/billing.js'

const limiter = rateLimit({
  windowMs: 60000,
  keyGenerator: (req) => req.headers['x-api-key'],
  limit: (req) => (getUserTier(req.headers['x-api-key']) === 'pro' ? 1000 : 100),
})

A session id is read the same way, from req.session.id.

JunoAPI key and session id Four identifiers, and none of them is the correct one. Each identifies a different kind of caller.

An API key identifies an application. A user id identifies a person. A session identifies a visit. An address identifies a connection. Pick the one that matches what you're limiting.

JunoAPI key and session id The API key weakness surprises people: a key identifies the integration, not the person using it.

A partner with fifty thousand end users has one key, so one bad user consumes everybody's budget and gets the whole integration limited.

If that matters, the key wants a per-key budget plus a per-end-user one inside it, which means passing an end-user identifier through the integration.

JunoAPI key and session id Anything a client controls can be rotated, and session ids are the worst offender: clearing cookies costs nothing and issues a fresh budget, so session-keyed limits deter accidents and not attackers.

Two things worth doing whatever you key on. Never put a raw secret in the key, since limiter keys end up in logs and store dumps, so hash the API key first.

And decide what happens when the identifier is missing, because keyGenerator returning undefined groups every such request into one bucket. That's either a useful catch-all or an accidental shared limit, and it should be the one you chose.

Combining them

Real systems rarely pick one. The usual arrangement is a chain with fallbacks, most specific first:

  1. Authenticated user id, when someone is logged in.
  2. API key, for integrations.
  3. Session id, for guests with a session.
  4. IP address, as the last resort.

Each step down is less precise and more available, so a request is always counted against the best identifier it actually has.

JunoCombining them The order runs from most specific to most available, and it's worth reading in that direction.

A logged-in person is the clearest thing you can identify. An address is the vaguest, and the only one that's always there.

JunoCombining them A fallback chain in one limiter has a flaw worth knowing: everyone falling through to the address share that tier's budget with each other, so anonymous traffic competes with itself.

Often better to run separate limiters with their own budgets: a tight one on anonymous traffic, a generous one on authenticated users, instead of one limiter switching keys.

JunoCombining them Prefix the key with the strategy that produced it: user:4821, never a bare 4821. Without that, a user id and a session id can collide on the same string and two unrelated clients share one budget.

The other thing the chain hides is that it's an escalation path: an attacker operates at whichever tier is cheapest to acquire.

So the chain is only as strong as its weakest rung, and limits should get tighter as identifiers get more anonymous instead of staying uniform down the list.

Try it

An API limits 100 requests per minute per IP address. Three complaints arrive.

  1. A company with 400 staff says the app stops working every afternoon.
  2. A user on mobile says the limit never seems to apply to them.
  3. Someone is scraping the public catalogue at roughly 10,000 requests an hour and nothing is stopping them.
Compare your answers
#What's happeningFix
1400 people share one outbound address, so 400 people share 100 requests a minuteKey on the authenticated user id, keeping the address limit for anonymous traffic only
2Their carrier moves them between addresses, so each move hands them a fresh budgetSame fix. An identity that follows the person cannot be shed by reconnecting
3The catalogue is public, so there's no user to key on, and addresses cost the scraper almost nothingTighter anonymous limits, keyed on an IPv6 prefix rather than a single address, plus something upstream. This one is reduced rather than solved

One and two are the same defect seen from both sides: the address is too coarse for the office and too fine for the mobile user.

Three is the honest limit of the whole technique. Rate limiting makes abuse cost something, and against a public endpoint that price is only ever as high as your cheapest identifier.

Where this goes next

The limiter counts correctly and against the right thing. What it still has is the fixed window's boundary burst, letting twice the limit through around a reset.

Sliding window algorithms closes that, by measuring against a window that moves with the request instead of a block on the clock.