ADR-008: Simplified API Gateway - Backend Controls All Routing
Status
Context
Previously, the AWS HTTP API Gateway maintained 13 explicit public route definitions alongside a catch-all /{proxy+} route with JWT authorizer. This architecture required infrastructure deployment every time we added new backend routes.
Problems with Previous Approach
- Infrastructure Coupling: Adding new routes required updating CDK code and deploying infrastructure
- Route Duplication: Public endpoints were defined in BOTH infrastructure (api-gateway-stack.ts) AND microservices (SecurityConfig.java)
- Deployment Friction: Route changes required infrastructure deployment (~5-10 minutes) instead of just service deployment (~2-3 minutes)
- Maintenance Burden: Same security rules maintained in two places (risk of inconsistency)
Example of the Problem
When we added /api/v1/topics endpoints:
- Topics controller worked fine with proper
@PreAuthorizeannotations - But all requests went through the
/{proxy+}catch-all route with JWT authorizer - This was correct for POST/PUT/DELETE (require auth)
- BUT: If we wanted public GET endpoints later, weβd need infrastructure updates
Decision
We have decided to simplify the API Gateway architecture:
Changes Made
-
Removed all explicit route definitions (13 routes deleted):
/api/v1/config- Frontend bootstrap configuration/health,/info- Actuator endpoints/api/v1/events/current,/api/v1/events/{eventCode}- Public event discovery/api/v1/events/{eventCode}/sessions*- Session listings/api/v1/partners- Partner showcase/api/v1/companies/{companyIdentifier}- Company details/api/v1/events/{eventCode}/registrations*- Anonymous registration
-
Removed JWT authorizer from catch-all route:
- Previously:
/{proxy+}required JWT at infrastructure level - Now:
/{proxy+}forwards ALL requests to backend - Backend validates JWT and enforces authentication
- Previously:
-
Updated architecture documentation:
- Infrastructure: Edge concerns only (CORS, throttling, DDoS protection)
- Backend: All authentication and authorization
- Single source of truth: Microservices define their own security
New Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Client (Browser, Mobile App, API Consumer) β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
β All Requests
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β AWS HTTP API Gateway (Infrastructure) β
β - CORS configuration β
β - Throttling (1000 req/sec, 2000 burst) β
β - DDoS protection β
β - NO JWT validation β
β - Route: /{proxy+} β Spring Boot API Gateway β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
β Proxied Requests
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Spring Boot API Gateway Service (ECS) β
β - Route to microservices (based on path) β
β - JWT validation via CognitoJWTValidator β
β - SecurityConfig: public vs authenticated endpoints β
β - Rate limiting (role-based) β
β - Correlation ID generation β
ββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββ
β
β Routed Requests
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Microservices (ECS) β
β - Each service: SecurityConfig.java β
β - JWT validation via Spring Security β
β - Route-level security (.permitAll() vs .authenticated()) β
β - Method-level authorization (@PreAuthorize annotations) β
β - Business logic implementation β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Security Model
Infrastructure Layer (AWS API Gateway):
- β CORS enforcement
- β Request throttling
- β DDoS protection
- β NO JWT validation (moved to backend)
API Gateway Service Layer (Spring Boot):
- β JWT signature validation
- β Route requests to microservices
- β Correlation ID propagation
- β Role-based rate limiting
Microservice Layer:
- β JWT re-validation (defense-in-depth)
- β Route-level authentication (SecurityConfig)
- β Method-level authorization (@PreAuthorize)
- β Business logic enforcement
Consequences
Positive Outcomes
-
Development Velocity β‘:
- Adding new routes: Just update microservice code
- No infrastructure deployment needed
- Faster iteration cycle (2-3 min vs 5-10 min)
-
Simpler Infrastructure π:
- Reduced from ~450 lines to ~150 lines
- Single route definition:
/{proxy+} - Easier to understand and maintain
-
Single Source of Truth π―:
- Microservices control their own security
- No duplication between infrastructure and services
- Reduced risk of inconsistency
-
Flexibility π:
- Public endpoints: Just update SecurityConfig.java
- Protected endpoints: Just add @PreAuthorize
- No infrastructure changes required
-
Cost Reduction π°:
- Fewer infrastructure deployments
- Less AWS API Gateway route configuration
- Reduced maintenance overhead
Negative Outcomes
-
Edge Protection Lost β οΈ:
- Invalid JWTs now reach the backend
- Minor performance impact: ~1-2ms per invalid request
- Mitigation: Backend rejects immediately, no security risk
-
Backend Dependency βοΈ:
- Infrastructure relies on backend for all security
- Backend must be correctly configured
- Mitigation: Integration tests verify security config
Trade-off Analysis
| Aspect | Before | After | Impact |
|---|---|---|---|
| Invalid JWT handling | Rejected at edge | Rejected at backend | β ~1-2ms latency increase |
| Adding new route | Infrastructure deploy | Service deploy only | β 50% faster |
| Public endpoint changes | Update 2 places | Update 1 place | β Reduced duplication |
| Infrastructure complexity | ~450 lines | ~150 lines | β 66% reduction |
| Security depth | 2 layers | 1 layer | β οΈ Acceptable (backend still validates) |
Implementation Details
Files Modified
-
infrastructure/lib/stacks/api-gateway-stack.ts:
- Removed 13 explicit route definitions (~200 lines)
- Removed
authorizer: this.authorizerfrom catch-all route - Updated documentation comments
-
services/event-management-service/src/main/java/ch/batbern/events/config/SecurityConfig.java:
- Changed
.anyRequest().permitAll()to.anyRequest().authenticated() - Fixes POST /topics 403 error
- Enables @PreAuthorize annotations
- Changed
Testing Strategy
Step 1: Verify immediate fix (SecurityConfig):
# Test POST /topics with ORGANIZER role
curl -X POST https://api.staging.batbern.ch/api/v1/topics \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"title": "Test Topic"}'
# Expected: 201 Created (not 403 Forbidden)
Step 2: Verify infrastructure change:
# Public endpoints work WITHOUT auth
curl https://api.staging.batbern.ch/api/v1/events/current
# Expected: 200 OK
# Protected endpoints return 401 WITHOUT JWT
curl -X POST https://api.staging.batbern.ch/api/v1/topics
# Expected: 401 Unauthorized
# Protected endpoints work WITH valid JWT
curl -X POST https://api.staging.batbern.ch/api/v1/topics \
-H "Authorization: Bearer $TOKEN"
# Expected: 201 Created
# Invalid JWT returns 401 from backend
curl https://api.staging.batbern.ch/api/v1/topics \
-H "Authorization: Bearer invalid.token"
# Expected: 401 Unauthorized
Alternatives Considered
Alternative 1: Keep JWT validation at edge + backend
Pros: Defense-in-depth, invalid tokens rejected early Cons: Still requires infrastructure updates for new public routes Decision: Rejected - doesnβt solve the core problem
Alternative 2: Use wildcard patterns for public routes
Pros: Fewer route definitions Cons: Still requires infrastructure updates, less flexible Decision: Rejected - partial solution only
Alternative 3: Chosen approach (backend-only validation)
Pros: No infrastructure updates, simple, flexible Cons: Invalid tokens reach backend Decision: Accepted - benefits outweigh costs
References
- Original issue: POST /api/v1/topics returned 403 Forbidden
- Root cause:
.anyRequest().permitAll()prevented JWT parsing - Related: Spring Security
@PreAuthorizerequires populated SecurityContext - Related: AWS API Gateway JWT Authorizer documentation
Notes
- JWT authorizer definition kept in code (commented) for documentation
- Can be re-enabled for specific high-security routes if needed
- Backend already had JWT validation, so this just removes redundancy
- No security compromise: backend still validates everything
Update β 2026-06-02: CORS truly single-layer now
This ADR placed CORS at the edge (AWS API Gateway), but the Spring Boot gateway had
residual CORS that predated the consolidation and was never removed: a
SecurityConfig.corsConfigurationSource bean (wired into both filter chains) plus a
CorsHandler whose origin allowlist the rate-limit / Turnstile / account-active filters used
to attach CORS headers to their own 4xx responses. So the allowlist effectively lived in two
places, and adding a new origin (the beta.batbern.ch canary) required editing both β the
symptom that surfaced this.
Resolved: the Spring-layer CORS was deleted; CORS is now configured only in
infrastructure/lib/stacks/api-gateway-stack.ts (corsPreflight). Before removing the Spring
layer we verified the edge covers everything it did β Allow-Origin (actual and error
responses), Allow-Credentials, preflight Allow-Methods/-Headers, Max-Age β and closed the one
gap by adding exposeHeaders (the SPA reads X-Correlation-ID off responses in ~8 places;
APIGW was not exposing it). To allow a new origin now: update allowOrigins only.
Date
2025-12-18 (updated 2026-06-02)