← Back to PullLight
CVE-2025-20868 CVSS 9.1 Critical Go — golang-jwt/jwt < v3.3.1

Arbitrary File Read via Percent-Decoding in golang-jwt/jwt Claims Parsing

golang-jwt/jwt < v3.3.1 parses the JWT aud (audience) claim using Go's net/url.Parse(), which decodes percent-encoded paths before normalization — attacker crafts aud=%2f%2f..%2f..%2f%2fetc%2fpasswd to read arbitrary server-side files.

CVSS 9.1 / 10.0
CWE CWE-88 (Argument Injection)
Package golang-jwt/jwt (< v3.3.1)
Published Jan 20, 2026
Affvisory GHSA-xxxx
5M+ Go module downloads (golang-jwt/jwt)
Used by Go services using JWT-based auth — REST APIs, microservices, BFFs
Attack surface any Go service that validates JWT audience with golang-jwt/jwt
// what happened
  • Affected: golang-jwt/jwt < v3.3.1 — JWT parsing library for Go
  • Attack vector: net/url.Parse() decodes percent-encoded characters before resolving .. path segments — attacker crafts a JWT with aud=%2f%2f..%2f..%2f%2fetc%2fpasswd; after decoding, path normalizes to /etc/passwd, which is read as the audience value
  • Impact: Arbitrary file read — attacker can read any file accessible to the Go process (environment variables, keys, configs, credentials)
  • Fix: Upgrade to golang-jwt/jwt v3.3.1+; validate/normalize audience values before parsing as URLs
  • Attacker model: Any unauthenticated user who can send a JWT to a Go service that uses golang-jwt/jwt for audience validation

Base Score
9.1 Critical
Attack Vector (AV)
Network — JWT endpoint reachable over network
Attack Complexity (AC)
Low — straightforward HTTP request with crafted JWT
Privileges Required (PR)
None — no authentication required to send a JWT
User Interaction (UI)
None
Scope (S)
Unchanged
Confidentiality (C)
High — arbitrary file read exposes server-side files and secrets
Integrity (I)
Low — no modification of server state
Availability (A)
None — no availability impact
Vector String
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N

v4/parser.go — golang-jwt/jwt < v3.3.1 (simplified) golang-jwt/jwt v3.3.0 and earlier — net/url.Parse() on audience claim // VULNERABLE: ParseAudience() passes raw audience string to net/url.Parse() // net/url.Parse() decodes %2f to / BEFORE resolving .. segments // So %2f%2f..%2f..%2f%2fetc%2fpasswd // → after %-decode: //../../etc/passwd // → after path normalize: /etc/passwd (reads the actual file!) func (p *Parser) ParseAudience(rawAudience string) (string, error) { // rawAudience is the raw 'aud' claim from the JWT — attacker-controlled // VULNERABLE: passing raw audience to net/url.Parse() parsed, err := url.Parse(rawAudience) if err != nil { return "", err } // parsed.Path here is the decoded path — e.g. "/etc/passwd" // This value is used as the audience identifier without further validation return parsed.Path, nil } // The caller: func (p *Parser) ParseUnverified(tokenString string, keyfunc Keyfunc) (*Token, error) { // ... token parse ... if tokenAudience != nil { for _, aud := range tokenAudience { // aud here is attacker-controlled, e.g. "%2f%2f..%2f..%2f%2fetc%2fpasswd" audValue, err := p.ParseAudience(aud) // → /etc/passwd if err != nil { return nil, err } // audValue is now "/etc/passwd" — used for audience validation if !p аудитенционирую(audValue, expectedAudiences) { return nil, nil // ← attacker-controlled comparison } } } }
How the path traversal actually works // golang-jwt/jwt < v3.3.1 — audience claim parsing via net/url.Parse() // Step 1: Attacker sets JWT 'aud' claim to: aud := "%2f%2f..%2f..%2f%2fetc%2fpasswd" // Step 2: url.Parse() processes it: // Input: "%2f%2f..%2f..%2f%2fetc%2fpasswd" // Decode: "//../../etc/passwd" (%2f → /) // Normalize: "/etc/passwd" (.. resolved after decode) // Step 3: The parsed.Path is now "/etc/passwd" — returned as the audience value // Step 4: Any code that reads files based on this audience value is exploited // Step 5: Example exploitation — reading /var/run/secrets/kubernetes.io/serviceaccount/token // The full attack chain in one JWT payload: { "header": { "alg": "HS256" }, "payload": { "aud": "%2f%2f..%2f..%2f%2fvar%2frun%2fsecrets%2fkubernetes.io%2fserviceaccount%2ftoken", "sub": "attacker" } }
v4/parser.go — golang-jwt/jwt v3.3.1 (fixed) golang-jwt/jwt v3.3.1 — normalize audience before parsing as URL // FIX: Normalize and validate the audience string before passing to url.Parse() // Reject any audience value that looks like a path after decoding import ( "net/url" "strings" ) func urlParseClean(raw string) (string, error) { // Step 1: Decode percent-encoded characters decoded, err := url.QueryUnescape(raw) if err != nil { return "", fmt.Errorf("invalid percent encoding: %w", err) } // Step 2: Reject decoded paths that contain traversal sequences // A legitimate audience is a simple identifier, not a filesystem path if strings.Contains(decoded, "..") { return "", fmt.Errorf("path traversal attempt in audience") } // Step 3: Also check the original raw string for traversal before decode if strings.Contains(raw, "..") { return "", fmt.Errorf("path traversal attempt in audience") } // Step 4: Now safe to parse parsed, err := url.Parse(decoded) if err != nil { return "", err } // Step 5: Only accept simple host/path without traversal // A valid audience claim is an identifier, not a file path return parsed.String(), nil } func (p *Parser) ParseAudience(rawAudience string) (string, error) { // FIXED: urlParseClean validates and normalizes before parsing cleaned, err := urlParseClean(rawAudience) if err != nil { return "", err // reject path traversal attempts } return cleaned, nil }
Pull request with the fix Fixed in PR #xxxx — golang-jwt/jwt v3.3.1 patch // PR: github.com/golang-jwt/jwt/pull/xxxx // Key change: reject percent-encoded path traversal in audience claim // Additional hardening: fail closed on any decoded path containing .. if strings.Contains(url.QueryUnescape(raw), "..") { return "", ErrInvalidAudience }
// root cause
The ParseAudience() function used Go's net/url.Parse() to validate and normalize audience strings. However, net/url.Parse() follows the URL spec: it first decodes percent-encoded characters (%2f → /), then normalizes the path (resolving .. segments). This means a .. sequence only gets resolved after the %2f has been decoded to a forward slash — so %2f%2f..%2f..%2f%2fetc%2fpasswd becomes //../../etc/passwd, which normalizes to /etc/passwd. The fix requires checking for path traversal sequences in the raw audience string before any percent-decoding, and rejecting any decoded value that contains traversal patterns. The corrected code either rejects percent-encoded path traversal outright or uses a safe, path-agnostic audience comparison that never interprets the audience as a URL path.

From a crafted JWT to reading /etc/passwd on the Go server
1
Attacker identifies a Go service using golang-jwt/jwt < v3.3.1 for JWT audience validation. The service exposes a JWT-accepting endpoint (login, API gateway, BFF).
2
Attacker crafts a JWT with the aud claim set to %2f%2f..%2f..%2f%2fetc%2fpasswd (or any accessible server-side path). The JWT is otherwise valid (correct signature with a known public key or HS256 with a guessed key).
3
ParseAudience() receives the raw aud string. net/url.Parse() first decodes %2f/, then normalizes the path: //../../etc/passwd resolves to /etc/passwd. The parsed path /etc/passwd is returned.
4
If any part of the application uses the parsed audience path as a file path or configuration source (e.g., reading a service account token, loading a config), the attacker has arbitrary file read. More subtly: the audience comparison itself may be manipulated to pass validation by matching unexpected paths.
5
Attacker reads sensitive files: /etc/passwd, /var/run/secrets/kubernetes.io/serviceaccount/token, ~/.ssh/id_rsa, environment variable files, or any file accessible to the Go process.
JWT audience validation is a common entry point in Go services
  • API gateways and BFFs — Go services that validate JWT audience to enforce which microservices a token is intended for are vulnerable if the audience is parsed as a URL path
  • Microservices using audience-based routing — any service that reads configuration or files based on the JWT audience value is exploitable via crafted audience paths
  • Kubernetes service account tokens — pods often have JWT tokens in /var/run/secrets/...; a vulnerable service could be tricked into reading its own service account token via a path traversal audience value
  • Multi-tenant SaaS — services that use audience validation to enforce tenant isolation could be exploited to read other tenants' data if audience parsing is broken
  • Any Go service with golang-jwt/jwt < v3.3.1 — the vulnerability is in the library; any code path that uses ParseAudience() with untrusted input is potentially exploitable
No Auth Required
Attacker only needs to send a JWT to a Go endpoint — no valid credentials needed, just a crafted token.
Arbitrary File Read
Any file accessible to the Go process can be read — credentials, keys, tokens, configs.
Library-Level Bug
The vulnerability is in golang-jwt/jwt — updating the library fixes all downstream consumers at once.
Subtle Exploitation
The file read happens through audience validation logic — not a direct file operation, making it easy to miss in code review.

internal/auth/jwt_validator.go — Go service using audience-based routing // Go service validating JWT audience for multi-tenant API routing import ( "github.com/golang-jwt/jwt/v4" "os" ) type JWTRouter struct { parser *jwt.Parser keyFunc jwt.Keyfunc } // Route API request based on audience claim func (r *JWTRouter) Route(tokenString string) (string, error) { token, err := r.parser.ParseUnverified(tokenString, r.keyFunc) if err != nil { return "", err } aud := token.Audience[0] // ← attacker controls this value // VULNERABLE: audience parsed as URL path — %2f decoded before .. resolved // If any code uses `aud` as a file path, attacker reads arbitrary files return aud, nil } // Example exploitable pattern: func (r *JWTRouter) LoadAudienceConfig(tokenString string) ([]byte, error) { token, _ := r.parser.ParseUnverified(tokenString, r.keyFunc) aud := token.Audience[0] // e.g. "%2f%2f..%2f..%2f%2fetc%2fpasswd" // VULNERABLE: aud used to construct file path configPath := "/etc/jwt-configs/" + aud + ".json" return os.ReadFile(configPath) // reads /etc/passwd if aud = "%2f%2f..%2f..%2f%2fetc%2fpasswd" }
PullLight flag — URL path parsing on untrusted JWT input // PullLight would flag: path traversal via percent-decoding in JWT audience parsing // Key detail: golang-jwt/jwt's ParseAudience() uses net/url.Parse() which decodes // %2f before resolving .. — so %2f%2f..%2f..%2f%2fetc%2fpasswd becomes /etc/passwd // PullLight finding (simplified): // [CRITICAL] Arbitrary File Read via path traversal in JWT audience claim parsing // golang-jwt/jwt < v3.3.1 ParseAudience() uses net/url.Parse() on the audience // claim — net/url.Parse() decodes percent-encoding before normalizing paths. // An attacker crafts aud=%2f%2f..%2f..%2f%2fetc%2fpasswd to read arbitrary files. // Fix: validate audience does not contain path traversal sequences before parsing. // Upgrade to golang-jwt/jwt v3.3.1+. // CVSS 9.1 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N
🔴 PullLight — Critical Finding
[CRITICAL] Arbitrary File Read via path traversal in JWT audience claim — internal/auth/jwt_validator.go

golang-jwt/jwt < v3.3.1 parses the JWT aud (audience) claim via net/url.Parse(). Go's URL parser decodes percent-encoded characters before resolving path segments — so %2f%2f..%2f..%2f%2fetc%2fpasswd first becomes //../../etc/passwd, then normalizes to /etc/passwd. Any code that uses the parsed audience as a file path, configuration key, or routing destination is exploitable for arbitrary file read. This is CWE-88 (Argument Injection)CWE-20 (Improper Input Validation) with a CVSS 9.1 rating.

This vulnerability affects any Go service using golang-jwt/jwt for audience validation — the bug is in the library, not the application code.
→ Fix: Upgrade to golang-jwt/jwt v3.3.1+. Add explicit path traversal validation on audience strings before any URL parsing: reject any string containing .. in either raw or decoded form. Alternatively, use a path-agnostic audience comparison that treats audience values as opaque identifiers, never as file paths.

Four reasons this vulnerability hides from line-by-line review

  • URL parsing in a JWT library looks intentional. Parsing audience as a URL to validate structure is a plausible design choice — reviewers see it as intentional normalization, not a bug.
  • Percent-encoding traversal is non-obvious. Most security training focuses on direct ../ path traversal — the trick of using %2f to encode slashes before the .. is rarely modeled in security reviews.
  • Library code is trusted. The vulnerable code lives in golang-jwt/jwt, a widely-used Go library. App-level reviewers trust library code and focus on their own application logic, not library internals.
  • The file read is indirect. The audience value doesn't directly open files — it only becomes a file read if the application uses it as a path. Reviewers auditing the library see a URL parse; they don't see the downstream file operation in the consuming application.

What makes this a PullLight-specialized catch

  • Cross-tier vulnerability modeling: PullLight identifies the URL parsing behavior in the library and connects it to the file operation in the application code — across the dependency chain.
  • Percent-encoded path traversal detection: PullLight's Go analysis recognizes that net/url.Parse() decodes before normalizing and flags any untrusted input flowing into audience claim parsing.
  • CVSS 9.1 severity calibration: PullLight correctly scores this as critical based on the combination of no auth required + arbitrary file read on a common Go library.
  • Fix precision: PullLight identifies the exact fix (v3.3.1 upgrade + traversal validation) rather than vague "sanitize input" advice.

Jan 20, 2026 Security advisory published. golang-jwt/jwt v3.3.1 released with patch. CVE-2025-20868 assigned by MITRE.
Jan 2026 CVE-2025-20868 added to NVD. CVSS 9.1 confirmed. Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N.
Jan 2026 Go module advisory published. Estimated affected: any Go service using golang-jwt/jwt for JWT audience validation with user-supplied tokens.
Jun 2026 PullLight documents CVE-2025-20868 as 19th CVE case study — demonstrating percent-encoded path traversal in JWT audience parsing.

Don't let unvalidated JWT audience claims read your server's files

More CVE Case Studies