ADR-008: Simplified API Gateway - Backend Controls All Routing

Last updated: June 3, 2026 Reading time: 7 minutes File size: 11.61 KB

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

  1. Infrastructure Coupling: Adding new routes required updating CDK code and deploying infrastructure
  2. Route Duplication: Public endpoints were defined in BOTH infrastructure (api-gateway-stack.ts) AND microservices (SecurityConfig.java)
  3. Deployment Friction: Route changes required infrastructure deployment (~5-10 minutes) instead of just service deployment (~2-3 minutes)
  4. 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 @PreAuthorize annotations
  • 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

  1. 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
  2. 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
  3. 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

  1. Development Velocity ⚑:

    • Adding new routes: Just update microservice code
    • No infrastructure deployment needed
    • Faster iteration cycle (2-3 min vs 5-10 min)
  2. Simpler Infrastructure πŸ“‰:

    • Reduced from ~450 lines to ~150 lines
    • Single route definition: /{proxy+}
    • Easier to understand and maintain
  3. Single Source of Truth 🎯:

    • Microservices control their own security
    • No duplication between infrastructure and services
    • Reduced risk of inconsistency
  4. Flexibility πŸ”„:

    • Public endpoints: Just update SecurityConfig.java
    • Protected endpoints: Just add @PreAuthorize
    • No infrastructure changes required
  5. Cost Reduction πŸ’°:

    • Fewer infrastructure deployments
    • Less AWS API Gateway route configuration
    • Reduced maintenance overhead

Negative Outcomes

  1. Edge Protection Lost ⚠️:

    • Invalid JWTs now reach the backend
    • Minor performance impact: ~1-2ms per invalid request
    • Mitigation: Backend rejects immediately, no security risk
  2. 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

  1. infrastructure/lib/stacks/api-gateway-stack.ts:

    • Removed 13 explicit route definitions (~200 lines)
    • Removed authorizer: this.authorizer from catch-all route
    • Updated documentation comments
  2. 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

Testing Strategy

Step 1: Verify immediate fix (SecurityConfig):

bash
# 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:

bash
# 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 @PreAuthorize requires 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)