a. Overview
In this lab
- Build
auth: users, BCrypt passwords, login that returns a JWT signed with RS256, and a public JWKS endpoint. - Put Spring Cloud Gateway behind the edge: one place for routing, token validation and authorization (open vs protected vs admin routes).
- Propagate the user's identity to the services as trusted headers (
X-User-Id). - Measure what the extra hop and the token check cost, and how many logins per second
authcan take.
Until now anyone could create or delete events in the catalog. BoxOffice needs to know who is calling (authentication) and what they may do (authorization) β and it should not ask every service to solve that problem again. The standard answer is an API gateway1: the single front door for the API, where tokens are validated once and requests are routed to the right service. Behind it, services trust the identity the gateway passes along.
This lab builds chapter 5 of the story of the course: the front door that stateless replicas need, where the token is checked once for every service and no replica keeps a session. It implements the API gateway of class 4, the service map of class 5 and the token validation at the gateway of the identity page.
Before you start
| Time | 3β4 hours |
| You need | the tools of lab 0; Docker with at least 6 GB (five JVMs from now on); ports 80 and 8080 free |
| Directory | every command runs from the root of your boxoffice/ repository |
docker compose version # Docker Compose v2.x (or newer)
java -version # 21 or newer
mvn -v # Apache Maven 3.9+
k6 version # k6 v1.0+
uv --version # uv (runs the Python helper scripts)
Start state β the end of lab 3:
π boxoffice/
βββ docker-compose.yaml # db, catalog-1..3, edge
βββ infra/nginx/nginx.conf # edge -> catalog replicas
βββ π load/ hello.js smoke.js load.js stress.js ratelimit.js
βββ π results/ ...
βββ π scripts/ ...
βββ π services/catalog/ # with InstanceHeaderFilter
Catch up
Skipped or broke lab 3? Download boxoffice-after-lab-3.zip, unzip it and cd boxoffice. This lab adds two services (services/auth, services/gateway) and two k6 scripts, and replaces docker-compose.yaml and infra/nginx/nginx.conf; every file is shown in full on the next pages and is in code/.
Slides
Click the slides and use β β to move through them; F goes full screen and P opens the presenter view, with the notes. Open the slides in a new tab
Theory connection
- Class 4, API gateway: routing, authentication at the edge, rate limiting, BFF.
- Class 4, Identity: OAuth 2.1/OIDC, JWT, sessions; Zero trust: why "trust the gateway's headers" is only acceptable inside a protected network.
- Component catalog: API Gateway, Auth Service.
What you will build
sequenceDiagram
autonumber
actor Fan
participant E as edge :80
participant G as gateway :8080
participant A as auth :8081
participant C as catalog-1..3 :8082
Fan->>E: POST /auth/login {email, password}
E->>G: forward
G->>A: route /auth/** (open)
A->>A: BCrypt check, sign JWT with private key
A-->>Fan: 200 {accessToken: "eyJ..."}
Fan->>E: POST /catalog/events + Authorization: Bearer eyJ...
E->>G: forward
G->>A: GET /auth/.well-known/jwks.json (first time only, then cached)
G->>G: verify signature, exp, iss, aud β scope "admin"?
G->>C: route lb://catalog + X-User-Id, X-User-Scope
C-->>Fan: 201 Created Tokens are JWTs2: signed JSON that any holder of the public key can verify without calling auth.
| Route | Who may call it | Enforced by |
|---|---|---|
POST /auth/register, POST /auth/login, GET /auth/.well-known/jwks.json | anyone | gateway: permitAll |
GET /catalog/** | anyone | gateway: permitAll |
POST/PUT/DELETE /catalog/** | tokens with scope admin | gateway: hasAuthority("SCOPE_admin") |
GET /auth/me and every other route (including future ones) | any valid token | gateway: anyExchange().authenticated() |
The topology after this lab: edge (Nginx: TLS termination in production, rate limiting, load balancing over gateway replicas) β gateway (identity, routing, load balancing over service instances) β services.
Steps
-
Users table, BCrypt, login, JWT signing with an RSA key pair, the JWKS document.
-
Spring Cloud Gateway routes, client-side load balancing over
catalog-1..3, JWT validation, identity headers, the new edge and Compose.
3. Try it end to end
With both steps done, rebuild and start everything:
docker compose up --build -d
docker compose ps # db, catalog-1..3, auth, gateway healthy; edge running
Now as the administrator (created at startup from ADMIN_EMAIL/ADMIN_PASSWORD):
ADMIN=$(curl -s -X POST localhost/auth/login -H 'Content-Type: application/json' \
-d '{"email":"admin@boxoffice.dev","password":"boxoffice-admin"}' | jq -r .accessToken)
curl -s -X PUT localhost/catalog/events/2 -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' \
-d '{"title":"Match #2 (rescheduled)","category":"SPORTS","venueId":15,"startsAt":"2027-03-02T21:00:00-03:00","priceFrom":100}' | jq .title
# look inside the token: header and claims are only Base64URL, NOT encrypted
echo "$ADMIN" | jq -R 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson'
Think first: a client sends X-User-Id: <the admin's id> itself. What happens?
Nothing useful for the attacker: the gateway's UserHeadersFilter removes any X-User-* header coming from outside before setting its own from the validated token. Without that line, any client could impersonate any user, because the services trust those headers blindly. That trust is acceptable only because the services are not reachable except through the gateway β zero-trust networks (class 12) go further and authenticate every hop (mTLS, or re-validating the JWT in each service).
Try the other failure cases too: an expired token (JWT_TTL=PT30S docker compose up -d auth, log in again, wait two minutes β the gateway tolerates 60 s of clock skew past exp β then docker compose up -d auth to go back to one hour), a token with one character changed (signature check fails), and restarting auth (new key pair: old tokens are rejected because their kid no longer exists in the JWKS β see the warning in step 1).
Measure it: the price of a front door
Every hop and every check costs something. Send the same request, GET /catalog/events/{id}, at the same rate along four paths β straight to one catalog replica, to the gateway port, through the edge, and through the edge with a token the gateway validates β so that each difference isolates one cost; then measure the throughput of auth itself.
# k6 inside the compose network: the catalog replicas publish no port
k6net() { docker run --rm -i --network boxoffice_default -v "$PWD":/w -w /w grafana/k6 "$@"; }
k6net run -q -e RATE=50 load/gateway.js > /dev/null # warm-up: right after a build the JVMs are cold
k6net run -e RATE=50 load/gateway.js # 4 paths Γ 50 req/s
mkdir -p results/lab4
for r in 5 10 20 40; do k6 run -q -e RATE=$r --summary-export results/lab4/rate-$r.json load/login.js; done
uv run scripts/summarize.py results/lab4/rate-*.json
Record in results/lab4.md:
# Lab 4 β gateway and identity
## Hops (50 req/s each, 1 min)
| Path | p50 (ms) | p95 (ms) | p99 (ms) |
|---|---:|---:|---:|
| catalog alone (service) | | | |
| gateway β catalog (direct) | | | |
| edge β gateway β catalog (open) | | | |
| edge β gateway (JWT validated) β catalog (jwt) | | | |
At p50: gateway hop = direct β service β ____ ms; edge hop = open β direct β ____ ms; JWT validation = jwt β open β ____ ms.
## Login capacity
<table from summarize.py: rate, p50, p99, errors>
- Max logins/s with p99 < 1 s: ____ ; CPU of auth at that rate (docker stats): ____ %
- One BCrypt check (cost 10) takes about ____ ms on my machine.
## Questions
- Why does the gateway validate tokens locally instead of calling auth on every request?
- What would happen to the flash sale if every request called /auth/login?
How to read it:
- Each scenario is named after its path in k6's summary (
http_req_duration{scenario:service}β¦). Every path adds one piece to the previous one, so the differences are the costs: the gateway hop and the edge hop each add a fraction of a millisecond to a few milliseconds at p50 (an extra network hop and a proxy). Signature checking with RS256 is microseconds of CPU: JWT validation is cheap because it is local β the gateway fetched the public key once and never callsauthper request β sojwtandopenare within noise of each other. The catalog route is open, but a request that carries a token still has it validated: a tampered one gets401there too. - Login is expensive on purpose. BCrypt with cost 10 burns tens of milliseconds of CPU per attempt, so
authwith 1 CPU tops out at roughly ten to twenty logins per second (in a run for this lab, 10 logins/s kept p99 under 200 ms withauthat ~80% CPU, and 20 logins/s overloaded it). Past the ceiling it does not degrade gently: the logins share the CPU, all of them slow down, and once they exceed the gateway's 5 sresponse-timeoutthey fail with504β most of them, at 20 logins/s. That is exactly what makes password guessing slow β and why clients must reuse tokens instead of logging in per request. During a flash sale, logins should happen before 10:00.
If your numbers differ
On a laptop, the gateway (1 CPU) shares the machine with five other JVMs and PostgreSQL, so the hop can cost more than in production β in a run for this lab on a busy machine, p50 reached hundreds of milliseconds at 150 req/s. Compare relative costs on the same machine and the same run: service vs direct to the gateway vs through the edge vs with JWT. k6 runs in a container so that every path starts inside the same network; on Docker Desktop, k6 on the host would add the VM's port forwarding to every path. Right after a build the tail still pays JIT compilation even after the warm-up: if the p99 thresholds fail, run the measurement once more and record the second run. For logins, the ceiling scales with CPU: one core gives roughly 10β20 BCrypt checks per second.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
503 from /catalog/** for a few seconds after the gateway starts; log No servers available for service: catalog | the load balancer's health checks have not run yet | wait ~10 s; if it persists, check docker compose ps (catalog replicas healthy?) and that the gateway pom has spring-boot-starter-webclient |
401 on every protected route | no Authorization: Bearer β¦ header, an expired token, or auth restarted (new key) | log in again: export TOKEN=$(curl -s -X POST localhost/auth/login -H 'Content-Type: application/json' -d '{"email":"ana@example.com","password":"secret-123"}' \| jq -r .accessToken) |
403 as admin | you used the user's token, or the admin was not created | docker compose logs auth \| grep administrator; log in with admin@boxoffice.dev / boxoffice-admin |
409 e-mail already registered | you ran the register command twice | fine: just log in |
Bind for 0.0.0.0:8080 failed | another program uses 8080 | change only the host side: "18080:8080" |
the gateway logs Unable to resolve the Configuration with the provided Issuer or JWKS errors | AUTH_JWKS_URI/JWT_ISSUER differ between auth and gateway | use the docker-compose.yaml of this lab unchanged |
containers restart or get killed (exit 137) | Docker is out of memory | give Docker β₯ 6 GB (Docker Desktop β Settings β Resources) |
Best practices in this lab
- Validate every claim of the token, in one place β signature against the JWKS of
auth, plusexp,issandaud, inSecurityConfig.java; see Identity β validating tokens at the gateway. - Deny by default β
.anyExchange().authenticated()inSecurityConfig.javaprotects every route nobody has listed yet, including future ones; see API gateway. - Never trust identity headers from the client β
UserHeadersFilter.javaremovesX-User-*before setting them from the token; see Zero Trust. - Put a timeout on every hop and retry only idempotent requests β
connect-timeout: 1000,response-timeout: 5sand a single retry ofGETin the gateway'sapplication.yaml; see Reliability β retry with backoff and jitter. - Hash passwords with a slow, salted function β BCrypt with cost 10 in
AuthService.java, measured at 10β20 logins/s per core, which is why clients reuse tokens; see Stateless app servers.
Clean up
docker compose down # keeps the database (users and events)
# docker compose down -v # resets everything: users are created again from scratch
Keep everything: lab 5 adds Redis to this stack.
Deliverable
-
services/authandservices/gatewayrunning; the newinfra/nginx/nginx.confsends everything to the gateway. - The curl session above reproduced:
201,200,401,403, and an admin update that works. - The spoofing test: sending
X-User-Idyourself has no effect (show the evidence). -
results/lab4.mdwith the hop table, the login capacity and the two answers. - Repository tagged
lab4; quiz answered.
End state
π boxoffice/
βββ docker-compose.yaml # replaced: + auth (8081), + gateway (8080); edge -> gateway
βββ infra/nginx/nginx.conf # replaced: everything goes to the gateway, /actuator hidden
βββ load/gateway.js load/login.js # new
βββ π services/auth/ # new: users, BCrypt, RS256 JWT, JWKS
βββ π services/gateway/ # new: routes, lb://catalog, JWT validation, identity headers
βββ results/lab4.md results/lab4/
Next: Lab 5 β Caching.
-
Spring Cloud Gateway reference, Server WebFlux; Spring Security reference, OAuth 2.0 Resource Server β JWT.Β β©
-
IETF, RFC 7519 β JSON Web Token and RFC 7517 β JSON Web Key.Β β©