← Back to list

I Wasted 6 Hours on Spring Cloud Gateway JWT Auth — Here Are the 4 Errors That Actually Matter

Key rotation that 401s valid logins, one curl header that grants admin, and an upgrade that silently unbinds your routes.

Hobbiespark in CodeToDeploy · 2026-06-10 03:50 · 50 claps · 7.1 min read
#spring-boot #jwt #microservices #java #programming
Open on Medium ↗
Wiki topics: ML · Machine Learning 💻 · Programming

I Wasted 6 Hours on Spring Cloud Gateway JWT Auth — Here Are the 4 Errors That Actually Matter

Key rotation that 401s valid logins, one curl header that grants admin, and an upgrade that silently unbinds your routes.

I had the happy path working in twenty minutes: gateway validates the token, forwards the request, downstream trusts the gateway. Then a teammate proved he could curl his way to admin by setting one header.

Then I rotated a signing key in the IdP and watched valid logins start 401-ing. Then we upgraded the release train and routing quietly stopped binding. Six hours later, here’s the map.

🚨 WANTED: TECH TALENT

💰 High Pay | 🌍 Remote | ⚡ Fast Hiring

Frontend • Backend • Full Stack • AI/ML • DevOps

**👉 APPLY NOW**

The last article fronted MCP servers with a gateway and waved at “the gateway validates the JWT.” This is the part it waved at: the actual RS256 + Spring Cloud Gateway setup — key rotation, JWKS caching, and header stripping — and the four ways it silently bites you in production.

TL;DR

  • A key rotation rejects valid tokens only if you broke the cache — the default decoder refetches the JWKS on an unknown kid, and a custom cache disables that refetch.
  • Your gateway must strip client-supplied identity headers, not just add trusted ones — unknown inbound headers are forwarded downstream by default.
  • Spring Cloud 2025.x renamed the modules and property prefixes and turned off X-Forwarded-* by default. Old config binds to nothing on 2025.1.
  • The JWKS is fetched lazily on the first token, so the first requests after a cold start can stall or fail.

The shape of the setup

The gateway is a WebFlux app that is also an OAuth2 resource server. It validates the RS256 access token against the IdP’s JWKS endpoint, then proxies to the downstream MCP servers.

<!-- Spring Cloud 2025.x: the starter was renamed -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway-server-webflux</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # discovery at /.well-known/openid-configuration (RFC 8414 style),
          # which advertises the jwks_uri the decoder will poll
          issuer-uri: https://idp.example.com/realms/mcp
  cloud:
    gateway:
      server:
        webflux:                       # <-- 2025.x prefix, not spring.cloud.gateway.*
          routes:
            - id: mcp
              uri: http://mcp-server:8080
              predicates:
                - Path=/mcp/**
@Bean
SecurityWebFilterChain security(ServerHttpSecurity http) {
    http
        .authorizeExchange(ex -> ex
            .pathMatchers("/actuator/health").permitAll()
            .anyExchange().authenticated())
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
    return http.build();
}

With just issuer-uri, Boot autoconfigures a ReactiveJwtDecoder that does discovery, fetches the JWKS, validates the RS256 signature, and checks iss/exp. Keep that in mind — most of the bugs below come from "improving" on this default.

Symptom 1: valid tokens get rejected for a few minutes right after a key rotation

The fix is almost always to delete the JWKS cache you added. This is the one that cost me the most time, because the code I added looked like a best practice.

When the IdP rotates its signing key, it starts issuing tokens with a new kid. The gateway has the old JWKS cached. The default decoder handles this for you: when it sees a kid that isn't in the cached set, it refetches the JWKS from the jwks_uri.

Underneath, the Nimbus JWKSourceBuilder wraps the source with refresh-ahead caching plus rate limiting that is deliberately lenient enough to let a rotation through. Left alone, rotation self-heals with no restart.

The trap is the “optimization” you copy from a servlet tutorial — supplying your own cache to lengthen the TTL:

// WRONG. Looks responsible. Silently breaks key rotation.
@Bean
ReactiveJwtDecoder jwtDecoder() {
    Cache cache = new ConcurrentMapCache("jwks");   // "cache the keys for an hour"
    return NimbusReactiveJwtDecoder.withJwkSetUri(jwkSetUri)
            .cache(cache)                            // <-- now an unknown kid is
            .build();                                //     served from cache, not refetched
}

Once a custom cache owns the JWK set, an unknown kid is answered from that cache instead of triggering a refetch — so every token signed by the freshly rotated key is rejected until your TTL expires. You turned a self-healing system into a timed outage.

// RIGHT. Default decoder = caches AND refetches on an unknown kid.
@Bean
ReactiveJwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
    return ReactiveJwtDecoders.fromIssuerLocation(props.getJwt().getIssuerUri());
}

Even better: if you only have issuer-uri in your properties, delete the bean entirely and let Boot build exactly this.

If you genuinely need to tune fetch behavior, tune it through the Nimbus JWKSourceBuilder (rate limit, refresh-ahead window) — not a raw Spring Cache — and tell your IdP to introduce the new key at least one cache lifetime before it starts signing with it.

Symptom 2: a client can impersonate anyone by sending one header

Your gateway adds X-User-Id after validating the token — but it never removes the X-User-Id the client sent. Downstream can't tell the difference, so curl -H "X-User-Id: admin" walks straight in.

Spring Cloud Gateway forwards inbound headers it doesn’t recognize to the downstream service by default. So the trust boundary isn’t “the gateway sets identity headers” — it’s “the gateway sets identity headers and the client cannot pre-set them.” If you only do the first half, every identity header your downstream reads is attacker-controlled.

Strip first, then set from validated claims. Do the strip as a default-filter so a new route can’t silently forget it:

spring:
  cloud:
    gateway:
      server:
        webflux:
          default-filters:
            # remove anything a client could use to impersonate identity,
            # on every route, before any AddRequestHeader runs
            - RemoveRequestHeader=X-User-Id
            - RemoveRequestHeader=X-User-Roles
            - RemoveRequestHeader=X-Authenticated-User

Then set the headers from the validated Authentication (which the client cannot forge, because it comes from the verified JWT):

@Bean
GlobalFilter identityFromJwt() {
    return (exchange, chain) -> exchange.getPrincipal()
        .cast(JwtAuthenticationToken.class)
        .map(auth -> exchange.mutate().request(r -> r.headers(h -> {
            h.set("X-User-Id", auth.getToken().getSubject());
            h.set("X-User-Roles", auth.getAuthorities().stream()
                    .map(GrantedAuthority::getAuthority)
                    .collect(Collectors.joining(",")));
        })).build())
        .defaultIfEmpty(exchange)
        .flatMap(chain::filter);
}

Two more decisions while you’re here. Decide whether downstream should see the raw user token at all: if not, RemoveRequestHeader=Authorization; if yes, relay it deliberately with the TokenRelay filter rather than by accident.

And remember hop-by-hop headers are governed separately — spring.cloud.gateway.server.webflux.filter.remove-non-proxy-headers.headers controls that list if you need to extend it.

Symptom 3: auth silently broke the moment you upgraded to Spring Cloud 2025.x

Two breaking changes landed together, and both fail quietly instead of loudly. Nothing throws — routing just stops working, or downstream loses the real client IP.

First, the modules and property prefixes were renamed. spring-cloud-starter-gateway became spring-cloud-starter-gateway-server-webflux, and spring.cloud.gateway.* became spring.cloud.gateway.server.webflux.*.

On 2025.0 the old names are deprecated and log a warning; on 2025.1 (Oakwood) the deprecated artifacts were removed outright, so your old spring.cloud.gateway.routes block binds to nothing and your routes simply vanish.

Second, X-Forwarded-* and Forwarded headers are now disabled by default. If your audience/issuer checks, your rate limiter keyed on client IP, or your downstream logging relied on the forwarded client address, they break until you explicitly declare which proxies you trust.

Migrate the names (the spring-boot-properties-migrator dependency or the OpenRewrite recipe org.openrewrite.java.spring.cloud2025.SpringCloudGatewayDeprecatedModulesAndStarters will do the mechanical part), then re-enable forwarded headers for your real hops:

spring:
  cloud:
    gateway:
      server:
        webflux:
          # regex matching your LB / ingress source IPs; without this,
          # X-Forwarded-* is dropped and downstream sees the gateway's IP
          trusted-proxies: "10\\.0\\.\\d+\\.\\d+"

Symptom 4: the first request after every deploy 401s or hangs, then it’s fine

The JWKS is fetched lazily on the first token, not at startup. So a cold start plus a traffic burst means your first requests race (or block on) that fetch, and a slow IdP turns that into visible 401s or latency spikes.

Keep refresh-ahead on (it’s the default — don’t replace it, see Symptom 1) so an expiring cache never stalls live traffic, avoid an aggressively short TTL, and warm the decoder on boot by forcing one JWKS fetch before real traffic arrives:

@Component
class JwksWarmUp {
    JwksWarmUp(ReactiveJwtDecoder decoder) {
        // decoding any throwaway token forces the JWK set fetch up front;
        // it fails validation, but the cache is now warm before real traffic
        decoder.decode("warm-up").onErrorComplete().subscribe();
    }
}

30-second diagnostic

SymptomLikely causeFirst thing to checkValid tokens 401 for minutes after a key rotationCustom JWKS cache disabled unknown-kid refetchDo you have a ReactiveJwtDecoder bean with .cache(...)? Delete it.curl with a forged identity header gets throughGateway adds identity headers but doesn't strip inbound onesIs there a RemoveRequestHeader for each trust header in default-filters?Routes vanished / config ignored after upgrade2025.x renamed prefix; old prefix no longer bindsAre routes under spring.cloud.gateway.server.webflux.*?Downstream sees gateway IP, not client IPX-Forwarded-* disabled by default in 2025.xIs trusted-proxies set to your LB regex?First requests after deploy are slow / 401JWKS fetched lazily on first tokenAdd a startup warm-up; confirm refresh-ahead isn't overridden.

Do this before you ship

  • Remove any hand-rolled ReactiveJwtDecoder/JWKS cache; rely on issuer-uri autoconfiguration.
  • Add a RemoveRequestHeader to default-filters for every header downstream treats as identity.
  • Set identity headers from the validated Authentication, never from the inbound request.
  • Decide explicitly whether Authorization is stripped or relayed downstream.
  • On Spring Cloud 2025.x: new starter coordinates, new ...server.webflux.* prefix, and trusted-proxies set.
  • Add a startup JWKS warm-up so the first request isn’t the one that pays for the fetch.

Closing

The pattern under all four bugs is the same: the framework default already does the safe thing, and the outage comes from “improving” it — a cache that kills rotation, an add without a strip, a config block that no longer binds. Validate the token, strip what the client could fake, set identity from claims, and leave the JWKS machinery alone.

Next in the series: rate limiting and circuit breaking these same MCP routes without strangling legitimate agent traffic. If gateway-level debugging like this is your kind of pain, follow along — the whole series lives here on CodeToDeploy.

Sources

Thank you for being a part of the community

Before you go:

👉 Be sure to clap and follow the writer ️👏️️

👉 Follow us: **Linkedin| [Medium](https://medium.com/codetodeploy)**

👉 CodeToDeploy Tech Community is live on Discord — **Join now!**

Disclosure: This post includes affiliate and partnership links.


메타데이터
post_id
4ff02071ebdc
slug
spring-cloud-gateway-jwt-validation-key-rotation-jwks-4ff02071ebdc
url
https://medium.com/codetodeploy/spring-cloud-gateway-jwt-validation-key-rotation-jwks-4ff02071ebdc
canonical_url
https://medium.com/codetodeploy/spring-cloud-gateway-jwt-validation-key-rotation-jwks-4ff02071ebdc
author_url
https://medium.com/@hobbiespark
status
ok
fetched_at
2026-06-10 18:44:10