Skip to content

perf: parse and verify each token once - #30

Merged
bsg62 merged 2 commits into
mainfrom
perf/reduce-redundant-work
Aug 27, 2026
Merged

bsg62 merged 2 commits into
mainfrom
perf/reduce-redundant-work

Conversation

@bsg62

@bsg62 bsg62 commented Aug 27, 2026

Copy link
Copy Markdown
Member

Removes redundant work from the decode path. No user-visible behavior change: a jwtd <token> invocation is still ~0.9 ms, of which ~0.46 ms is bare Go process startup. These wins only show on unusually large tokens and payloads.

What was redundant

A --key run decoded the token's header and claims three times:

  1. jwt.ParseUnverified decoded both segments — with plain json.Unmarshal, which loses number precision and accepts trailing data, so every result was discarded
  2. parseUnverifiedJWT re-decoded both strictly
  3. verifyJWTSignature called jwt.Parse on the whole compact string again, purely to reach the cryptography

parseUnverifiedJWT now does the segment work itself and carries the signing method and decoded signature bytes forward, so verification calls SigningMethod.Verify over the segments it already has.

Security-critical detail

Verification used to get its algorithm allowlist from jwt.WithValidMethods inside jwt.Parse. Verifying from an already-parsed token makes that jwtd's own check, so it is now explicit and runs before Verify:

alg := p.method.Alg()
if methods := validMethodsForKey(key); methods != nil && !slices.Contains(methods, alg) {
    return false, fmt.Errorf("%w: signing method %v is invalid", jwt.ErrTokenSignatureInvalid, alg), nil
}

Without it, an HS256 token signed with the bytes of a published public key would verify against that key. TestVerifyJWTSignature_RejectsAlgOutsideKeyTypeAllowlist pins this across RSA, EC, Ed25519, and HMAC keys.

Other changes

  • Both text escapers open with a byte-level plain-ASCII shortcut. The scan is deliberately not bytes.ContainsFunc: rune decoding maps every invalid UTF-8 byte to RuneError, which no escape predicate matches, so a rune-level fast path would wave malformed bytes through unescaped.
  • isJWEBytes/isJWTBytes share their delimiter counts with the string forms, so printDecryptedPayload no longer copies an unbounded decrypted payload just to measure it.
  • claimTime converts whole seconds with strconv.ParseInt, keeping the exact big.Rat path for the fractional and exponent forms.
  • parsedJWT.raw dropped — removing jwt.Parse left it with no reader, and an unread struct field draws no compiler or vet diagnostic.

Numbers (36 KB / 500-claim token)

before after
parseUnverifiedJWT 346 µs / 4,133 allocs 170 µs / 2,069
verifyJWTSignature 196 µs / 2,082 allocs 19 µs / 13
full --key decode 1.15 ms / 18,057 allocs 0.74 ms / 13,858
escapeTerminalText (clean) 287 MB/s 4,053 MB/s
escapeFormattedJSONControls (clean) 664 MB/s 4,105 MB/s
formatTimestamps 2.3 µs / 76 allocs 1.3 µs / 19

Verification

Beyond the suite, vet, and gofmt, the old and new binaries were diffed directly: 555 malformed/random tokens, 108 signed-token × key × flag combinations, and 42 JWE cases across JSON/text/binary/nested/array payloads — stdout and stderr byte-identical, zero differences.

The only behavior change anywhere is the wording of four malformed-JSON errors, which now name the failing segment (parsing JWT claims: EOF rather than parsing JWT: token is malformed: could not JSON decode claim: EOF). Same rejections, same exit codes.

New tests pin the properties that removing jwt.Parse/jwt.ParseUnverified put at risk: the algorithm allowlist across every key type, the eight malformed-token rejections that used to come from the library (extra segments, missing/unknown alg, non-base64url segments), and fast-path equivalence for both escapers and the timestamp shortcut.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q26LwBBSBPUpbf1kd4Ep3z

bsg62 and others added 2 commits August 27, 2026 22:39
The decode path decoded a token's header and claims three times on a
--key run: jwt.ParseUnverified decoded both segments, parseUnverifiedJWT
threw those away and redid them strictly, and verifyJWTSignature parsed
the whole compact string again just to reach the cryptography.

parseUnverifiedJWT now does the segment work itself and carries the
signing method and decoded signature bytes forward, so verification
calls SigningMethod.Verify over the segments it already has. The
algorithm allowlist jwt.WithValidMethods used to apply is now spelled
out explicitly and runs before Verify -- without it an HS256 token
signed with a published public key would verify against that key.

Both text escapers open with a byte-level plain-ASCII shortcut, the
shape predicates gained byte forms so a decrypted payload is not copied
just to be measured, and whole-second timestamps skip the big.Rat path.

On a 36 KB token: parse 346us -> 170us, verify 196us -> 19us, full
--key decode 1.15ms -> 0.74ms, and the escapers go from 287/664 MB/s to
about 4.1 GB/s on clean text. Behaviour is unchanged: stdout and stderr
are byte-identical across signed JWT, JWE, and malformed-token cases,
and only the wording of four malformed-JSON errors differs, now naming
the failing segment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q26LwBBSBPUpbf1kd4Ep3z
Verification used to re-parse the compact string through jwt.Parse, and
that was the only thing reading the field. Nothing reads it now, and an
unread struct field draws no compiler or vet diagnostic, so it would
quietly rot out of step with the doc comment. The segments still
reassemble into the original token where a caller needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q26LwBBSBPUpbf1kd4Ep3z
@bsg62
bsg62 merged commit 83a331b into main Aug 27, 2026
8 checks passed
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