DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

OrderHub Day 36: stop minting tokens, only validate them — an RS256 JWT from an external OIDC issuer, checked via JWKS

On Day 35 of building OrderHub my app was doing something it really shouldn't in production: it was both the issuer and the verifier. It minted an HS256 token at /auth/login and checked it with the same shared secret. That's symmetric — anyone who can verify a token can also forge one — and it couples authentication tightly to the app: my service owned the user store, the login endpoint and the signing key. Day 36 breaks that coupling. An external OIDC provider (Keycloak, Auth0, Okta, Entra) owns login and signs tokens with its private RS256 key; my app becomes a pure resource server that only validates incoming tokens against the issuer's public key. No /auth/login, no user store, no signing secret. Here's what changed and why it matters.

Symmetric HS256 → asymmetric RS256

The core shift is the key type:

// Day 35 — self-issued, symmetric HS256:
//   app signs AND verifies with the SAME shared secret
//   (whoever can verify can also forge)

// Day 36 — external issuer, asymmetric RS256:
//   IdP signs with its PRIVATE key
//   app verifies with the matching PUBLIC key — and can never mint a token
Enter fullscreen mode Exit fullscreen mode

With RS256 the signing key lives only in the IdP. Anyone can verify with the public key, but no one else can sign. The payoff is decoupling: one identity provider, many stateless resource servers, none of them holding a password or a secret. That's the whole reason the model scales.

The app fetches public keys from the JWKS

The app never hard-codes a key. It's configured with the issuer, discovers the issuer's endpoints once at startup via /.well-known/openid-configuration, and fetches the public keys from the JWKS endpoint:

GET {issuer}/.well-known/openid-configuration   { "jwks_uri": ".../certs", ... }
GET {jwks_uri}   { "keys": [ { "kty":"RSA", "kid":"idp-1", "n":"0vx7…", "e":"AQAB" } ] }
Enter fullscreen mode Exit fullscreen mode

Each token's header names a kid; the decoder picks the matching JWKS key to verify the signature. The big win is key rotation for free — the IdP can roll its keys and the app just refetches the JWKS. No redeploy, no shared secret to rotate.

A valid signature is necessary but not sufficient

The part I want to stress, because it's the easy thing to get wrong: verifying the signature only proves who signed it. A token genuinely signed by the IdP but minted for a different app or a different realm is still not for you. So the validator pins three more claims:

iss  == https://idp.orderhub.test/realms/orderhub   (wrong realm → 401)
aud  contains "orderhub-api"                          (not us → 401)
exp  > now (± small clock-skew leeway)                (past → 401)
Enter fullscreen mode Exit fullscreen mode

Signature proves origin; iss/aud/exp prove the token was minted for us and is still current. All four must pass — that's defence in depth.

401 vs 403 — the distinction that keeps clients sane

The demo lets you send a token through the chain and see exactly where it stops, and the split is the same one from earlier days. A bad signature, wrong issuer, or expired token fails validation401, before the role is ever checked: no valid identity, go re-authenticate. A valid token that establishes a real identity but lacks the required role fails authorization403: you are who you say, but you may not do this. A JwtAuthenticationConverter maps the roles claim to authorities, so reads need ROLE_USER and writes need ROLE_ADMIN — a plain user hits 403 on a POST, an admin sails through to a 201.

How it looks in real Spring code

None of this is much code. spring-boot-starter-oauth2-resource-server installs a BearerTokenAuthenticationFilter; a NimbusJwtDecoder built from the JWKS verifies the RS256 signature; a DelegatingOAuth2TokenValidator checks exp, iss and aud; the converter maps claims to roles; the chain is STATELESS and health + docs stay public. I gated the whole thing behind orderhub.security.oauth2.enabled so Day 35 and every prior test stays green — flip it off and the open filter chain permits all, exactly like Day 33. The @WebMvcTest generates an RSA keypair, signs with the private half, points the decoder at the public half, and asserts valid→200, bad-sig/wrong-iss/expired/wrong-aud→401, wrong-role→403.

That's the mental model I keep: a resource server trusts an issuer, not a secret — verify, don't mint. Day 37 pushes authorization down to method-level @PreAuthorize.

Try the interactive walkthrough (send a token, watch where it's stopped):
https://dev48v.infy.uk/orderhub.php
Repo: https://github.com/dev48v/order-hub-from-zero

Top comments (0)