Skip to content

OAuth access tokens: use RFC 9068 at+jwt typ header (config-overridable) #189

@rsharath

Description

@rsharath

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:

  1. 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.
  2. Verify sidepkg/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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestspec-complianceDeviation from SPIFFE/WIMSE/JWT-SVID specs

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions