GHSA-jc38-x7x8-2xc8
PHP JWT Framework: JWSVerifier uses algorithm from unprotected header, enabling algorithm confusion attacks
GHSA-jc38-x7x8-2xc8 is a high vulnerability (CVSS 8.1) in web-token/jwt-library, published June 18, 2026. No source lists public exploit code. Fixed in web-token/jwt-library 3.4.10.
- Severity
- 8.1 HighCVSS 4.0, GitHub Advisories
- Exploit chance
- UnscoredNo EPSS score yet
- Exploitation
- None knownNot on CISA’s or ENISA’s lists
- Fixed in
3.4.10web-token/jwt-library +23 more
Description
Summary
JWSVerifier::getAlgorithm() in src/Library/Signature/JWSVerifier.php (line 144) merges protected and unprotected headers using PHP's spread operator:
$completeHeader = [...$signature->getProtectedHeader(), ...$signature->getHeader()];
In PHP, when spreading arrays with duplicate string keys, the last array's values take precedence. Since the unprotected header (getHeader()) is spread second, an attacker can override the integrity-protected alg parameter by placing a different value in the unprotected header.
This creates a Time-of-Check/Time-of-Use (TOCTOU) vulnerability:
HeaderCheckerManagervalidatesalgfrom the protected headerJWSVerifierusesalgfrom the unprotected header for actual verification
The same issue exists in JWEDecrypter.php (lines 120-124) where array_merge() exhibits the same last-wins behavior for alg and enc.
Affected Code
JWSVerifier.php line 144 — Spread operator merge order allows unprotected header to override alg:
$completeHeader = [...$signature->getProtectedHeader(), ...$signature->getHeader()];
JWEDecrypter.php lines 120-124 — array_merge() with same last-wins behavior:
$completeHeader = array_merge(
$jwe->getSharedProtectedHeader(),
$jwe->getSharedHeader(),
$recipient->getHeader()
);
Attack Vectors
Vector A — Mixed key sets (HIGH probability)
If the application uses a JWKSet containing keys of different types (common in multi-tenant or federation scenarios), the JWSVerifier iterates all keys (line 86). An attacker can force a different algorithm that matches a different key in the set.
Vector B — alg ONLY in unprotected header (HIGH probability)
If alg is placed EXCLUSIVELY in the unprotected header (not in the protected header at all), HeaderCheckerManager::checkDuplicatedHeaderParameters() does NOT trigger. The JSON Flattened/General serializers allow tokens with no protected header or a protected header without alg. RFC 7515 Section 4.1.1 states alg MUST be integrity-protected, but the library does not enforce this.
Vector C — Direct JWSVerifier usage (HIGH probability)
JWSLoader takes ?HeaderCheckerManager (nullable). If developers use JWSVerifier directly or create JWSLoader without a HeaderCheckerManager, the duplicate header check never runs.
Contrast with JWSBuilder (safe)
JWSBuilder::findSignatureAlgorithm() (line 196) uses [...$header, ...$protectedHeader] where protected wins. It also has checkDuplicatedHeaderParameters() (line 218). The JWSVerifier has neither safeguard.
Proof of Concept
<?php
// Demonstrate algorithm override via unprotected header
$protected = ["alg" => "RS256", "typ" => "JWT"];
$unprotected = ["alg" => "HS256"];
$merged = [...$protected, ...$unprotected];
// $merged["alg"] === "HS256" — unprotected wins!
// JSON Flattened JWS with algorithm override:
$maliciousJws = json_encode([
'payload' => base64url_encode($payload),
'protected' => base64url_encode('{"alg":"RS256"}'),
'header' => ['alg' => 'HS256'], // OVERRIDE
'signature' => base64url_encode($sig),
]);
// HeaderCheckerManager validates RS256 from protected header -> PASS
// JWSVerifier uses HS256 from unprotected header -> attacker's algorithm choice
A full working PoC demonstrating HS512-to-HS256 downgrade with mixed keysets is available upon request.
Suggested Fix
In JWSVerifier::getAlgorithm(), read alg exclusively from the protected header:
private function getAlgorithm(Signature $signature): Algorithm
{
$protectedHeader = $signature->getProtectedHeader();
if (! isset($protectedHeader['alg'])) {
throw new InvalidArgumentException('The "alg" parameter must be in the protected header.');
}
return $this->signatureAlgorithmManager->get($protectedHeader['alg']);
}
For JWEDecrypter, reverse the merge order so protected header wins, or extract alg/enc exclusively from the protected header.
Résolution
Un correctif a été préparé sur une branche dédiée basée sur 3.4.x, avec des tests anti-régression dédiés (fork privé temporaire de cette advisory, PR #1).
JWS algorithm confusion — JWSVerifier lit le paramètre alg exclusivement dans le header protégé en intégrité (RFC 7515 §4.1.1) ; un alg placé dans le header non protégé ne peut plus surcharger l'algorithme signé.
Validation : php -l OK, PHPUnit vert, aucune nouvelle erreur PHPStan introduite (différentiel nul vs 3.4.x), aucun commentaire ajouté dans le code source. Après merge, cascade prévue 3.4.x → 4.0.x → 4.1.x.
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- High
- Privileges needed
- None
- User interaction
- None
What an attacker can affect
- Confidentiality
- High
- Integrity
- High
- Availability
- None
Affected packages
| Package | Affected | Fixed |
|---|---|---|
web-token/jwt-libraryPackagist | before 3.4.10 | 3.4.10 |
web-token/jwt-libraryPackagist | 4.0.0 up to 4.0.7 | 4.0.7 |
web-token/jwt-libraryPackagist | 4.1.0 up to 4.1.7 | 4.1.7 |
web-token/jwt-bundlePackagist | before 3.4.10 | 3.4.10 |
web-token/jwt-bundlePackagist | 4.0.0 up to 4.0.7 | 4.0.7 |
web-token/jwt-bundlePackagist | 4.1.0 up to 4.1.7 | 4.1.7 |
web-token/jwt-experimentalPackagist | before 3.4.10 | 3.4.10 |
web-token/jwt-experimentalPackagist | 4.0.0 up to 4.0.7 | 4.0.7 |
web-token/jwt-experimentalPackagist | 4.1.0 up to 4.1.7 | 4.1.7 |
web-token/jwt-frameworkPackagist | before 3.4.10 | 3.4.10 |
Show 14 moreShow less
| Package | Affected | Fixed |
|---|---|---|
web-token/jwt-frameworkPackagist | 4.0.0 up to 4.0.7 | 4.0.7 |
web-token/jwt-frameworkPackagist | 4.1.0 up to 4.1.7 | 4.1.7 |
web-token/jwt-libraryPackagist | before 3.4.10, plus listed versions 3.3.03.3.13.3.23.3.33.3.4 Show 11 moreShow less3.3.53.4.03.4.13.4.23.4.33.4.43.4.53.4.63.4.73.4.83.4.9 | 3.4.10 |
web-token/jwt-libraryPackagist | 4.0.0 up to 4.0.7, plus listed versions 4.0.14.0.24.0.34.0.44.0.54.0.6 | 4.0.7 |
web-token/jwt-libraryPackagist | 4.1.0 up to 4.1.7, plus listed versions 4.1.14.1.24.1.34.1.44.1.54.1.6 | 4.1.7 |
web-token/jwt-bundlePackagist | before 3.4.10, plus listed versions 3.3.03.3.13.3.23.3.33.3.4 Show 45 moreShow less3.3.53.4.03.4.13.4.23.4.33.4.43.4.53.4.63.4.73.4.83.4.9v0.0.0v0.0.1v0.0.2v0.1.0v0.2.0v0.2.1v0.2.2v0.2.3v0.3.0v0.3.1v0.3.2v0.4.0v0.4.1v0.5.0v0.5.1v0.5.2v1.0.0v1.0.1v1.0.2v1.0.3v1.0.4v1.0.5v1.0.6v1.1.0v1.1.0-beta1v1.1.0-beta2v1.1.0-beta3v1.1.0-beta4v1.1.1v1.1.2v1.1.3v1.1.4v1.2.0v1.2.1 | 3.4.10 |
web-token/jwt-bundlePackagist | 4.0.0 up to 4.0.7, plus listed versions 4.0.14.0.24.0.34.0.44.0.54.0.6 | 4.0.7 |
web-token/jwt-bundlePackagist | 4.1.0 up to 4.1.7, plus listed versions 4.1.14.1.24.1.34.1.44.1.54.1.6 | 4.1.7 |
web-token/jwt-experimentalPackagist | before 3.4.10, plus listed versions 3.3.03.3.13.3.23.3.33.3.4 Show 11 moreShow less3.3.53.4.03.4.13.4.23.4.33.4.43.4.53.4.63.4.73.4.83.4.9 | 3.4.10 |
web-token/jwt-experimentalPackagist | 4.0.0 up to 4.0.7, plus listed versions 4.0.14.0.24.0.34.0.44.0.54.0.6 | 4.0.7 |
web-token/jwt-experimentalPackagist | 4.1.0 up to 4.1.7, plus listed versions 4.1.14.1.24.1.34.1.44.1.54.1.6 | 4.1.7 |
web-token/jwt-frameworkPackagist | before 3.4.10, plus listed versions 3.1.03.1.13.1.23.1.33.1.4 Show 45 moreShow less3.1.53.1.63.1.73.2.03.2.13.2.103.2.23.2.33.2.43.2.53.2.63.2.73.2.83.2.93.3.03.3.0-alpha13.3.0-alpha23.3.0-alpha33.3.13.3.23.3.33.3.43.3.53.4.03.4.13.4.23.4.33.4.43.4.53.4.63.4.73.4.83.4.9v0.0.0v0.0.1v0.0.2v0.1.0v0.2.0v0.2.1v0.2.2v0.2.3v0.3.0v0.3.1v0.3.2v0.4.0 | 3.4.10 |
web-token/jwt-frameworkPackagist | 4.0.0 up to 4.0.7, plus listed versions 4.0.14.0.24.0.34.0.44.0.54.0.6 | 4.0.7 |
web-token/jwt-frameworkPackagist | 4.1.0 up to 4.1.7, plus listed versions 4.1.14.1.24.1.34.1.44.1.54.1.6 | 4.1.7 |
What each source reports
GitHub Advisories Score used 8.1
PHP JWT Framework: JWSVerifier uses algorithm from unprotected header, enabling algorithm confusion attacks
Summary
JWSVerifier::getAlgorithm() in src/Library/Signature/JWSVerifier.php (line 144) merges protected and unprotected headers using PHP's spread operator:
$completeHeader = [...$signature->getProtectedHeader(), ...$signature->getHeader()];
In PHP, when spreading arrays with duplicate string keys, the last array's values take precedence. Since the unprotected header (getHeader()) is spread second, an attacker can override the integrity-protected alg parameter by placing a different value in the unprotected header.
This creates a Time-of-Check/Time-of-Use (TOCTOU) vulnerability:
HeaderCheckerManagervalidatesalgfrom the protected headerJWSVerifierusesalgfrom the unprotected header for actual verification
The same issue exists in JWEDecrypter.php (lines 120-124) where array_merge() exhibits the same last-wins behavior for alg and enc.
Affected Code
JWSVerifier.php line 144 — Spread operator merge order allows unprotected header to override alg:
$completeHeader = [...$signature->getProtectedHeader(), ...$signature->getHeader()];
JWEDecrypter.php lines 120-124 — array_merge() with same last-wins behavior:
$completeHeader = array_merge(
$jwe->getSharedProtectedHeader(),
$jwe->getSharedHeader(),
$recipient->getHeader()
);
Attack Vectors
Vector A — Mixed key sets (HIGH probability)
If the application uses a JWKSet containing keys of different types (common in multi-tenant or federation scenarios), the JWSVerifier iterates all keys (line 86). An attacker can force a different algorithm that matches a different key in the set.
Vector B — alg ONLY in unprotected header (HIGH probability)
If alg is placed EXCLUSIVELY in the unprotected header (not in the protected header at all), HeaderCheckerManager::checkDuplicatedHeaderParameters() does NOT trigger. The JSON Flattened/General serializers allow tokens with no protected header or a protected header without alg. RFC 7515 Section 4.1.1 states alg MUST be integrity-protected, but the library does not enforce this.
Vector C — Direct JWSVerifier usage (HIGH probability)
JWSLoader takes ?HeaderCheckerManager (nullable). If developers use JWSVerifier directly or create JWSLoader without a HeaderCheckerManager, the duplicate header check never runs.
Contrast with JWSBuilder (safe)
JWSBuilder::findSignatureAlgorithm() (line 196) uses [...$header, ...$protectedHeader] where protected wins. It also has checkDuplicatedHeaderParameters() (line 218). The JWSVerifier has neither safeguard.
Proof of Concept
<?php
// Demonstrate algorithm override via unprotected header
$protected = ["alg" => "RS256", "typ" => "JWT"];
$unprotected = ["alg" => "HS256"];
$merged = [...$protected, ...$unprotected];
// $merged["alg"] === "HS256" — unprotected wins!
// JSON Flattened JWS with algorithm override:
$maliciousJws = json_encode([
'payload' => base64url_encode($payload),
'protected' => base64url_encode('{"alg":"RS256"}'),
'header' => ['alg' => 'HS256'], // OVERRIDE
'signature' => base64url_encode($sig),
]);
// HeaderCheckerManager validates RS256 from protected header -> PASS
// JWSVerifier uses HS256 from unprotected header -> attacker's algorithm choice
A full working PoC demonstrating HS512-to-HS256 downgrade with mixed keysets is available upon request.
Suggested Fix
In JWSVerifier::getAlgorithm(), read alg exclusively from the protected header:
private function getAlgorithm(Signature $signature): Algorithm
{
$protectedHeader = $signature->getProtectedHeader();
if (! isset($protectedHeader['alg'])) {
throw new InvalidArgumentException('The "alg" parameter must be in the protected header.');
}
return $this->signatureAlgorithmManager->get($protectedHeader['alg']);
}
For JWEDecrypter, reverse the merge order so protected header wins, or extract…
- Record
GHSA-jc38-x7x8-2xc8- CVSS 4.0
- 8.1 High
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:P - Weakness
- CWE-345
- Updated
- 2026-11-02
OSV 8.1
PHP JWT Framework: JWSVerifier uses algorithm from unprotected header, enabling algorithm confusion attacks
Summary
JWSVerifier::getAlgorithm() in src/Library/Signature/JWSVerifier.php (line 144) merges protected and unprotected headers using PHP's spread operator:
$completeHeader = [...$signature->getProtectedHeader(), ...$signature->getHeader()];
In PHP, when spreading arrays with duplicate string keys, the last array's values take precedence. Since the unprotected header (getHeader()) is spread second, an attacker can override the integrity-protected alg parameter by placing a different value in the unprotected header.
This creates a Time-of-Check/Time-of-Use (TOCTOU) vulnerability:
HeaderCheckerManagervalidatesalgfrom the protected headerJWSVerifierusesalgfrom the unprotected header for actual verification
The same issue exists in JWEDecrypter.php (lines 120-124) where array_merge() exhibits the same last-wins behavior for alg and enc.
Affected Code
JWSVerifier.php line 144 — Spread operator merge order allows unprotected header to override alg:
$completeHeader = [...$signature->getProtectedHeader(), ...$signature->getHeader()];
JWEDecrypter.php lines 120-124 — array_merge() with same last-wins behavior:
$completeHeader = array_merge(
$jwe->getSharedProtectedHeader(),
$jwe->getSharedHeader(),
$recipient->getHeader()
);
Attack Vectors
Vector A — Mixed key sets (HIGH probability)
If the application uses a JWKSet containing keys of different types (common in multi-tenant or federation scenarios), the JWSVerifier iterates all keys (line 86). An attacker can force a different algorithm that matches a different key in the set.
Vector B — alg ONLY in unprotected header (HIGH probability)
If alg is placed EXCLUSIVELY in the unprotected header (not in the protected header at all), HeaderCheckerManager::checkDuplicatedHeaderParameters() does NOT trigger. The JSON Flattened/General serializers allow tokens with no protected header or a protected header without alg. RFC 7515 Section 4.1.1 states alg MUST be integrity-protected, but the library does not enforce this.
Vector C — Direct JWSVerifier usage (HIGH probability)
JWSLoader takes ?HeaderCheckerManager (nullable). If developers use JWSVerifier directly or create JWSLoader without a HeaderCheckerManager, the duplicate header check never runs.
Contrast with JWSBuilder (safe)
JWSBuilder::findSignatureAlgorithm() (line 196) uses [...$header, ...$protectedHeader] where protected wins. It also has checkDuplicatedHeaderParameters() (line 218). The JWSVerifier has neither safeguard.
Proof of Concept
<?php
// Demonstrate algorithm override via unprotected header
$protected = ["alg" => "RS256", "typ" => "JWT"];
$unprotected = ["alg" => "HS256"];
$merged = [...$protected, ...$unprotected];
// $merged["alg"] === "HS256" — unprotected wins!
// JSON Flattened JWS with algorithm override:
$maliciousJws = json_encode([
'payload' => base64url_encode($payload),
'protected' => base64url_encode('{"alg":"RS256"}'),
'header' => ['alg' => 'HS256'], // OVERRIDE
'signature' => base64url_encode($sig),
]);
// HeaderCheckerManager validates RS256 from protected header -> PASS
// JWSVerifier uses HS256 from unprotected header -> attacker's algorithm choice
A full working PoC demonstrating HS512-to-HS256 downgrade with mixed keysets is available upon request.
Suggested Fix
In JWSVerifier::getAlgorithm(), read alg exclusively from the protected header:
private function getAlgorithm(Signature $signature): Algorithm
{
$protectedHeader = $signature->getProtectedHeader();
if (! isset($protectedHeader['alg'])) {
throw new InvalidArgumentException('The "alg" parameter must be in the protected header.');
}
return $this->signatureAlgorithmManager->get($protectedHeader['alg']);
}
For JWEDecrypter, reverse the merge order so protected header wins, or extract…
- Record
GHSA-jc38-x7x8-2xc8- CVSS 4.0
- 8.1 High
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N/E:P - Weakness
- CWE-345
- Updated
- 2026-11-02
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | GitHub Advisories, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
References
Left out here: 141 versions and the rest of 2 source descriptions. The JSON record has them.