oidc-keycloak-zero-trust
OIDC, Keycloak & Zero-Trust JWT
The problem: who is calling, and can we trust the network?
The SPA must log a user in, and every service must know which user is behind a request — not just "someone authenticated at the edge." The naive model ("the gateway checked the token, so anything inside the cluster is trusted") fails the moment a second internal service, a misconfigured route, or a compromised pod can reach account-service directly. We want every service to independently prove the caller's identity, and we want that identity to ride the whole async saga.
The pattern
Two patterns stacked:
OIDC (OpenID Connect) — an identity layer over OAuth 2.0. An identity provider (Keycloak) authenticates the user and issues a signed JWT access token. The SPA gets the token via the Authorization Code flow with PKCE (the browser-safe flow: no client secret, an exchange code bound to a one-time verifier). The token is a signed bag of claims — sub (user id), preferred_username, exp — that anyone can verify with Keycloak's public keys but no one can forge.
Zero trust — never trust the network, always verify the token. The gateway validates it and each service validates it again against Keycloak's JWKS (public signing keys). A request that sneaks past the gateway still hits a closed door.
Authorization Code + PKCE
browser ──────────────────────────────────► Keycloak (realm: banking)
│ ◄──────────── signed JWT ─────────────────┘
│
▼ Bearer <JWT>
Gateway ── validates JWT (JWKS) ──► forwards token downstream
│
├──► account-service ── validates JWT *again* (AddJwtBearer)
└──► transfer-service ── validates JWT *again* (jwks-rsa guard)
│
└─ stamps sub into metadata.userId ─► every saga event
Where it lives in our code
- Realm:
keycloak/realm-banking.json— imported on startup (--import-realm). Public clientbanking-web(PKCE S256, standard flow for the SPA + direct-access-grants so e2e/tests can password-grant), usersalice/bob, realm rolecustomer. Thebasicclient scope is indefaultClientScopes— without it the access token has nosubclaim and every downstream "no subject" check fails. - SPA login:
frontend/src/app/app.config.ts(angular-auth-oidc-client, PKCE) +frontend/src/app/auth/api-auth.interceptor.ts(attaches the bearer token + correlation id to every gateway call). - Gateway validation:
gateway/src/Gateway/Program.cs—AddJwtBearerwithMetadataAddress+ValidIssuers, plus theauthenticatedpolicy on/api/*routes. - Service re-validation (zero trust):
- account-service:
account-service/src/AccountService/Program.cs—AddJwtBearergated onAuth:Enabled(default true; false in integration tests),RequireAuthorization()on the endpoints. - transfer-service:
transfer-service/src/auth/jwt-auth.guard.ts— a Nest guard verifying the JWT against the JWKS withjwks-rsa,AUTH_ENABLEDenv (default true).
- account-service:
- Identity rides the saga: transfer-service stamps the authenticated
subintometadata.userId(transfers.service.ts), and account-service consumers copy incoming metadata onto every event they publish — so the initiating user is attributable on every message in the flow.
The dual-issuer wrinkle (a real cross-network gotcha)
The same Keycloak realm has two addresses: http://localhost:8180/... from the browser/host and http://keycloak:8080/... from inside the compose network. A JWT's iss claim is whatever URL the caller used to get it — so a browser token says localhost:8180, an in-network token says keycloak:8080. Same signing keys, different issuer string. The fix: every validator accepts both ValidIssuers and pins the JWKS via MetadataAddress, instead of deriving everything from a single Authority. This is exactly the kind of thing that "works on my machine" and breaks in Docker if you don't anticipate it.
Trade-offs
- Re-validating in every service costs a little CPU and a JWKS fetch (cached). The payoff is that no service has to trust the network or the gateway. Worth it.
ValidateAudience = false(documented in the code): Keycloak puts its ownaudin tokens by default; a production setup adds an audience mapper for this API and validates it so a token minted for another app can't be replayed here. We skip it for demo simplicity and say so out loud.- Public client + PKCE, no secret: correct for a SPA (a browser can't keep a secret). PKCE is what makes the secret-less Authorization Code flow safe against code interception.
Interview Q&A
Q: Authorization Code + PKCE vs. the implicit flow — why PKCE?
The implicit flow returned tokens directly in the URL fragment — leaky (browser history, referrer headers) and now deprecated. Authorization Code returns a short-lived code that's exchanged for tokens over a back channel; PKCE binds that code to a one-time code_verifier the SPA generated, so an intercepted code is useless without the verifier. It's the OAuth working group's current recommendation for public clients.
Q: Access token vs. ID token — which goes to the API? The access token. The ID token is about the authentication event and is for the client (the SPA) to learn who logged in; sending it to APIs is a common mistake. The access token is the bearer credential APIs validate and authorize on.
Q: How does a service validate a JWT without calling Keycloak per request?
It fetches Keycloak's JWKS (public keys) once from the discovery document and caches it, then verifies the token's RS256 signature locally. No per-request round trip — verification is offline math. Key rotation is handled by re-fetching JWKS when an unknown kid appears.
Q: The gateway validated the token. Isn't re-validating in services redundant? That redundancy is zero trust. The gateway is one enforcement point reachable from the internet; inside the cluster, services must not assume every caller passed through it. Defense in depth means the security boundary is each service, not the perimeter.
Q: Why carry userId in event metadata instead of re-deriving it downstream?
Because the async consumers (debit, credit, compensate) run with no HTTP request and no token — the saga is event-driven. Stamping the authenticated sub into metadata.userId at the synchronous edge makes every downstream event attributable to a real user, which is what an audit trail needs.
Q: Where does authorization (not just authentication) happen?
Authentication ("who are you") is the JWT signature + issuer check, everywhere. Authorization ("may you do this") is the authenticated policy at the gateway and RequireAuthorization()/the guard at the services. Richer rules (role customer, owns-this-account checks) would hang off the same validated claims — the realm already ships a customer role to build on.
Try to break it
curl http://localhost:8080/api/accounts(no token) → 401 at the edge. Now hit account-service directly with no token — still 401. The service doesn't trust the network.- Grab a token (
scripts/e2e.shshows the password-grant curl, notescope=openid), tamper one character of the signature, replay it → 401 (signature check fails locally, no Keycloak call needed). - Forge a token signed with your own RSA key and the right issuer → 401 (the
kidisn't in Keycloak's JWKS).transfer-service/src/auth/jwt-auth.guard.spec.tsproves exactly this with a local keypair. - Remove
"basic"fromdefaultClientScopesin the realm, recreate Keycloak, request a token → it has nosub, and the services reject it with "no subject" — the gotcha that cost real debugging time. - Submit a transfer as alice, then inspect the published event in the RabbitMQ UI (
localhost:15672):metadata.userIdis alice'ssubUUID, carried with no HTTP context in sight.