Repository navigation
v0.2.0
jwt v0.2.0
The first release since v0.1.17, and much the largest. Every exported symbol was read against
its own documentation, and most of what follows came out of doing that.
It breaks things. Going to 0.2.0 rather than 0.1.18 is the signal: a leading zero means the
API is not stable yet, and in a 0.x line the minor bump is where a break gets announced.
Upgrading
Three changes cover most callers:
- Add an
Algas the first argument to anyEnrichcall. - Stop producing or accepting
NONEin a header. The name isnonenow, which is what
RFC 7518 section 3.6 says and what every other implementation reads. - Check anything that depended on
Expected.Audiencematching an exact list in order. It
checks membership now, per RFC 7519 section 4.1.3.
Two smaller ones: TokenPair fields are string rather than json.RawMessage, and if you
assigned Blocklist.Clock you want Blocklist.SetClock.
Security
Enrich signed whatever it was handed. It read the algorithm out of the header of the
token it was given, reused that header verbatim, spliced in that payload, and verified
nothing before re-signing with your private key. A service enriching a token it received
would mint one for any header and payload the sender chose. parseAlg resolved the unsecured
algorithm, so the sender could also ask for a token carrying no signature. Keys.EnrichToken
inherited the lot. The algorithm is the caller's now, none is refused, and both entry points
check the signature before re-signing.
A revocation for a token with no exp was discarded. InvalidateToken stored the claim's
expiry and returned nil. With no exp that value is 0, which garbage collection read as
expiring in 1970 and deleted on the next tick. You were told the session was revoked; a tick
later it was live again, and nothing reported a problem.
A typed-nil public key took the process down. A *rsa.PublicKey(nil) held in an interface
is not nil, so it passed every nil check and was dereferenced inside crypto/rsa. Any token
naming that kid crashed the process.
The JWKS fetch had no limits at all. No timeout, so an endpoint that accepted the
connection and then went quiet held the goroutine for the life of the process. No scheme
check, so keys could arrive over plain http. Redirects were followed anywhere, https to http
included. Neither the body nor the decode was bounded, and the whole remote response went
into the error string. Now: 15 second timeout, https except on loopback, five redirects each
re-checked, a 1 MB ceiling, and the body on a field instead of in the message.
Base64Decode wrote past the end of the caller's buffer. It appended = padding, and
bytes.Split does not cap the last part it returns, so the signature segment still owned the
token's whole spare capacity. With a pooled HTTP read buffer, which is how most servers hand a
token to this package, that write lands in memory the next request is about to use.
FetchAWSCognitoPublicKeys built its URL by interpolation. A region of evil.com/x moved
the request to another host, and the keys that came back were used to verify tokens.
Faster, from the same fix
Trimming the padding rather than appending it, and decoding with base64.RawURLEncoding, took
four allocations and 256 bytes off every verification. The safest version turned out to be the
quickest one.
| Before | Now | |
|---|---|---|
Verify |
22 allocs, 1632 B | 18 allocs, 1376 B |
Sign (map) |
23 allocs, 1528 B | 23 allocs, 1528 B |
Sign (struct) |
21 allocs, 1392 B | 21 allocs, 1392 B |
Enrich moved the other way, 39 allocations to 47. That is what checking a signature costs,
and it was not being checked before, so it is not a trade worth reversing.
New
Most of this came from reading what three services in production are forced to write around
the package.
KeySet,NewRemoteKeySet,NewCognitoKeySet. A key set that refreshes on a timer and
again when a token names akidit has not seen, rate limited.FetchPublicKeyswas a
single GET with no cache and no refetch, so one fleet failed withErrUnknownKiduntil
restart every time the issuer rotated.SignPair,Keys.SignPair. Linked access and refresh tokens. The hand-written version
it replaces ran to about 129 lines.FromHeader,FromQuery,FromCookie,ExtractToken. Two of the three Bearer parsers
found in the wild disagreed about a token containing a space.Skew, which tolerates clock skew onnbfas well asiat.Futureonly ever rescued
iat, and validation checksnbffirst and stops, so four call sites worked only because
their provider omitsnbf.ClassifyandErrorKind, sorting a failure into malformed, invalid, expired or
rejected, instead of enumerating fifteen sentinels by hand.TokenBlocklistandDefaultBlocklistKey. A Redis backend that re-derived the key
function returned thejtiunconditionally, so every token without one mapped to the empty
key and a single logout blocked every session in the system.Keys.Verify,Validators,RequireExpiry,Audience.Contains,Blocklist.Close,
Blocklist.SetClock, and sign options for individual claims.
Also in this release
- A fourteen chapter book, 121 pages as PDF. Chapter 12 is the honest list of this
package's sharp edges. - A brand kit and an agent skill, the second installable as a Claude Code
plugin. - A
Lintjob in CI, and benchmark charts generated from the benchmarks themselves rather
than hotlinked from a URL that had started returning 404. go.modstill has no requirements, and it is not going to get any.
Full detail, including everything not listed here, is in CHANGELOG.md.