Skip to content

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 auth can 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/ at the start of lab 4
πŸ“ boxoffice/
β”œβ”€β”€ YAML 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

  • 1. The auth service


    Users table, BCrypt, login, JWT signing with an RSA key pair, the JWKS document.

  • 2. The gateway


    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
curl -s -X POST localhost/auth/register -H 'Content-Type: application/json' -d '{"name":"Ana","email":"ana@example.com","password":"secret-123"}'{"id":"0b9d8a6e-…","name":"Ana","email":"ana@example.com","role":"USER"}TOKEN=$(curl -s -X POST localhost/auth/login -H 'Content-Type: application/json' -d '{"email":"ana@example.com","password":"secret-123"}' | jq -r .accessToken)curl -s localhost/auth/me -H "Authorization: Bearer $TOKEN"{"id":"0b9d8a6e-…","name":"Ana","email":"ana@example.com","role":"USER"}curl -s -o /dev/null -w "%{http_code}\n" localhost/auth/me401curl -s -o /dev/null -w "%{http_code}\n" -X DELETE localhost/catalog/events/2 -H "Authorization: Bearer $TOKEN"403

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.

load/gateway.js
// Lab 4: what do the edge, the gateway and the token check cost? The same request,
// GET /catalog/events/{id}, at the same arrival rate along four paths:
//   service   straight to one catalog replica              (no edge, no gateway)
//   direct    to the gateway port                          (gateway β†’ catalog)
//   open      through the edge                             (edge β†’ gateway β†’ catalog)
//   jwt       through the edge with a valid token          (the gateway also validates the JWT)
// Catalog replicas publish no port, so k6 runs inside the compose network (see the lab page):
//   docker run --rm -i --network boxoffice_default -v "$PWD":/w -w /w grafana/k6 run -e RATE=50 load/gateway.js
import http from 'k6/http';
import { check } from 'k6';

const EDGE = __ENV.BASE_URL || 'http://edge';
const GATEWAY = __ENV.GATEWAY_URL || 'http://gateway:8080';
const SERVICE = __ENV.SERVICE_URL || 'http://catalog-1:8082';
const RATE = Number(__ENV.RATE || 50);

const scenario = (exec) => ({
  executor: 'constant-arrival-rate', exec, rate: RATE, timeUnit: '1s', duration: '1m',
  preAllocatedVUs: 50, maxVUs: 500,
});

export const options = {
  scenarios: { service: scenario('service'), direct: scenario('direct'), open: scenario('open'), jwt: scenario('jwt') },
  summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
  thresholds: {
    'http_req_duration{scenario:service}': ['p(99)<500'],
    'http_req_duration{scenario:direct}': ['p(99)<500'],
    'http_req_duration{scenario:open}': ['p(99)<500'],
    'http_req_duration{scenario:jwt}': ['p(99)<500'],
    http_req_failed: ['rate<0.01'],
  },
};

// runs once: register a fan and log in; every VU reuses the token
export function setup() {
  const email = `fan-${Date.now()}@boxoffice.dev`;
  const json = { headers: { 'Content-Type': 'application/json' } };
  http.post(`${EDGE}/auth/register`, JSON.stringify({ name: 'Load Fan', email, password: 'secret-123' }), json);
  const login = http.post(`${EDGE}/auth/login`, JSON.stringify({ email, password: 'secret-123' }), json);
  check(login, { 'login is 200': (r) => r.status === 200 });
  return { token: login.json('accessToken') };
}

const randomId = () => 1 + Math.floor(Math.random() * 200000);
const event = (base) => `${base}/catalog/events/${randomId()}`;

export function service() {
  check(http.get(event(SERVICE)), { 'service is 200': (r) => r.status === 200 });
}

export function direct() {
  check(http.get(event(GATEWAY)), { 'direct is 200': (r) => r.status === 200 });
}

export function open() {
  check(http.get(event(EDGE)), { 'open is 200': (r) => r.status === 200 });
}

// an open route, but a request that carries a token gets it validated (a bad one is rejected with 401)
export function jwt(data) {
  const res = http.get(event(EDGE), { headers: { Authorization: `Bearer ${data.token}` } });
  check(res, { 'jwt is 200': (r) => r.status === 200 });
}
load/login.js
// Lab 4: how many logins per second can auth take? BCrypt is slow on purpose.
//   k6 run -e RATE=20 load/login.js
import http from 'k6/http';
import { check } from 'k6';

const BASE_URL = __ENV.BASE_URL || 'http://localhost';
const RATE = Number(__ENV.RATE || 20);
const json = { headers: { 'Content-Type': 'application/json' } };

export const options = {
  scenarios: {
    logins: {
      executor: 'constant-arrival-rate', rate: RATE, timeUnit: '1s', duration: '30s',
      preAllocatedVUs: 20, maxVUs: 300,
    },
  },
  summaryTrendStats: ['avg', 'med', 'p(95)', 'p(99)', 'max'],
  thresholds: { http_req_failed: ['rate<0.01'], 'http_req_duration{name:login}': ['p(99)<1000'] },
};

export function setup() {
  const email = `login-${Date.now()}@boxoffice.dev`;
  http.post(`${BASE_URL}/auth/register`, JSON.stringify({ name: 'Login Fan', email, password: 'secret-123' }), json);
  return { email };
}

export default function (data) {
  const res = http.post(`${BASE_URL}/auth/login`,
    JSON.stringify({ email: data.email, password: 'secret-123' }), { ...json, tags: { name: 'login' } });
  check(res, { 'login is 200': (r) => r.status === 200 });
}
# 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:

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 calls auth per request β€” so jwt and open are within noise of each other. The catalog route is open, but a request that carries a token still has it validated: a tampered one gets 401 there too.
  • Login is expensive on purpose. BCrypt with cost 10 burns tens of milliseconds of CPU per attempt, so auth with 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 with auth at ~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 s response-timeout they fail with 504 β€” 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, plus exp, iss and aud, in SecurityConfig.java; see Identity β€” validating tokens at the gateway.
  • Deny by default β€” .anyExchange().authenticated() in SecurityConfig.java protects every route nobody has listed yet, including future ones; see API gateway.
  • Never trust identity headers from the client β€” UserHeadersFilter.java removes X-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: 5s and a single retry of GET in the gateway's application.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/auth and services/gateway running; the new infra/nginx/nginx.conf sends 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-Id yourself has no effect (show the evidence).
  • results/lab4.md with the hop table, the login capacity and the two answers.
  • Repository tagged lab4; quiz answered.

End state

changed in lab 4
πŸ“ boxoffice/
β”œβ”€β”€ YAML docker-compose.yaml        # replaced: + auth (8081), + gateway (8080); edge -> gateway
β”œβ”€β”€  infra/nginx/nginx.conf     # replaced: everything goes to the gateway, /actuator hidden
β”œβ”€β”€ JavaScript 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
└── Markdown results/lab4.md  results/lab4/

Next: Lab 5 β€” Caching.


  1. Spring Cloud Gateway reference, Server WebFlux; Spring Security reference, OAuth 2.0 Resource Server β€” JWT. ↩

  2. IETF, RFC 7519 β€” JSON Web Token and RFC 7517 β€” JSON Web Key. ↩