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

JWSVerifier merges the unprotected JWS header over the protected one, letting an attacker override the integrity-checked alg and force algorithm confusion.

- Payout: Research
- Severity: high
- Program: web-token/jwt-framework
- Found by: Papadope, hostep, samuelwei
- Disclosed: 2026-06-18
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-jc38-x7x8-2xc8
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-jc38-x7x8-2xc8
- Tags: area:oss domain:oss impact:integrity kind:research platform:ghsa sev:high tech:jwt tech:php technique:source-review vuln:auth-bypass vuln:crypto

**Target:** PHP JWT library JWSVerifier/JWEDecrypter header parsing
**Root cause:** getAlgorithm() builds the complete header with `[...$protectedHeader, ...$header]`, and PHP spread gives later string keys precedence, so the unprotected header's alg wins over the protected one. HeaderCheckerManager validates the protected alg while JWSVerifier verifies with the unprotected alg (TOCTOU).
**Steps:**
1. Note JWSVerifier.php line 144: `$completeHeader = [...$signature->getProtectedHeader(), ...$signature->getHeader()];`
2. Craft a JSON Flattened JWS with `protected` = base64url(`{"alg":"RS256"}`) and `header` = `{"alg":"HS256"}`
3. HeaderCheckerManager validates RS256 from the protected header and passes
4. JWSVerifier reads HS256 from the unprotected header and verifies with the attacker-chosen algorithm
5. Alternatively omit alg from the protected header entirely so checkDuplicatedHeaderParameters never fires, or use JWSVerifier/JWSLoader without a HeaderCheckerManager
**How found:** Code review of the header merge in JWSVerifier and JWEDecrypter; noticed PHP spread/array_merge last-wins semantics put the unprotected header second, and contrasted with JWSBuilder which spreads protected last and calls checkDuplicatedHeaderParameters.
**Exploitation:** Attacker submits a JWS whose protected header advertises an accepted algorithm (e.g. RS256) while the unprotected header carries a different alg (e.g. HS256/HS512). Validation checks the protected value; verification uses the unprotected one, so with a mixed JWKSet the signature is checked against a different key type, enabling algorithm confusion/downgrade and forged tokens.
**Escalation:** Vector A: mixed-type JWKSet lets the forced algorithm match a different key in the set. Vector B: alg only in unprotected header bypasses duplicate-parameter checks. Vector C: direct JWSVerifier use or JWSLoader without HeaderCheckerManager skips checks entirely.
**Insight:** When auditing JWT/JWS libraries, check the merge order of protected vs unprotected headers and whether alg/enc are read from the integrity-protected header only; PHP spread and array_merge silently let the later array win on duplicate keys, creating a check/use split between the validator and verifier.

## Proof of concept (copied exactly from the original report)

**PHP spread merge demonstrating alg override**

```
$protected = ["alg" => "RS256", "typ" => "JWT"];
$unprotected = ["alg" => "HS256"];
$merged = [...$protected, ...$unprotected];
// $merged["alg"] === "HS256" — unprotected wins!
```

**Malicious flattened JWS with alg 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
```

