Stage 7 · Master
Phase 5 — API Gateway
JWT Validation
Give the gateway its own independent copy of auth-service's public keys, verify every token before it reaches a backend, and replace any client-supplied identity headers with server-verified ones — closing the trusted-header placeholder left open in the User module's CRUD lesson.
The Gateway Verifies Tokens Itself — It Does Not Ask auth-service
It might seem natural for gateway-service to call auth-service's verification endpoint on every request. That would make login infrastructure a synchronous dependency of every API call. Authentication Service already created pkg/jwtverify for this exact boundary. The gateway supplies its refreshed public-key set to that concrete verifier and performs every check locally.
pkg/jwtverify contains Claims, public-key lookup, algorithm pinning, and expiry validation. Private signing keys and token issuance remain inside auth-service. Downstream services can verify what auth-service signed, but cannot mint a token.
+publicKeys, err := keysource.LoadEd25519PublicKeys(cfg.JWKSFile)
+if err != nil {
+ logger.Error().Err(err).Msg("load JWT public keys")
+ os.Exit(1)
+}
+tokenVerifier := jwtverify.New(publicKeys)
+
engine.Use(sharedhttpmw.Adapt(sharedhttpmw.RequireAuth(tokenVerifier)))The gateway owns key refresh and process configuration; pkg/jwtverify owns parsing and validation. Neither needs auth-service to be reachable while serving a request.
Stripping Client Headers Before Injecting Server-Verified Ones
The User module's crud-apis lesson left a specific placeholder unresolved: handlers there trust an X-User-Id and X-Org-Id header without explaining who is responsible for setting them safely. This is that answer. Any client-supplied X-User-Id, X-Org-Id, or X-User-Roles header arriving at the gateway is deleted outright — a client could otherwise simply set X-User-Id: any-uuid-they-want and impersonate anyone — and the gateway injects its own versions, populated only from claims it just verified.
package httpmw
import (
"net/http"
sharedhttpmw "github.com/hoa-platform/backend/pkg/httpmw"
)
// InjectTrustedIdentity must run after RequireAuth (which places verified
// Claims in context) and before the request is proxied to any backend. It
// is the single point where the platform's trust boundary between
// "client-supplied" and "gateway-verified" is enforced.
func InjectTrustedIdentity(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := sharedhttpmw.ClaimsFromContext(r.Context())
if !ok {
http.Error(w, "unauthenticated", http.StatusUnauthorized)
return
}
// A client cannot be trusted to set these — delete whatever it sent
// before setting our own server-verified values.
r.Header.Del("X-User-Id")
r.Header.Del("X-Org-Id")
r.Header.Del("X-User-Roles")
r.Header.Set("X-User-Id", claims.Subject)
r.Header.Set("X-Org-Id", claims.OrganizationID)
r.Header.Set("X-User-Roles", claims.Role)
next.ServeHTTP(w, r)
})
}
r.Header.Del runs before r.Header.Set for every one of these three headers, not just X-User-Id — an easy mistake is deleting only the header a specific attack targeted while leaving the other two still client-settable.
The Gateway's Own Layer of the Same Check RBAC Enforces Downstream
The Authentication module's authorization-rbac lesson placed a claims.OrgID == pathOrgID check inside each backend handler. The gateway adds a coarser version of the same idea one layer earlier: for any path containing an {orgID} segment, it can reject a request whose verified org_id claim does not match before the request is even proxied, saving a backend round trip for the most common tenant-mismatch case, while every backend handler still performs its own authoritative check as defense in depth — the gateway's check is an optimization, never a replacement for it.
gateway-service could theoretically have a bug in its own org_id extraction. Because every backend service still independently re-verifies claims.OrgID against its own path parameters (Authentication module), a gateway-level bug fails safe — the worst outcome is an unnecessary round trip to a backend that then correctly rejects the request, never a bypass of tenant isolation entirely.
Testing Header Stripping End to End
Applied exercise
Handle a legacy internal caller that still expects to set X-User-Id directly
A batch job running inside the cluster (not through the public gateway) calls user-service directly without ever going through gateway-service, and currently sets X-User-Id itself since it has no JWT to present.
- Explain why user-service trusting X-User-Id from any caller reaching it directly is a security gap, independent of anything the gateway does.
- Propose a fix that does not involve the batch job going through the public gateway (e.g., a NetworkPolicy, a service-to-service credential, or a separate internal-only endpoint).
- State why 'the gateway strips forged headers' is not, by itself, a sufficient answer to this scenario.
Deliverable
A written explanation of why gateway-side header stripping does not protect direct-to-backend traffic, plus a concrete alternative control.
Completion checks
- The answer explicitly recognizes that traffic bypassing the gateway entirely is unprotected by anything the gateway does.
- A concrete alternative control is named, not just 'route everything through the gateway' as an unqualified fix.
Why does gateway-service delete X-User-Id, X-Org-Id, and X-User-Roles headers before setting its own values, rather than only setting them if absent?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.