Summary
Access-token credentials are currently signed with a JOSE header of typ: JWT. Per RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens §2.1, an OAuth 2.0 access token in JWT form SHOULD carry the explicit media type at+jwt in its typ header. These are OAuth 2.0 access tokens, not JWT-SVIDs, so at+jwt is the more correct value and lets resource servers distinguish ATs from other JWTs (e.g. ID tokens) by type.
This is a security measure, not only a correctness one. Per the JWT BCP (RFC 8725 §3.11, reaffirmed in draft-ietf-oauth-rfc8725bis), explicit typing defends against cross-JWT confusion — a JWT minted for one purpose presented where another type is expected. The BCP is explicit that a generic typ: JWT value "does not constitute effective explicit typing." (Raised by @adeinega.)
Current behavior
Both signing paths set typ: JWT:
internal/service/credential.go:423–426 (RS256 path)
internal/service/credential.go:428–431 (ES256 path)
hdrs := jws.NewHeaders()
_ = hdrs.Set(jws.TypeKey, "JWT")
And nothing on the verify side enforces typ: pkg/authjwt performs no typ check on access tokens, so even the generic value isn't validated. For contrast, DPoP proofs already do effective explicit typing — pkg/dpop/proof.go:122-128 requires typ == "dpop+jwt" exactly and rejects anything else.
Proposed change
Effective explicit typing needs both halves — emit the specific type and require it:
- Issue side — default the access-token
typ to at+jwt.
- Add a config option (e.g.
access_token_typ, default at+jwt).
- Update both signing sites in
internal/service/credential.go.
- Update any test that asserts
typ: JWT on issued access tokens.
- Verify side —
pkg/authjwt must require typ == at+jwt on access tokens and reject other values, mirroring the DPoP dpop+jwt check. Without this, emitting at+jwt yields the correct header but none of the anti-confusion protection — the "not effective" state the BCP warns about.
The JWT override is a temporary migration affordance only — for a rolling upgrade where some verifiers don't yet accept at+jwt. During migration the verifier MAY also accept JWT; the end state is at+jwt emitted and enforced on both sides, with the override deprecated and removed. It should not be a permanent supported mode, since a generic typ re-introduces exactly the gap this issue closes.
Profile split — JWT-SVID vs at+jwt
A token has a single typ header, so it cannot be simultaneously a spec-compliant JWT-SVID (typ = JWT/JOSE) and a spec-compliant RFC 9068 access token (typ = at+jwt). ZeroID tokens currently serve both roles. Getting this right means choosing the profile per token — OAuth AT for OAuth resource servers (at+jwt), JWT-SVID for SPIFFE consumers (JWT/JOSE) — which requires the issuance path to know the intended consumer. That is the same missing input as #199 (resource indicators): once a caller names the target, the AS can set both typ and aud correctly. #189 and #199 are coupled.
References
Reported by Andrii Deinega via WIMSE WG review of ZeroID.
Summary
Access-token credentials are currently signed with a JOSE header of
typ: JWT. Per RFC 9068 — JWT Profile for OAuth 2.0 Access Tokens §2.1, an OAuth 2.0 access token in JWT form SHOULD carry the explicit media typeat+jwtin itstypheader. These are OAuth 2.0 access tokens, not JWT-SVIDs, soat+jwtis the more correct value and lets resource servers distinguish ATs from other JWTs (e.g. ID tokens) by type.This is a security measure, not only a correctness one. Per the JWT BCP (RFC 8725 §3.11, reaffirmed in draft-ietf-oauth-rfc8725bis), explicit typing defends against cross-JWT confusion — a JWT minted for one purpose presented where another type is expected. The BCP is explicit that a generic
typ: JWTvalue "does not constitute effective explicit typing." (Raised by @adeinega.)Current behavior
Both signing paths set
typ: JWT:internal/service/credential.go:423–426(RS256 path)internal/service/credential.go:428–431(ES256 path)And nothing on the verify side enforces
typ:pkg/authjwtperforms notypcheck on access tokens, so even the generic value isn't validated. For contrast, DPoP proofs already do effective explicit typing —pkg/dpop/proof.go:122-128requirestyp == "dpop+jwt"exactly and rejects anything else.Proposed change
Effective explicit typing needs both halves — emit the specific type and require it:
typtoat+jwt.access_token_typ, defaultat+jwt).internal/service/credential.go.typ: JWTon issued access tokens.pkg/authjwtmust requiretyp == at+jwton access tokens and reject other values, mirroring the DPoPdpop+jwtcheck. Without this, emittingat+jwtyields the correct header but none of the anti-confusion protection — the "not effective" state the BCP warns about.The
JWToverride is a temporary migration affordance only — for a rolling upgrade where some verifiers don't yet acceptat+jwt. During migration the verifier MAY also acceptJWT; the end state isat+jwtemitted and enforced on both sides, with the override deprecated and removed. It should not be a permanent supported mode, since a generictypre-introduces exactly the gap this issue closes.Profile split — JWT-SVID vs
at+jwtA token has a single
typheader, so it cannot be simultaneously a spec-compliant JWT-SVID (typ=JWT/JOSE) and a spec-compliant RFC 9068 access token (typ=at+jwt). ZeroID tokens currently serve both roles. Getting this right means choosing the profile per token — OAuth AT for OAuth resource servers (at+jwt), JWT-SVID for SPIFFE consumers (JWT/JOSE) — which requires the issuance path to know the intended consumer. That is the same missing input as #199 (resource indicators): once a caller names the target, the AS can set bothtypandaudcorrectly. #189 and #199 are coupled.References
typheader valueat+jwttyp: JWTis not effective typing)dpop+jwtprecedent already enforced inpkg/dpopaud), OAuth access tokens: serialize single-valued aud as a string; make multi-audience a policy-gated opt-in #200 (single-stringaud)Reported by Andrii Deinega via WIMSE WG review of ZeroID.