RiaChoi Text Logo.
Implementing JWT Authentication in a Spring Cloud MSA

JWT

Spring

MSA

Redis

Implementing JWT Authentication in a Spring Cloud MSA

A walkthrough on how to implement JWT on a Spring Boot project - part1.

Ria ChoiJuly 3rd, 2026

Intro

As you split a monolith into microservices, authentication becomes complicated.

  • Where should a user actually log in and get a token?
  • Who is responsible for validating that token?
  • How do downstream services know who is calling them without re-parsing a JWT every time?

In this tutorial, I'll walk through the authentication flow from a real microservices project of mine (KOK): a user logs in through a User Service, receives an Access Token and Refresh Token, and then calls other services through an API Gateway that validates the JWT and forwards identity information via headers.

By the end, you’ll have:

  • A login endpoint that issues JWT access/refresh tokens and stores the refresh token in Redis
  • A token reissue flow that detects refresh token theft
  • A Spring Cloud Gateway filter that validates JWTs and blocks invalid requests
  • User identity (X-User-Id, X-Username, X-Role) propagated to downstream services — with protection against header spoofing
  • Route configuration wired up with Eureka service discovery

 

Architecture Overview

Client
  │
  │ POST /api/v1/auth/login  (username, password)
  ▼
┌───────────────┐
│  User Service  │──── issues Access Token + Refresh Token, saves Refresh Token in Redis
└───────────────┘
  │
  │ Subsequent requests: Authorization: Bearer <token>
  ▼
┌──────────────────────────┐
│       API Gateway          │
│  - InternalBlockFilter      │──── blocks direct external access to /api/v1/internal/**
│  - JwtAuthenticationFilter  │──── validates token, strips/re-adds X-User-* headers
└──────────────────────────┘
  │
  ├──▶ user-service
  ├──▶ store-service
  ├──▶ reservation-service
  ├──▶ slot-service
  ├──▶ waiting-service
  ├──▶ notification-service
  ├──▶ review-service
  └──▶ payment-service
       (all registered with Eureka)

The key idea:

The Gateway is the only place that touches raw JWTs. Downstream services trust the headers the Gateway injects and never need a JWT library at all.

This keeps authentication logic in one place instead of being duplicated across five services.

Let’s build it piece by piece!

 

Part1: Issuing Tokens in the Auth Service

1.1 Login Request/Response DTOs

Full Code Here

LoginRequest.java
LoginRequest.java
LoginResponse.java
LoginResponse.java

Two things worth noting here compared to a "textbook" JWT setup:

  • userId is a UUID, not an auto-increment Long. This avoids leaking how many users have signed up, and works cleanly across services that don't share a database.
  • The of() static factory keeps AuthService from having to know about the builder internals — it just hands over a User entity and two token strings.
1.2 JwtProvider — Signing Tokens

Full Code Here

This class lives in the User Service, because only the service that issues tokens needs the ability to sign them.

JwtProvider.java
JwtProvider.java

The detail that matters most here is the tokenType claim. Both access and refresh tokens are signed with the same secret and carry the same userId/username/role claims — so without a way to tell them apart, nothing stops a client from sending a refresh token to an endpoint that expects an access token (or vice versa). Embedding tokenType: "access" / "refresh" directly in the payload closes that gap, and it's what makes step 2 of the reissue flow (below) possible.

 

1.3 RefreshTokenStore — Why Refresh Tokens Live in Redis

Full Code Here

RefreshTokenStore.java
RefreshTokenStore.java

Access tokens are stateless by design — the Gateway validates them with nothing but a signature check. Refresh tokens are different: because they're long-lived and powerful (a valid refresh token can mint new access tokens indefinitely), we want the server to be able to invalidate one on demand — for example, if it's ever leaked. That requires state, which is what Redis provides here.

1.4 AuthService — Login

Full Code Here

AuthService.java
AuthService.java

Notice that "user not found" and "wrong password" both throw the sameINVALID_LOGIN_INFO exception. This is intentional — returning a different message for "this username doesn't exist" vs. "wrong password" leaks information about which usernames are registered.

 

1.5 AuthService — Token Reissue

Full Code Here

AuthService.java
AuthService.java

Full Code Here

TokenRefreshRequest.java
TokenRefreshRequest.java

Full Code Here

TokenResponse.java
TokenResponse.java

What I considered when designing this authentication process.

1. The Refresh Token is not rotated on every reissue

Only a new Access Token is minted, and the same Refresh Token is returned to the client. This is a deliberate simplicity trade-off (rotation adds complexity around race conditions when multiple requests refresh concurrently).

2. Step 4 is the important one.

Rather than only checking "is this a valid JWT," the service checks whether it matches the specific token that was issued and stored in Redis. If someone presents a well-formed, correctly-signed, non-expired refresh token that simply isn't the one on file for that user — meaning the real one was likely replaced or the account was already re-authenticated elsewhere — the service treats it as a theft signal and immediately deletes the stored token, forcing that user to log in again everywhere.

Full Code Here

AuthErrorCode.java
AuthErrorCode.java

At this point, the User Service can log a user in, issue both tokens, and safely reissue an Access Token without ever trusting a Refresh Token blindly. Now let's look at the Gateway, which has to validate the Access Token on every single request.

 

Part2: Validating JWTs at the Gateway

The Gateway only ever sees Access Tokens — it doesn't talk to Redis and doesn't know anything about refresh flows. Its job is narrow and fast: verify the signature, check expiration, and forward identity.

2.1 JwtUtil - Verifying (not Signing) Tokens

Full Code Here

JwtUtil.java
JwtUtil.java

Note this is a separate class from the User Service's JwtProvider, even though both hold the same shared secret. The Gateway version has no generate* methods at all — there's no reason for the Gateway to ever be capable of issuing tokens.

 

2.2 JwtAuthenticationFilter — a Named Gateway Filter, Not a Global One

Rather than a GlobalFilter that runs on every route unconditionally, this is implemented as an AbstractGatewayFilterFactory — a named filter that gets attached explicitly per-route in configuration (you'll see this in Part 3). This makes it opt-in and visible in config rather than implicit.

Full Code Here

Blog image

A few things worth calling out:

1. The whitelist checks method and path together.

POST /api/v1/auth/login is whitelisted, but GET /api/v1/auth/login is not — not that such a route would normally exist, but the pattern-matching is intentionally strict rather than "any request to this path skips auth."

2. Every request gets its inbound X-User-* headers stripped, whitelisted or not.

This is the detail I'd originally missed entirely. If the Gateway only added these headers after successful validation, a client could still smuggle in a fake X-User-Id header on a request to a whitelisted path (like signup) and have it pass straight through to a downstream service that trusts it blindly. Stripping first — unconditionally — closes that hole.

Five distinct error codes, not one generic 401:

Auth Error Code
Auth Error Code

That last case (AUTH-005) matters because jwtUtil.parseClaims() succeeding doesn't guarantee the payload has the shape this system expects — a token signed with the right secret but built by a different code path (or an older token schema) could still pass signature verification while carrying garbage claims.

 

2.3 InternalBlockFilter — Keeping Service-to-Service Endpoints Private

Some endpoints exist purely for services to call each other and should never be reachable from outside the cluster. This is a separate, simpler WebFilter that runs before the JWT filter:

Full Code Here

InternalBlockFilter.java
InternalBlockFilter.java

@Order(-100) puts this ahead of the JWT filter in the chain, so a request to /api/v1/internal/** gets rejected with 403 before the system even bothers checking for a token.

 

2.4 GlobalExceptionHandler — One Response Shape for Everything Else

Anything that isn't caught explicitly inside JwtAuthenticationFilter — malformed requests, unexpected exceptions — still needs to come back in the same { status, message, data } shape:

Full Code Here

Blog image

Unexpected errors are logged with full detail server-side but return a generic [COMMON-500] message to the client — no stack traces or internal details leak out.

 

Part3: Wiring Up Routes and Eureka

Full Code Here

application.yml
application.yml

Every route explicitly attaches JwtAuthenticationFilter — including the user-service route that also handles login and signup. That works because the filter itself checks the whitelist internally (Part 2.2); the route config doesn't need a separate "skip auth" mechanism.

Two routes point at the same downstream service: reservation-service handles both /api/v1/reservations/** and, via the slot-service route id, /api/v1/slots/**. Splitting these into separate route entries — even though they hit the same uri: lb://reservation-service — keeps the routing table self-documenting about which URL prefixes exist, even as the underlying service boundaries evolve.

The lb:// prefix tells Spring Cloud Gateway to resolve user-service, store-service, etc. through the load balancer, using service names registered in Eureka rather than hardcoded hosts and ports. Once this is running, you can confirm registration by checking the Eureka dashboard — the Gateway and all backend services should appear as UP.

 

Part4: Testing the Full Flow

With everything wired up, here's the end-to-end flow to verify in Postman or curl:

  • LoginPOST /api/v1/auth/login with valid credentials → expect 200 with accessToken and refreshToken.
  • Call a protected route without a tokenGET /api/v1/stores/1 with no Authorization header → expect 401, [AUTH-001].
  • Call with a header but no Bearer prefixAuthorization: <token> → expect 401, [AUTH-002].
  • Call with an expired token → expect 401, [AUTH-003].
  • Call with a tampered token (flip a character in the signature) → expect 401, [AUTH-004].
  • Call with a valid token → expect 200, and confirm on the downstream service's logs that X-User-Id, X-Username, and X-Role arrived correctly.
  • Try to spoof identity → send a request with your own X-User-Id header set, no valid token → confirm the Gateway strips it and the request is rejected with [AUTH-001] rather than passing your fake header through.
  • ReissuePOST /api/v1/auth/reissue with the refresh token → expect a new access token, same refresh token back.
  • Reissue with a stale/replaced refresh token → log in twice for the same user (issuing a second refresh token that overwrites the first in Redis), then try to reissue with the first one → expect 401, REFRESH_TOKEN_MISMATCH, and confirm the stored token is now deleted (a third reissue attempt with either token should fail with REFRESH_TOKEN_NOT_FOUND).
  • Signup and health check → confirm these still work without a token, since they're on the whitelist.
  • Internal endpoint from outsideGET /api/v1/internal/anything → expect 403, [AUTH-006], regardless of whether a token is attached.
# 1. Login
curl -X POST http://localhost:8000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"testuser","password":"Password1!"}'

# 6. Call a protected route with the token
curl -X GET http://localhost:8000/api/v1/stores/1 \
  -H "Authorization: Bearer <accessToken>"

# 8. Reissue
curl -X POST http://localhost:8000/api/v1/auth/reissue \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"<refreshToken>"}'

Wrapping Up

At this point we have a working authentication pipeline:

  • The User Service owns credentials and issues both tokens — it's the only place passwords are checked and the only place that can sign a JWT.
  • Refresh Tokens are stateful (stored in Redis) precisely because they need to be revocable; Access Tokens stay stateless because they're short-lived and validated by signature alone.
  • The Gateway owns validation — it's the only place a raw JWT is parsed, and it actively strips inbound identity headers before deciding whether to re-add trustworthy ones.
  • Downstream services trust X-User-Id / X-Username / X-Role headers, staying blissfully unaware that JWTs even exist.

This separation is what makes the system scale cleanly: adding a ninth microservice means adding one route entry to the Gateway config with filters: [name: JwtAuthenticationFilter] — not re-implementing token validation from scratch.

⚠️ One caveat worth restating: these headers are only trustworthy because the Gateway is the sole entry point into the cluster. If a downstream service is ever reachable directly (bypassing the Gateway), the header-stripping logic that protects against spoofing no longer applies. In production, network-level rules should enforce that only the Gateway can reach internal services directly.

In the next post, I'll cover the Owner approval workflow — how a separate MASTER role approves or rejects business owner signups, and how that interacts with the role embedded in the JWT.

Share

Related contents