↑↓ to navigate Enter to open "…" all these words ANDOR to combine

Auth & the edge · No. 16

Cross-service auth without a shared secret: JWKS dual-fetch

When you split a monolith into services, the easy answer is to copy the JWT signing secret into every validator. That is also the answer that turns one leaked key into a system-wide breach. Here is the asymmetric boundary that fixes it.


You have a monolith that mints its own JWTs. One process signs the token, the same process validates it, and the signing key is a symmetric secret (HS256). This is fine. The signer and the validator are the same code, so the shared secret is not really shared with anyone.

Then you extract a module into its own service. Now the Orders service needs to validate a token that the Identity service minted. The obvious move is to copy the HS256 secret into the Orders service config so it can validate. Then into the Catalog service. Then into the gateway. Then into the notification worker.

Stop and look at what just happened. With a symmetric secret, the key that validates a token is the same key that mints one. Every service that can check a token can also forge one. You have taken a single high-value secret and replicated it across every deployment unit, log scrape, and environment variable in the fleet. The blast radius of one leak is now "the entire system can be impersonated."

Why it matters

Symmetric signing couples trust to secret distribution. The more services you have, the more copies of the forge-anything key exist, and the more places it can leak: a misconfigured config map, a debug log that dumped the environment, a developer laptop, a third-party APM that captured an env var. You cannot rotate it without a coordinated flag day across every service at once, because they all share it.

The correct model for distributed token validation is asymmetric: the issuer signs with a private key it never shares, and every other service validates with the matching public key. A public key is public. It cannot mint tokens. Leaking it is a non-event. That is the property you want for a boundary that many services depend on.

The problem is that "just switch to RS256" is not the whole story. The validators still need to get the public key, keep getting it as it rotates, and refuse to be tricked into validating the wrong way. That is what MMCA.Common's JWKS layer is built to handle.

The MMCA answer: publish the public half as a JWKS document

The framework's token service supports two signing algorithms, selected by the concrete JwtSettings.SigningAlgorithm, which defaults to RS256:

  • Monolith mode: HS256 (symmetric HMAC-SHA256). One process signs and validates with a shared Base64 secret. A shared secret inside one process is not actually shared, so this is correct here.
  • Microservice mode: RS256 (asymmetric RSA-SHA256). The issuer signs with its RSA private key; other services validate with the matching public key and hold no secret at all.

The join point that makes RS256 work across services is JWKS (JSON Web Key Set). In MMCA.Common, IJwksProvider (in MMCA.Common.Infrastructure, implemented by RsaJwksProvider) materializes a JsonWebKeySet from a PEM-encoded RSA public key. Critically, it exports only the public parameters: the private key never leaves the issuer. The set is built lazily on the first request, and only a successful result is cached: the Lazy<JsonWebKeySet> is created with LazyThreadSafetyMode.PublicationOnly, so one transient failure reading the PEM is retried on the next call rather than cached for the life of the process.

The API layer's JwksEndpointExtensions serves that key set at the standard discovery path, /.well-known/jwks.json. A downstream service points its JwtBearer handler at that URL and the ASP.NET Core JWKS machinery fetches the issuer's public keys, validates incoming RS256 tokens against them, and refreshes the key set on its own schedule. The Identity service holds the one private key; every validator holds nothing.

// Issuer side (Identity API): RsaJwksProvider exposes ONLY the public key.
// Served at GET /.well-known/jwks.json by JwksEndpointExtensions.
JsonWebKeySet GetJsonWebKeySet();   // public parameters only; private key never leaves

// Validator side (any extracted service): JwtBearer fetches the issuer's JWKS URL
// (routed through the YARP gateway) and validates RS256 tokens against the public keys.
// The validator holds NO secret. A leaked public key cannot mint tokens.

(Source: RsaJwksProvider.cs, IJwksProvider.cs, and the JWKS notes in MMCA.Common/CLAUDE.md's microservices extraction section.)

The dual-fetch, and the graceful empty set

The dual-fetch ADR-004 describes is in the key discovery itself: a validator discovers the issuer's public key from the JWKS endpoint, and the framework falls back gracefully when the key is not there. When JWKS publishing is disabled (the default for a monolith) or no key is configured, RsaJwksProvider returns an empty key set rather than throwing. The endpoint stays a valid, pollable URL instead of erroring. A monolith that never turns JWKS on still answers /.well-known/jwks.json with an empty document, so flipping a service into extracted mode does not require standing up new endpoints. Discover the key; fall back when it is not there. That is the dual-fetch.

A different two-fetch is easy to conflate with this one, so it is worth naming plainly: the shared login flow, AuthenticationServiceBase<TUser>.LoginAsync in MMCA.Common.Application, does an untracked read to validate credentials (so the adversarial majority of login traffic, the failed attempts, never pays EF Core change-tracking cost), then a tracked re-fetch only on success to persist the rotated refresh token. The base's own doc comment names it the "untracked-then-tracked dual-fetch"; the ADC and Store Identity modules are thin sealed subclasses that supply the app-specific hooks (the untracked lookup, the claim set) and re-label it "dual-fetch pattern" in their class doc comments (AuthenticationService.cs:17 in ADC, :16 in Store). It is shared framework login code, not a per-app copy, but it is still not part of ADR-004's cross-service validation design.

JWKS discovery is routed through the YARP gateway, not pointed at each service's internal address. The validator asks the gateway for the issuer's JWKS document, and the gateway forwards it to whichever host currently owns Identity. This keeps the validator's config stable across topology changes: when Identity moves, redeploys, or scales, the gateway route updates, not every consumer.

JWT algorithm pinning: the attack you would otherwise ship

Switching to asymmetric keys opens a specific, classic attack, and the framework closes it explicitly.

Once the RSA public key is published (which is the entire point of JWKS), an attacker has it. If a validator naively trusts the token's own alg header, the attacker can craft a token that claims alg: HS256 and sign it using the public RSA key bytes as the HMAC secret. A validator that reads alg from the attacker-controlled header would happily verify it. This is the well-known algorithm- confusion / key-substitution attack.

MMCA.Common's TokenService pins the algorithm. On the refresh path, GetPrincipalFromExpiredToken deliberately skips lifetime validation (its job is to read claims out of an already-expired token, with the relevant analyzer warning suppressed inline and justified) but it re-checks the token's alg header against the expected algorithm and restricts ValidAlgorithms to the single expected value. An attacker cannot substitute an HS256-signed token in place of a real RS256 one. The same pin guards the cross-service path: the JWKS-forwarded bearer handler (AddForwardedJwtBearer, used by extracted services validating against the issuer's published keys) also restricts ValidAlgorithms to [RsaSha256] (added in v1.82.0), so a token whose header claims HS256 is rejected on the discovery path too, not only in-process. The token service is also IDisposable and owns its RSA handles, releasing the native key handles on disposal.

The rule of thumb: never let the token tell you how to verify it. The validator decides the algorithm; the token only supplies the signature.

Trade-offs, honestly

The JWKS layer is the right model, but it is not free, and the §11 review of the framework names the rough edges.

  • Two round trips on successful login. The Identity service's login flow (the shared login dual-fetch above, not the JWKS layer itself) pays a tracked re-fetch, a second query on the success path. In practice the first query warms the database buffer pool so the second is near-instantaneous, and failed logins (the adversarial majority) pay nothing extra. It is a deliberate trade, not an oversight.
  • A small race window between the two fetches. A user could be deleted between the untracked validation and the tracked re-fetch. The re-fetch then returns NotFound, which is the correct outcome, so the window is acceptable rather than hidden.
  • Plain-HTTP metadata discovery is an explicit, auditable opt-out. AddForwardedJwtBearer resolves RequireHttpsMetadata in three steps: explicit argument, then the Authentication:JwtBearer:RequireHttpsMetadata config key, then true everywhere except Development, and a resolved false outside Development logs one startup warning naming the key. The honest residual is that an operator can still set that key to false, and both production deployments do: ADC and Store validate against an internal-ingress cleartext http://identity, so their bicep sets it explicitly with the justification written beside it. That is a reviewable line in a deployment template rather than a default nobody chose. Permissive dev CORS is a development-only affordance, selected by environment.
  • The invariants in this article fail the build. Executable tests run the real registration code and read the produced options back: the forwarded bearer handler's ValidAlgorithms must stay [RsaSha256], the RequireHttpsMetadata resolution above is asserted step by step (including the fail-fast on a cleartext authority with no opt-out), the permissive CORS policy must never support credentials while the credentialed one must never widen to any origin, and RsaJwksProvider must export only the public RSA parameters even when handed a private-key PEM. Beside them, a shared AnonymousEndpointTestsBase scans controllers and routable components by reflection and ships five facts: it fails on any [AllowAnonymous] outside an explicit allow-list, on a stale allow-list entry, on an empty scan, on an endpoint that declares neither [Authorize] nor [AllowAnonymous] (a stricter gate each suite opts into, which the framework turns on for itself), and on a stale entry in that second, undecorated allow-list. The framework's own allow-list holds 18 entries: ten credential-exchange actions that cannot require a token because issuing one is what they do (login, register, refresh, the OAuth exchange, the three provider challenges and the provider callback, forgot-password and reset-password), the five credential pages, and three landing and outcome pages that render nothing depending on the caller. NetArchTest's fluent API cannot express this pair, which is why the rules live as full-name reflection and options-resolution tests instead. Two residuals stay honest. Minimal-API endpoints opt out through an .AllowAnonymous() builder call, which is endpoint metadata rather than an attribute, so the JWKS and OIDC discovery endpoints themselves sit outside the scan's reach. And the anonymous-endpoint scan is only as complete as the suites that subclass it: each consuming repo owns its own allow-list. The framework's thesis is that a rule that matters should fail the build, and these rules do.

None of these are reasons to share a symmetric secret across services. They are the reasons to wire the asymmetric boundary deliberately.

Apply this even without MMCA

The pattern ports to any stack with a JWT story:

  1. In a single process, symmetric (HS256) is fine. Do not add asymmetric complexity you do not need. The signer and validator are the same code.
  2. The moment a second service validates tokens it did not mint, go asymmetric (RS256/ES256). The issuer keeps the private key; everyone else gets the public key. A validator should never hold a key that can also forge.
  3. Publish the public key as a JWKS document at /.well-known/jwks.json and let the standard JwtBearer discovery fetch and refresh it. Route discovery through your gateway so topology changes do not ripple into every consumer's config.
  4. Pin the algorithm on the validator. Set the allowed algorithms explicitly and re-check the token's alg against your expectation. Never let the token's own header choose the verification method, or you have shipped the algorithm-confusion attack.
  5. Fall back gracefully. Serve an empty key set rather than erroring when JWKS is off, so the same code path works in monolith and extracted modes.

The takeaway: the key that validates a token should never be the key that mints one. Asymmetric signing plus JWKS discovery is how you keep that true across a fleet, and it is the extraction point that makes pulling a module out into its own service a config change rather than an auth rewrite.


What we covered: why a shared symmetric secret turns one leak into a system-wide forgery, how RS256 + a published JWKS document lets extracted services validate against the issuer's public keys with no secret of their own, how ADR-004's dual-fetch (discover the key, fall back to an empty set) and gateway-routed discovery keep the boundary stable, and why algorithm pinning closes the key- substitution attack that publishing a public key would otherwise open.

Next in the series: password hashing done right: PBKDF2-SHA512, 600k iterations, and timing-safe comparison, the credential-at-rest half of the same security story.

MMCA.Common is Apache-2.0 licensed and open source. Star the repo, read the 2-minute ADR-004 behind this pattern, or dotnet add package MMCA.Common.API and try it.

Tags: .NET, C Sharp, Software Architecture, Microservices, Authentication