Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix JWT validation in Go and net/http

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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".

Stack
  • 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
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks6 cards
Download the project ZIP, 23 KB
On this page

Steps

  1. Find every ParseUnverified call, every jwt.Parse or ParseWithClaims without WithValidMethods, WithIssuer and WithAudience, and every use of jwt.WithoutClaimsValidation.
  2. 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.
  3. 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.
  4. Add jwt.WithExpirationRequired: golang-jwt v5 checks exp only when the token has one. Keep jwt.WithLeeway to seconds of clock skew.
  5. 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.
  6. 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.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.goGo
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

Fixedimpl/fixed.goGo
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

Wrong fiximpl/wrong.goGo
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 regression test

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.

Testapp_test.goGo
// 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})))
}

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix JWT validation in Go and net/http
ToolRuleWhat it found here
Semgrepgo.jwt-go.security.audit.jwt-parse-unverified.jwt-go-parse-unverifiedNo 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> .
CodeQLgo/missing-jwt-signature-checkNo 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)
gosecgosec ./... (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)
govulncheckgovulncheck ./... and -scan moduleNo 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

Libraries and versions

Libraries and versions
ComponentVersion
LanguageGo
RuntimeGo 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9
FrameworkGo standard library 1.27.2 (net/http) with golang-jwt/jwt v5.3.1
github.com/golang-jwt/jwt/v55.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

Run the tests

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.

Shell
go mod download
go build ./... tool
Shell
# 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.

Shell
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'
Shell
# 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.

Download the project ZIP, 23 KB

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

  1. github.com/golang-jwt/jwt/v5 package documentation (ParseWithClaims and parser options)
  2. golang-jwt: migration guide (v5 validation changes)
  3. RFC 8725: JSON Web Token Best Current Practices
  4. RFC 6750: Bearer Token Usage (WWW-Authenticate error codes)
  5. OWASP JSON Web Token Cheat Sheet
  6. Go vulnerability GO-2025-3553 (golang-jwt header parsing, fixed in 5.2.2)
  7. CodeQL: go/missing-jwt-signature-check
  8. Semgrep rule: jwt-go-parse-unverified

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.