Your RSA-2048 keys break in 2030. Find every one of them before attackers do.
🐘 Packagist

GHSA-jc38-x7x8-2xc8

PHP JWT Framework: JWSVerifier uses algorithm from unprotected header, enabling algorithm confusion attacks

Published
Jun 18, 2026
Updated
Jul 6, 2026
Affected
12 pkgs
Patched
12 / 12
Exploits
None indexed

Blast Radius

12 pkgs affected
🐘web-token/jwt-library🐘web-token/jwt-library🐘web-token/jwt-library🐘web-token/jwt-bundle🐘web-token/jwt-bundle🐘web-token/jwt-bundle🐘web-token/jwt-experimental🐘web-token/jwt-experimental+4 more

Real-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.

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:

  1. HeaderCheckerManager validates alg from the protected header
  2. JWSVerifier uses alg from 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-124array_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 confusionJWSVerifier 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.

Affected Packages

12 total 12 fixed
EcosystemPackageVulnerable rangeFix
🐘Packagistweb-token/jwt-libraryall versions3.4.10
🐘Packagistweb-token/jwt-library4.0.0&&< 4.0.74.0.7
🐘Packagistweb-token/jwt-library4.1.0&&< 4.1.74.1.7
🐘Packagistweb-token/jwt-bundleall versions3.4.10
🐘Packagistweb-token/jwt-bundle4.0.0&&< 4.0.74.0.7
🐘Packagistweb-token/jwt-bundle4.1.0&&< 4.1.74.1.7

Detection & mitigation playbook

Open-source dependency
  1. Detect

    Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for web-token/jwt-library. O3's reachability analysis confirms whether the vulnerable code path is actually invoked in your application, so you act on real exposure instead of every transitive match.

  2. Fix

    Update web-token/jwt-library to 3.4.10 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-jc38-x7x8-2xc8 is resolved across your whole dependency graph.

  3. Workarounds

    If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.

  4. How O3 protects you

    O3 pinpoints whether GHSA-jc38-x7x8-2xc8 is reachable in your code and exactly where to fix it, then blocks exploitation in production at runtime until the patched version is deployed.

Tailored to GHSA-jc38-x7x8-2xc8. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.

Frequently Asked Questions

## Summary `JWSVerifier::getAlgorithm()` in `src/Library/Signature/JWSVerifier.php` (line 144) merges protected and unprotected headers using PHP's spread operator: ```php $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
O3 Security · Impact-Aware SCA

Is GHSA-jc38-x7x8-2xc8 in your dependencies?

O3 detects GHSA-jc38-x7x8-2xc8 across Packagist dependencies and uses function-level reachability to confirm whether the vulnerable code path is actually reachable — not just present. No false positives.