Verify with jwt.ParseWithClaims, a keyfunc that returns the issuer's public key from configuration, and options: jwt.WithValidMethods([]string{"RS256"}), jwt.WithIssuer, jwt.WithAudience, jwt.WithExpirationRequired and jwt.WithLeeway(30*time.Second). Never use ParseUnverified on a bearer token, and answer every failure with 401 and WWW-Authenticate: Bearer error="invalid_token".
- Find every ParseUnverified call, every jwt.Parse or ParseWithClaims without WithValidMethods, WithIssuer and WithAudience, and every use of jwt.WithoutClaimsValidation.
- Give the keyfunc only keys from configuration: the issuer's public key, or a key looked up by kid in the configured issuer's JWKS. Never use a jwk, jku or x5u header from the token, and never return jwt.UnsafeAllowNoneSignatureType.
- Pass jwt.WithValidMethods with the issuer's algorithm only (RS256 here), and jwt.WithIssuer and jwt.WithAudience with the exact configured values. WithAudience accepts a token whose aud list includes this API.
- Add jwt.WithExpirationRequired: golang-jwt v5 checks exp only when the token has one. Keep jwt.WithLeeway to seconds of clock skew.
- Do the check in one middleware that wraps the protected routes and puts the verified claims in the request context, so no handler reads an unverified token; identify users by iss and sub together.
- Add regression tests that send real signed tokens through httptest.NewServer, built with crypto/rsa and crypto/hmac rather than with the library under test, including the nine forged and misdirected tokens in this card.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
func (Vulnerable) VerifyAccessToken(token string, cfg Config) (*jwt.RegisteredClaims, error) {
// "The API gateway checks tokens, we only need the user ID."
claims := &jwt.RegisteredClaims{}
_, _, err := jwt.NewParser().ParseUnverified(token, claims)
return claims, err
}With IMPL=vulnerable, 9 of 14 tests fail (9 exploit tests).
The fix
func (Fixed) VerifyAccessToken(token string, cfg Config) (*jwt.RegisteredClaims, error) {
claims := &jwt.RegisteredClaims{}
_, err := jwt.ParseWithClaims(token, claims,
// The key comes from configuration, never from the token. With an identity provider
// that rotates keys, look it up by kid in the issuer's JWKS instead.
func(*jwt.Token) (any, error) { return cfg.PublicKey, nil },
jwt.WithValidMethods([]string{"RS256"}), // the issuer's algorithm only: never none, never HS256
jwt.WithIssuer(cfg.Issuer), // "iss" must equal our issuer
jwt.WithAudience(cfg.Audience), // "aud" must include this API
jwt.WithExpirationRequired(), // golang-jwt checks "exp" only when present unless required
jwt.WithLeeway(30*time.Second), // seconds, not minutes, of clock skew
)
if err != nil {
return nil, err
}
if claims.Subject == "" {
return nil, errors.New(`token has no "sub"`)
}
return claims, nil
}With IMPL=fixed, 14 of 14 tests pass.
A fix that looks safe but is not
func (Wrong) VerifyAccessToken(token string, cfg Config) (*jwt.RegisteredClaims, error) {
// Signature and algorithm are checked; issuer, audience and a required "exp" are not.
claims := &jwt.RegisteredClaims{}
_, err := jwt.ParseWithClaims(token, claims,
func(*jwt.Token) (any, error) { return cfg.PublicKey, nil },
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithLeeway(30*time.Second),
)
return claims, err
}With IMPL=wrong, 3 of 14 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: every token below claims to be Alice. The API must answer 401 and reveal nothing of hers.
func expectRejected(t *testing.T, srv *httptest.Server, token string) {
t.Helper()
res, body := getAccount(t, srv, token)
if res.StatusCode != http.StatusUnauthorized {
t.Errorf("the token was accepted: HTTP %d %s", res.StatusCode, body)
}
if strings.Contains(body, "alice@example.test") {
t.Error("the response contains Alice's data")
}
}
func TestExploitAnUnsignedTokenAlgNoneIsRejected(t *testing.T) {
srv := newServer(t)
expectRejected(t, srv, unsecured(t, map[string]any{"alg": "none", "typ": "JWT"}, claimsFor("alice", nil)))
}
func TestExploitAnHS256TokenWhoseHMACSecretIsThePublicKeyIsRejected(t *testing.T) {
srv := newServer(t)
// The public key is public. A verifier that lets the token pick HS256 and hands it the
// public key bytes would check this HMAC with the very bytes the attacker used.
hs := map[string]any{"alg": "HS256", "typ": "JWT", "kid": seed.KeyID}
expectRejected(t, srv, signHS256(t, hs, claimsFor("alice", nil), issuerKeys.publicPEM))
}
func TestExploitATokenSignedWithTheAttackersOwnKeyEmbeddedInItsJWKHeaderIsRejected(t *testing.T) {
srv := newServer(t)
withJWK := maps.Clone(header)
withJWK["jwk"] = attackerKey.publicJWK
expectRejected(t, srv, signRS256(t, withJWK, claimsFor("alice", nil), attackerKey.private))
}
func TestExploitMallorysTokenWithItsPayloadChangedToAlicesIsRejected(t *testing.T) {
srv := newServer(t)
expectRejected(t, srv, withPayload(t, issue(t, claimsFor("mallory", nil)), claimsFor("alice", nil)))
}
func TestExploitATokenThatExpiredAnHourAgoIsRejected(t *testing.T) {
srv := newServer(t)
now := time.Now().Unix()
expectRejected(t, srv, issue(t, claimsFor("alice", map[string]any{"iat": now - 3900, "exp": now - 3600})))
}
func TestExploitATokenThatExpiredTwoMinutesAgoIsRejectedBeyondThe30SecondTolerance(t *testing.T) {
srv := newServer(t)
now := time.Now().Unix()
expectRejected(t, srv, issue(t, claimsFor("alice", map[string]any{"iat": now - 420, "exp": now - 120})))
}
func TestExploitATokenWithoutExpIsRejected(t *testing.T) {
srv := newServer(t)
neverExpires := claimsFor("alice", nil)
delete(neverExpires, "exp")
expectRejected(t, srv, issue(t, neverExpires))
}
func TestExploitATokenTheIdentityProviderIssuedForAnotherAPIAudIsRejected(t *testing.T) {
srv := newServer(t)
expectRejected(t, srv, issue(t, claimsFor("alice", map[string]any{"aud": seed.OtherAudience})))
}
func TestExploitATokenFromAnotherTenantThatSharesTheSigningKeyIssIsRejected(t *testing.T) {
srv := newServer(t)
expectRejected(t, srv, issue(t, claimsFor("alice", map[string]any{"iss": seed.OtherIssuer})))
}- golang-jwt v5 checks exp and nbf only when the token carries them and checks iat only with jwt.WithIssuedAt; iss and aud are checked only when WithIssuer and WithAudience are passed. A correctly signed token with no exp never expires without WithExpirationRequired.
- WithAudience(aud...) accepts any one of the listed audiences; WithAllAudiences requires all of them. Both reject a token with no aud.
- golang-jwt's HMAC method accepts only a []byte key, so a keyfunc that returns *rsa.PublicKey cannot be fooled into checking an HS256 token with the public key. A keyfunc that returns the PEM bytes, or a shared secret, for whatever alg the header names can: WithValidMethods closes both.
- jwt.Parse with jwt.MapClaims returns claims as any: aud may be a string or a list and numbers are float64. Parse into jwt.RegisteredClaims or your own struct that embeds it.
- github.com/dgrijalva/jwt-go is unmaintained and affected by CVE-2020-26160 (GO-2020-0017), an aud check bypassed by a list; golang-jwt/jwt is its maintained fork. Moving from v4 to v5 changes validation (options on the parser instead of Claims.Valid), so read the migration guide.
- Log the reason a token was rejected on the server, not in the response; the 401 body and the WWW-Authenticate header should not say whether the signature, the issuer or the expiry failed.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs jwt-validation/go-nethttp does this for you, in Docker if you do not have Go).
- Against your own API, send a correctly signed token for another audience, one from another issuer and one without exp, and expect 401 for each; then send one that expired 10 seconds ago and expect it to pass within your leeway.
- Scanners do not see this bug in golang-jwt v5 code: Semgrep's rules target the old dgrijalva import, CodeQL's query only reports ParseUnverified when no path from the same request is verified, and nothing checks issuer, audience or exp options. The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | go.jwt-go.security.audit.jwt-parse-unverified.jwt-go-parse-unverified | No findings. The rule matches ParseUnverified only in a file that imports github.com/dgrijalva/jwt-go, the unmaintained original, so it does not see github.com/golang-jwt/jwt/v5; on a copy of impl/vulnerable.go with the dgrijalva import it flagged ParseUnverified. jwt-go-none-algorithm does match the v5 import (checked on a probe) but fires only on code that names the none method, which no implementation here does. Nothing in the packs checks issuer, audience or a required exp.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image); semgrep scan --config r/<id> . |
| CodeQL | go/missing-jwt-signature-check | No results on the card. The query drops any source that also reaches a verifying parse, and here the same Authorization header reaches ParseWithClaims in fixed.go and wrong.go through the Verifier interface. On a probe with only impl/vulnerable.go it reported ParseUnverified, traced from r.Header in app.go. It has no notion of missing issuer, audience or exp checks, so wrong.go is out of its scope.Checked: CodeQL CLI 2.27.1 with codeql/go-queries 1.6.12; database built with go build ./... in the golang:1.27.2 image (query source read at github/codeql b5b8165) |
| gosec | gosec ./... (all default rules) | No findings: gosec has no rule for JWT verification.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data) |
| govulncheck | govulncheck ./... and -scan module | No vulnerabilities. GO-2025-3553 (CVE-2025-30204, excessive memory allocation while parsing headers) affects golang-jwt/jwt/v5 before 5.2.2; 5.3.1 is past it.Checked: govulncheck 1.8.0 |
| Component | Version |
|---|---|
| Language | Go |
| Runtime | Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9 |
| Framework | Go standard library 1.27.2 (net/http) with golang-jwt/jwt v5.3.1 |
| github.com/golang-jwt/jwt/v5 | 5.3.1 |
| crypto/rsa, crypto/hmac, crypto/x509 (standard library; the tests build tokens with them) | Go 1.27.2 |
| gotest.tools/gotestsum (JUnit report, a go.mod tool) | 1.13.0 |
| golang.org/x/text (raised for gotestsum) | 0.42.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the jwt-validation/go-nethttpfolder.
go mod download
go build ./... tool# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go mod download && go build ./... tool'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=fixed \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=vulnerable \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=wrong \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs jwt-validation/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop 4.73.0 (Engine 29.4.3), linux/arm64
- Toolchain
- go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
- github.com/golang-jwt/jwt/v5 5.3.1
- gotestsum 1.13.0
- semgrep 1.180.0
- gosec 2.29.0
- govulncheck 1.8.0
- codeql CLI 2.27.1, codeql/go-queries 1.6.12
- Matrix
- fixed: 14 of 14 tests pass; vulnerable: 9 of 14 tests fail (9 exploit tests); wrong: 3 of 14 tests fail (3 exploit tests)
- Test results
- fixed 14/14 pass; vulnerable 9/14 fail (all 9 exploit tests); wrong 3/14 fail (3 exploit tests: no exp, another API's aud, another tenant's iss), with --network=none after install; the same on Go 1.26.9
- Dependency freshness
- github.com/golang-jwt/jwt/v5 5.3.1 was published on 2026-01-28 and is the latest release. gotestsum 1.13.0 is from 2025-09-11; go.mod raises its golang.org/x/text from 0.17.0 to 0.42.0 (2026-09-08) for GO-2026-5970. Go 1.27.2 (2026-10-08) is under seven days old but is a security release. govulncheck 1.8.0 found no vulnerabilities.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- github.com/golang-jwt/jwt/v5 package documentation (ParseWithClaims and parser options)
- golang-jwt: migration guide (v5 validation changes)
- RFC 8725: JSON Web Token Best Current Practices
- RFC 6750: Bearer Token Usage (WWW-Authenticate error codes)
- OWASP JSON Web Token Cheat Sheet
- Go vulnerability GO-2025-3553 (golang-jwt header parsing, fixed in 5.2.2)
- CodeQL: go/missing-jwt-signature-check
- Semgrep rule: jwt-go-parse-unverified
Attack patterns: