Skip to content

Fix: cloudflared access token invalidates JWT signature by re-serializing the header#1630

Open
AdrianBannister wants to merge 1 commit intocloudflare:masterfrom
AdrianBannister:bannister-forge-fix-jwt-reserialization
Open

Fix: cloudflared access token invalidates JWT signature by re-serializing the header#1630
AdrianBannister wants to merge 1 commit intocloudflare:masterfrom
AdrianBannister:bannister-forge-fix-jwt-reserialization

Conversation

@AdrianBannister
Copy link
Copy Markdown

Summary

getTokenIfExists reads the cached JWT from disk, parses it with jose.ParseSigned(), and callers (GetAppTokenIfExists / GetOrgTokenIfExists) return token.CompactSerialize(). This re-serializes the JWT header with Go's default alphabetical key ordering (documented behavior of json.Marshal on maps), which differs from the original key order. Since the JWT signature covers the exact base64url-encoded header bytes, the re-ordered header invalidates the signature.

Root cause in go-jose

CompactSerialize delegates to compact serialization, and compactSerialize calls mustSerializeJSON(obj.Signatures[0].protected). mustSerializeJSON in turn calls json.Marshal.

The protected header is stored as rawHeader, which is a map[HeaderKey]*json.RawMessage. Go's json.Marshal sorts map keys alphabetically, so {"typ":"JWT","alg":"RS256"} becomes {"alg":"RS256","typ":"JWT"} — different base64url bytes, broken signature.

Notably, go-jose does preserve the original protected-header bytes when parsing: signature.original is assigned from parsed.Protected. And during signature verification, computeAuthData correctly checks signature.original.Protected and uses the original bytes via .base64(). But compactSerialize ignores signature.original entirely and re-marshals from the map.

Changes

  • getTokenIfExists: Returns (string, *jose.JSONWebSignature, error) instead of (*jose.JSONWebSignature, error). The raw token string (trimmed) is read from disk and returned alongside the parsed JWS (still needed for expiry checking).
  • GetAppTokenIfExists / GetOrgTokenIfExists: Return the raw string instead of calling token.CompactSerialize().

Tests

Four new tests in token/token_test.go:

Test Verifies
TestGetTokenIfExists_PreservesOriginalTokenString Raw string matches disk; CompactSerialize() produces a different (broken) string
TestGetTokenIfExists_TrimsWhitespace Leading/trailing whitespace is stripped from the raw token
TestGetTokenIfExists_FileNotFound Error path when file doesn't exist
TestGetTokenIfExists_InvalidToken Error path when file contains unparseable content

The core regression test (PreservesOriginalTokenString) crafts a JWS with non-alphabetical header key order and asserts that CompactSerialize() would produce a different string — directly demonstrating the bug.

go test ./token/... -v
--- PASS: TestGetTokenIfExists_PreservesOriginalTokenString
--- PASS: TestGetTokenIfExists_TrimsWhitespace
--- PASS: TestGetTokenIfExists_FileNotFound
--- PASS: TestGetTokenIfExists_InvalidToken
PASS

Disclaimer
I used AI assistance (Opus 4.6) to help generate and validate this change. I do not have prior Go experience, so I also manually reviewed the resulting code and tests in my limited capacity. From that review, the change looks correct to me and the test coverage appears to exercise the bug and the fix clearly.

getTokenIfExists now returns the original raw token string from disk
alongside the parsed JWS object. Callers use the raw string instead of
CompactSerialize(), which re-marshals the header with Go's alphabetical
key ordering and changes the base64url bytes the signature covers.

Co-authored-by: Forge <forge-canva@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant