API Gateway — Service Reference
Module: backends/api-gateway · Port: 8000 (REST, external ingress only)
For the route table, rate limits, and CORS port mapping, see API Gateway (concept). This page covers implementation mechanics that doc doesn’t.
Purpose
Spring Cloud Gateway (WebFlux, reactive) — the single external entry point for all client traffic.
Owns TLS termination, JWT/session validation at the edge, Redis-backed rate limiting, CORS, and
routing to Core Engine (:8080) or Admin Console (:8084). It never talks to gRPC microservices
directly. Routes are registered programmatically in GatewayConfig.java (~1,195 lines), not
via application.yml route YAML — most-specific first, ending in a catch-all /api/v1/**.
| File | Responsibility |
|---|---|
config/GatewayConfig.java | All route definitions (auth requirement + rate-limit key per route) |
config/JwtTokenValidationGatewayFilter.java | Rejects requests without a valid JWT on a JWT-gated route |
config/OptionalJwtTokenValidationGatewayFilter.java | Validates a JWT if present, doesn’t reject its absence (e.g. BYOK issuance, which can authenticate via signature instead) |
security/JwtTokenValidator.java | HS512 signature/expiry validation + claims extraction (shared secret with core-engine, admin-console) |
config/RateLimitingGatewayFilterFactory.java | Redis fixed-window rate limiter |
config/RequestLoggingGatewayFilterFactory.java | Correlation-ID injection/propagation, structured logging |
config/IdentityHeaders.java | Constants for X-User-ID/X-User-Role/X-Issuer-ID/X-Token-Scope |
config/ApiKeyHasher.java | Hashes issuer API keys for the headless-issuer auth path |
config/SecurityConfig.java | WebFlux security chain wiring |
config/OpenApiConfig.java | Aggregated OpenAPI/Swagger metadata |
config/RedisConfig.java | ReactiveRedisTemplate bean for the rate limiter |
controller/FaviconController.java | Suppresses noisy 404s for /favicon.ico |
exception/GatewayExceptionHandler.java | Uniform JSON error envelope for gateway-level failures |
JWT validation
JwtTokenValidator verifies HS512-signed tokens against a secret shared across three services —
api-gateway, core-engine, admin-console — via the same JWT_SECRET env var. All three must
use the identical secret or cross-service tokens silently fail validation.
On success the gateway sets these headers on the proxied request (client-supplied copies are stripped first, so downstream services can trust them):
X-User-ID
X-User-Role
X-Issuer-ID (when present)
X-Token-ScopeX-Token-Scope disambiguates an admin-console platform admin from an issuer-portal user who also
holds an "Admin" role claim, since both are minted with the same shared secret from
overlapping role vocabularies. JwtTokenValidator.PENDING_APPROVAL_SCOPE
("issuer-portal-pending") is a special scope core-engine mints for an issuer still
PENDING_APPROVAL (or rejected); every JWT-gated filter must reject this scope except the public
/api/v1/issuer/auth/** endpoints.
Holder and verifier sessions are not gateway-validated bearer JWTs — they’re HttpOnly cookies
validated by Core Engine itself. The gateway routes those paths through as Cookie session auth
(no gateway-side JWT filter).
Rate limiting
RateLimitingGatewayFilterFactory is a Redis-backed fixed 60-second window counter (INCR +
EXPIRE 60s per key — resets at the window boundary, not a true sliding window despite the
class-level Javadoc). Key format: ratelimit:<limitKey>:<clientId>.
clientId resolves in priority order:
X-User-IDheader (set by the JWT filter for an authenticated caller)X-Issuer-IDheader, prefixedissuer:(for issuer-scoped routes)X-Forwarded-For/ remote socket address (anonymous/public callers)
Every response gets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset. Exceeding
the limit returns 429 Too Many Requests with Retry-After: 60 and a JSON error body.
Fail-open by design: .onErrorReturn(true) means a Redis outage disables rate limiting
entirely rather than blocking traffic — a deliberate availability-over-strictness tradeoff, worth
knowing when reasoning about abuse scenarios during a Redis incident.
CORS
Configured in application.yml (spring.cloud.gateway.server.webflux.globalcors), not in Java.
Two exact-path exemptions registered before the /** catch-all:
/api/v1/issuer/auth/sso/saml/acs— issuer SAML ACS webhook/api/v1/custodian/auth/sso/saml/acs— custodian SAML ACS webhook
Both allow any origin with credentials disabled — a SAML ACS endpoint is a cross-origin POST from
the identity provider’s own domain by design; the real security boundary is
SamlAssertionValidator’s XML-DSig signature check, not the Origin header.
Catch-all /** default CORS_ALLOWED_ORIGINS:
http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000— issuer-portal (5173), verifier-portal (5174), admin-console (5175), credential-wallet (5176), plus a generic 3000 fallback.
Known gap: custodian-portal’s dev port (5178) is absent from this default allowlist, even
though the gateway has an entire custodian route surface (custodian-registration,
custodian-auth, custodian-profile, custodian-intake, custodian-erasure-requests,
custodian-credentials, custodian-holders, custodian-workforce*, custodian-compliance*,
custodian-team, custodian-search, custodian-admin-ops — all present in GatewayConfig.java).
A deployed environment typically overrides CORS_ALLOWED_ORIGINS anyway, which is presumably why
this hasn’t surfaced — but a bare ./gradlew :backends:api-gateway:bootRun +
cd frontends/custodian-portal && npm run dev combination hits CORS failures on the default
config alone.
OpenAPI aggregation
OpenApiConfig publishes a single aggregated OpenAPI bean (“Attest ID - Credential Management
API”), served via springdoc at /v3/api-docs / /swagger-ui.html. Documents Development
(localhost:8000) and Production (api.attestpro.com) servers. This is a hand-written
top-level description, not a mechanically-merged aggregation of each backend’s own OpenAPI spec —
the gateway self-describes rather than proxying/merging Core Engine’s and Admin Console’s own
generated specs.