d. Authorization
The gateway-service is the single entry point for all requests. Beyond routing, it enforces authentication: before forwarding any request to a secured service, it checks for a valid JWT cookie and resolves the caller's identity. This is done through two components β RouterValidator and AuthorizationFilter.
1. How Authorization Works
The Gateway uses Spring Cloud Gateway's GlobalFilter mechanism. Every request passes through AuthorizationFilter before being routed. The filter follows this decision tree:
flowchart TD
A[Incoming request] --> B{RouterValidator\nisSecured?}
B -- No --> C[Forward to service]
B -- Yes --> D{Cookie\n__store_jwt_token\npresent?}
D -- No --> E[401 Unauthorized]
D -- Yes --> F[POST /auth/solve\nwith JWT]
F --> G{Valid token?}
G -- No --> E
G -- Yes --> H[Add id-account header]
H --> C When the token is valid, the filter enriches the downstream request with one header:
| Header | Value | Purpose |
|---|---|---|
id-account | Account UUID from token (jti) | Services use this to identify the caller |
Before that, the Gateway removes any id-account sent by the client (RemoveRequestHeader=id-account in default-filters, which run before AuthorizationFilter): otherwise anyone could call a secured route pretending to be another account. The header that reaches the services can only come from the Gateway.
This means downstream microservices never need to parse JWTs themselves β the Gateway handles it and passes the resolved identity as a plain header.
2. Full Request Sequence
sequenceDiagram
autonumber
actor User
User->>+Gateway: request with cookie __store_jwt_token
Gateway->>+AuthorizationFilter: filter(exchange, chain)
AuthorizationFilter->>RouterValidator: isSecured.test(request)
RouterValidator-->>AuthorizationFilter: true
AuthorizationFilter->>AuthorizationFilter: read cookie value
AuthorizationFilter->>+Auth Service: POST /auth/solve (token)
Auth Service->>Auth Service: JwtService.getId(token)
Auth Service-->>-AuthorizationFilter: { idAccount: "..." }
AuthorizationFilter->>AuthorizationFilter: mutate request headers
AuthorizationFilter->>-Gateway: chain.filter(mutated exchange)
Gateway->>-User: service response 3. The gateway-service Module
3.1 Repository
Create a new git repository for the gateway service and add it as a submodule:
π api/
βββ π account/
βββ π account-service/
βββ π auth/
βββ π auth-service/
βββ π gateway-service/
3.2 Code
Use Spring Initializr to generate a Maven project with the following settings:
- Group:
store - Artifact:
gateway-service - Package name:
store.gateway - Packaging: Jar
- Java: 25
Dependencies to add:
- Gateway (
spring-cloud-starter-gateway-server-webflux) β reactive gateway with routing and filter support - Reactive Web (
spring-boot-starter-webflux) β reactive stack required by Spring Cloud Gateway
Reactive stack
Spring Cloud Gateway runs on Project Reactor (WebFlux), not on the traditional servlet stack. This is why AuthorizationFilter returns Mono<Void> instead of void, and why the HTTP client inside it uses WebClient instead of RestTemplate.
The resulting structure is:
π api/
βββ π gateway-service/
βββ π src/
β βββ π main/
β βββ π java/
β β βββ π store/
β β βββ π gateway/
β β βββ GatewayServiceApplication.java
β β βββ GatewayResource.java
β β βββ π security/
β β βββ AuthorizationFilter.java
β β βββ RouterValidator.java
β βββ π resources/
β βββ application.yaml
βββ pom.xml
βββ Dockerfile
| Class | Description |
|---|---|
GatewayServiceApplication | Spring Boot entry point |
GatewayResource | The Gateway's own GET /health-check; answers with the container's HOSTNAME, handy to see which replica answered |
RouterValidator | Holds the list of open routes; provides an isSecured predicate |
AuthorizationFilter | Global filter; validates the JWT cookie and enriches the request |
3.3 RouterValidator in detail
RouterValidator defines which routes bypass authentication. It stores them as "METHOD /path" strings and exposes an isSecured predicate. A request matches an open route if the HTTP method and path both match (with optional /** wildcard support).
private List<String> openApiEndpoints = List.of(
"POST /auth/register",
"GET /auth/logout",
"POST /auth/login"
);
Any request not matching this list returns true from isSecured, meaning the filter will require a valid cookie.
3.4 Application configuration
The application.yaml defines the CORS policy, the default filters and the route table. Routes map URL patterns to upstream service hostnames (resolved inside the Docker Compose network):
default-filters:
- RemoveRequestHeader=id-account
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
routes:
- id: account-service
uri: http://account:8080
predicates:
- Path=/accounts/**
- id: auth-service
uri: http://auth:8080
predicates:
- Path=/auth/**
Source
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 | |
Health check: expose only what you need
/health-check is the Gateway's own endpoint, not a route, so AuthorizationFilter does not run on it β anyone can call it. Answer only what it needs (here, the hostname); returning System.getenv() would expose every environment variable, secrets included.
Invalid token β 401
retrieve() turns an error answer from /auth/solve into an exception, and an unhandled exception in the Gateway is a 500. The onStatus(...) maps the 401 of an invalid or expired token to a 401 at the Gateway, so the front end knows it must log in again.
4. Updating Docker Compose
Add the auth service to the compose.yaml of Hands-on 2. Like account, it publishes no port: only the Gateway is reachable from outside, while account and auth are reached only inside the Compose network, by their service names β the trusted layer.
auth and gateway need no variables to run on your machine: auth's application.yaml reads JWT_SECRET_KEY, JWT_HTTP_ONLY and JWT_DURATION with development defaults, and the Gateway reads CORS_ALLOWED_ORIGINS (default http://localhost,http://localhost:3000,http://localhost:8080).
JWT_SECRET_KEY outside development
The default key in application.yaml is public β it is in the repository β so anyone could forge tokens for a deployment that keeps it. Generate a key of your own, with 256 bits (32 random bytes, Base64-encoded):
and hand it to auth from the environment β e.g. in compose.yaml, with the value in a .env file that is never committed:
5. Testing the Security Layer
Start the platform:
Register a new user:
curl -X POST http://localhost:8080/auth/register \
-H "Content-Type: application/json" \
-d '{"name": "Antonio do Estudo", "email": "acme@insper.edu.br", "password": "123@321"}'
Login and capture the cookie:
curl -c cookies.txt -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "acme@insper.edu.br", "password": "123@321"}'
Access a secured endpoint using the cookie:
Access without the cookie β expect 401:
Done! The security layer is complete. Every request to a secured route now passes through the Gateway, which validates the JWT cookie with auth-service and forwards the resolved identity to downstream services β without any service needing to parse tokens themselves.