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


Two things worth noting here compared to a "textbook" JWT setup:
userIdis aUUID, not an auto-incrementLong. This avoids leaking how many users have signed up, and works cleanly across services that don't share a database.- The
of()static factory keepsAuthServicefrom having to know about the builder internals — it just hands over aUserentity and two token strings.
1.2 JwtProvider — Signing Tokens
This class lives in the User Service, because only the service that issues tokens needs the ability to sign them.

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

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

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



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.

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

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.

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:

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:

@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:

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

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:
- Login →
POST /api/v1/auth/loginwith valid credentials → expect200withaccessTokenandrefreshToken. - Call a protected route without a token →
GET /api/v1/stores/1with noAuthorizationheader → expect401,[AUTH-001]. - Call with a header but no
Bearerprefix →Authorization: <token>→ expect401,[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 thatX-User-Id,X-Username, andX-Rolearrived correctly. - Try to spoof identity → send a request with your own
X-User-Idheader set, no valid token → confirm the Gateway strips it and the request is rejected with[AUTH-001]rather than passing your fake header through. - Reissue →
POST /api/v1/auth/reissuewith 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 withREFRESH_TOKEN_NOT_FOUND). - Signup and health check → confirm these still work without a token, since they're on the whitelist.
- Internal endpoint from outside →
GET /api/v1/internal/anything→ expect403,[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-Roleheaders, 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.
