skip to content

What is a KeyResolver in Spring Cloud Gateway rate limiting, and how would you implement per-user and per-IP resolvers?

level: middleimportance: must knowfreq 55%

answer

  1. Mono<String> resolve(exchange)
  2. same key = same bucket
  3. principal -> per user; remote address -> per IP
  4. X-Forwarded-For behind proxies (trust carefully)
  5. deny-empty-key default true, 403

basics

~20 s

A KeyResolver returns a key that identifies which rate-limit bucket a request belongs to. Requests with the same key share one bucket. For per-user you return the user id/principal; for per-IP you return the client's IP address.

solid answer

~40 s

`KeyResolver` is a functional interface with `Mono<String> resolve(ServerWebExchange exchange)`. RequestRateLimiter calls it per request to pick the bucket key; all requests returning the same key are counted together against one token bucket. You register it as a Spring bean and reference it from the filter (e.g. via SpEL `#{@ipKeyResolver}` or the `key-resolver` arg). Typical resolvers: **per authenticated user** — resolve the security principal so each user gets their own limit; **per IP** — read the remote address (mindful of `X-Forwarded-For` behind proxies). If the resolver returns an empty Mono, `deny-empty-key` (default true) decides whether to reject with 429/403 or allow. The choice of key defines fairness: per-IP protects against anonymous floods but can punish users behind shared NAT; per-user gives fair per-account limits but requires authentication to have happened first.

code

java · 19 lines
java
@Configuration
public class RateLimitConfig {

    // Per authenticated user
    @Bean
    KeyResolver principalKeyResolver() {
        return exchange -> exchange.getPrincipal()
                .map(Principal::getName)
                .switchIfEmpty(Mono.just("anonymous"));
    }

    // Per client IP (naive; behind a proxy parse X-Forwarded-For instead)
    @Bean
    KeyResolver ipKeyResolver() {
        return exchange -> Mono.just(
                exchange.getRequest().getRemoteAddress()
                        .getAddress().getHostAddress());
    }
}

go deeper

for a junior

Know the KeyResolver picks the bucket; same key = shared limit; principal for user, IP for anonymous.

for a middle

Implement both beans, reference via SpEL, and explain empty-key handling.

for a senior

Handle X-Forwarded-For trust, NAT fairness, and composing multi-part keys.

for a principal

Design keying strategy per endpoint class (pre-auth IP vs. post-auth principal/tenant) and its abuse surface.

**What it is.** `KeyResolver` is a single-method (functional) interface in Spring Cloud Gateway: ```java public interface KeyResolver { Mono<String> resolve(ServerWebExchange exchange); } ``` When the **RequestRateLimiter** filter handles a request, it calls the configured `KeyResolver` to obtain a **key** — a `String` wrapped in a reactive `Mono`. That key names the **bucket**: every request that resolves to the *same* key draws tokens from the *same* RedisRateLimiter bucket. So the KeyResolver is what turns 'a limit' into 'a limit *per something*' — per user, per IP, per API key, per tenant, etc. **Registering and referencing it.** You declare a `@Bean` of type `KeyResolver`. The route filter references it either through the `key-resolver` argument using SpEL bean syntax `#{@beanName}`, or — if you define exactly one KeyResolver bean — it can be picked up as the default. Multiple resolvers require explicit references so the gateway knows which route uses which. **Per-user (principal) resolver:** ```java @Bean KeyResolver principalKeyResolver() { return exchange -> exchange.getPrincipal() .map(Principal::getName) .switchIfEmpty(Mono.just("anonymous")); } ``` This gives each authenticated user their own bucket. Note that authentication must have run in the filter chain before this resolves to a real principal; otherwise everyone collapses into the `anonymous` bucket. **Per-IP resolver:** ```java @Bean KeyResolver ipKeyResolver() { return exchange -> Mono.just( exchange.getRequest().getRemoteAddress().getAddress().getHostAddress()); } ``` Behind a load balancer or CDN, `getRemoteAddress()` is the proxy's IP, so every client would share one bucket. You must instead trust and parse the **`X-Forwarded-For`** header (or configure a `ForwardedHeaderTransformer` / `XForwardedRemoteAddressResolver`) — and only trust it from known proxies, or an attacker can spoof the header to dodge or poison limits. **Empty key handling.** If `resolve` returns `Mono.empty()`, the behavior depends on `spring.cloud.gateway.filter.request-rate-limiter.deny-empty-key` (default `true`) and `empty-key-status-code` (default 403 FORBIDDEN). With deny enabled, a null key is rejected rather than silently allowed — important so a missing key can't bypass limiting. **Trade-offs of key choice:** - **Per-IP:** works for anonymous traffic; but users behind shared NAT/corporate proxies share a bucket (collateral throttling), and IPv6 or mobile carrier-grade NAT complicate fairness. Also spoofable via forged headers if you trust them blindly. - **Per-user/principal:** fair per account and abuse-resistant per identity; but only meaningful after auth, and doesn't stop pre-auth floods (login endpoints still need IP limiting). - **Per API key / tenant:** good for B2B quotas. - **Constant key:** returning a fixed string makes the whole route share one global bucket. **Gotchas:** - Forgetting the bean → no KeyResolver → default behavior may deny everything or nothing depending on config. - Reactive: return a `Mono`; blocking calls inside a resolver harm throughput. - Combining resolvers (e.g. user *and* IP) requires composing the key yourself, e.g. `user + ":" + path`. **When to use which:** rate-limit unauthenticated endpoints (login, signup) by IP; rate-limit authenticated APIs by principal or API key; use tenant keys for multi-tenant quotas.

  • Behind a load balancer, why does a naive IP KeyResolver put every client in one bucket, and how do you fix it?
    getRemoteAddress() returns the proxy's IP, which is the same for all clients. Fix by reading the real client IP from a trusted X-Forwarded-For header (e.g. via XForwardedRemoteAddressResolver), trusting it only from known proxy hops.
  • What happens if the KeyResolver returns an empty Mono?
    Governed by deny-empty-key (default true): the request is denied (default 403) rather than allowed, so a missing key can't bypass the limiter. You can flip deny-empty-key to false to allow instead.
  • When would per-IP be wrong and per-user be right?
    For authenticated APIs, per-user gives fair per-account limits; per-IP would unfairly group users behind shared NAT. But pre-auth endpoints (login) must use IP since there's no principal yet.

saying these in an interview costs you the question

  • Trusting X-Forwarded-For from any source (spoofable)
  • Assuming a principal exists before authentication runs
  • Thinking the KeyResolver returns a plain String, not a Mono<String>
  • Believing an empty key silently allows the request by default

context