Token-type discrimination and verifier hardening
Under OIDC the same asymmetric keypair signs both access tokens and ID tokens with the same issuer (see ADR 0001), so an ID token presented to the UserInfo endpoint would otherwise pass signature and expiry verification and be accepted as an access token. We decided that, when OIDC is enabled, access tokens are signed with the JOSE header typ: "at+jwt" (RFC 9068) while ID tokens keep typ: "JWT", and the UserInfo endpoint — and any other access-token verifier — rejects a token whose typ is not at+jwt. Independently, JwtService.verify pins its algorithm: it always passes algorithms: [configuredAlgorithm] so a token's own alg header can never drive verification.
Considered Options
- Rely on the
openid-scope check to reject ID tokens at UserInfo — rejected: an ID token carries noscopeclaim so it 403s today, but that is accidental, undocumented, and defeated the moment agetIdTokenClaimshook adds a scope-shaped claim. - A structural marker claim (e.g. require
cid) — rejected: not a recognized spec guard and equally defeatable by a custom-claims hook. - Leave
verify()forwarding caller options unchanged — rejected:jsonwebtoken@9blocks HS-vs-public confusion only by key-type inference; pinning the algorithm deliberately, in one place, removes the chance for any call site to forget.
Consequences
- The
at+jwtheader is applied only when OIDC is enabled, so the byte-identical HS256 token format for non-OIDC consumers (ADR 0001) is unaffected. It does add to the one-time token-format change OIDC already entails. JwtService.verifynow pins["HS256"]for existing symmetric consumers as well. This is a safe-by-default tightening of verification behaviour, not a token-format change, but it is a behaviour change worth noting for any consumer relying on the previous pass-through.- Algorithm pinning lives in the library's own
JwtService. A consumer who supplies a customJwtInterfaceimplementation remains responsible for pinning algorithms in their own verifier.