Skip to content

CVE-2026-39999: Verified Repro With Script Download

CVE-2026-39999: Apache APISIX jwt-auth authentication bypass via algorithm confusion

CVE-2026-39999 is verified against apache/apisix · github. Affected versions: 2.2.0 through 3.16.0. Fixed in 3.17.0. Vulnerability class: Auth Bypass. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00287.

REPRO-2026-00287 apache/apisix · github Auth Bypass Variant found Jul 14, 2026 CVE entry ↗ .txt
Severity
CRITICAL
CVSS
9.1
Confidence
HIGH
Reproduced in
14m 33s
Tool calls
183
Spend
$2.62
01 · Overview

What Is CVE-2026-39999?

CVE-2026-39999 is a critical authentication bypass (CWE-290) in Apache APISIX's jwt-auth plugin caused by JWT algorithm confusion. An attacker who knows a consumer's RSA public key can forge a valid token and bypass authentication entirely. Pruva reproduced it (reproduction REPRO-2026-00287).

02 · Severity & CVSS

CVE-2026-39999 Severity & CVSS Score

CVE-2026-39999 is rated critical severity, with a CVSS base score of 9.1 out of 10.

CRITICAL threat level
9.1 / 10 CVSS base
Weakness CWE-290 Authentication Bypass by Spoofing — Authentication Bypass by Spoofing

Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.

03 · Affected Versions

Affected apache/apisix Versions

apache/apisix · github versions 2.2.0 through 3.16.0 are affected.

How to Reproduce CVE-2026-39999

$ pruva-verify REPRO-2026-00287
or curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00287/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh
Run in a VM or disposable container. This exploits a real vulnerability.
06 · Proof of Reproduction

Proof of Reproduction for CVE-2026-39999

Authorization bypass — reproduced
  • reached the target end-to-end
  • full exploit chain demonstrated
  • on the real production code path
  • high confidence
  • the upstream fix blocks the same trigger
Trigger

JWT header alg value set to HS256 and HMAC-SHA256 signature computed using the consumer's RSA public key (PEM) as the HMAC secret

Attack chain
  1. HTTP GET / to APISIX gateway (port 9080) with Authorization: Bearer <forged HS256 JWT> against a route protected by the jwt-auth plugin with an RS256 consumer configuration
Variants tested

Bypass/variant probe for CVE-2026-39999 (Apache APISIX jwt-auth JWT algorithm confusion). Tested whether the 3.17.0 alg-match fix can be bypassed, and whether the same confusion reaches consumers configured with asymmetric algorithms other than RS256 (ES256/ECDSA, EdDSA/Ed25519). Result: NO BYPASS. The fix rejects for…

How the agent worked 443 events · 183 tool calls · 15 min
15 minDuration
183Tool calls
121Reasoning steps
443Events
7Dead-ends
Agent activity over 15 min
Support
21
Repro
218
Judge
20
Variant
180
0:0014:33

Root Cause and Exploit Chain for CVE-2026-39999

Versions: Apache APISIX 2.2.0 through 3.16.0 (all versions that supportedFixed: in: 3.17.0 (commit e4de423fc583fdf82f47a1dda6c666aa2f2b3dca —

Apache APISIX's jwt-auth plugin selected the JWT signature verification primitive from the attacker-controlled JWT header alg field, while independently selecting the verification key from trusted consumer configuration. When a consumer was configured with an RS256 (asymmetric) algorithm, the plugin used the consumer's RSA public key as the verification key. An attacker who knew that public key could forge a JWT whose header declared alg=HS256 and whose HMAC-SHA256 signature was computed over the token using the public key PEM as the HMAC secret. The plugin then verified the forged token with HMAC-SHA256 — using the same public key as the secret — and accepted it, bypassing authentication entirely. This is the classic JWT algorithm confusion attack (CVE-2015-9235 pattern) realized in APISIX's jwt-auth verifier.

  • Package/component affected: apisix/plugins/jwt-auth (the jwt-auth authentication plugin), specifically the find_consumer()get_auth_secret()verify_signature() path in apisix/plugins/jwt-auth.lua and apisix/plugins/jwt-auth/parser.lua.
  • Affected versions: Apache APISIX 2.2.0 through 3.16.0 (all versions that supported asymmetric algorithms in jwt-auth and lacked the algorithm-match enforcement).
  • Fixed in: 3.17.0 (commit e4de423fc583fdf82f47a1dda6c666aa2f2b3dca — "fix(jwt-auth): enforce algorithm match before signature verification (#13182)").
  • Risk level: Critical. An unauthenticated remote attacker who can obtain a consumer's configured public key (which is, by definition, public) can forge a valid-looking JWT and access any route protected by that consumer's jwt-auth configuration. No private key material is required.

Impact Parity

  • Disclosed/claimed maximum impact: Authentication bypass / authorization bypass (authz_bypass) on remote API requests authenticated via jwt-auth with an RS256-style consumer.
  • Reproduced impact from this run: Full authentication bypass demonstrated end-to-end against a running Apache APISIX 3.16.0 gateway. A forged HS256 JWT (signed with the consumer's RSA public key as the HMAC secret) was accepted by the protected route (HTTP 200, request proxied to upstream), while the identical forged token was rejected by the fixed 3.17.0 gateway (HTTP 401, "failed to verify jwt: algorithm mismatch, expected RS256").
  • Parity: full. The remote API authentication bypass described in the advisory was reproduced exactly, and the negative control on the fixed version confirms the fix closes the bypass.
  • Not demonstrated: N/A — the claim is authorization bypass, not code execution, and the bypass itself was demonstrated in full.

Root Cause

In the vulnerable find_consumer() function (apisix/plugins/jwt-auth.lua, 3.16.0):

  1. The JWT is parsed and the consumer is located by the key claim in the payload (consumer_mod.find_consumer(plugin_name, "key", user_key)).
  2. get_auth_secret(consumer) returns the verification key based on the consumer's configured algorithm:
    local function get_auth_secret(consumer)
        if not consumer.auth_conf.algorithm or
           consumer.auth_conf.algorithm:sub(1, 2) == "HS" then
            return get_secret(consumer.auth_conf)     -- HMAC secret
        else
            return consumer.auth_conf.public_key      -- RSA public key (for RS256)
        end
    end
    
    For an RS256 consumer, the returned key is the RSA public key.
  3. jwt:verify_signature(auth_secret) in parser.lua selects the verification primitive from self.header.alg — the JWT header, which is attacker-controlled:
    function _M.verify_signature(self, key)
        return alg_verify[self.header.alg](self.raw_header .. "." ..
                   self.raw_payload, base64_decode(self.signature), key)
    end
    
  4. There was no check that self.header.alg matched consumer.auth_conf.algorithm. So when the attacker sets alg=HS256, alg_verify["HS256"](data, signature, public_key) is invoked, which computes HMAC-SHA256(data, public_key) and compares it to the attacker-supplied signature. Because the public key is public, the attacker can compute the identical HMAC and produce a matching signature → authentication bypass.

The fix (commit e4de423f, released in 3.17.0) inserts an algorithm-match enforcement before verify_signature:

-- Enforce that the JWT header's "alg" matches the consumer's configured algorithm
local expected_alg = consumer.auth_conf.algorithm or "HS256"
local token_alg = jwt.header and jwt.header.alg
if token_alg ~= expected_alg then
    local err = "failed to verify jwt: algorithm mismatch, expected " .. expected_alg
    ...
    return nil, nil, "failed to verify jwt"
end

This rejects any token whose header alg differs from the consumer's configured algorithm (e.g. an HS256 token against an RS256 consumer) before signature verification, closing the confusion.

Reproduction Steps

  1. Script: bundle/repro/reproduction_steps.sh (self-contained, idempotent).
  2. What it does:
    • Generates a fresh RSA-2048 keypair with openssl.
    • Deploys the real Apache APISIX gateway inside Docker containers on an isolated bridge network: bitnamilegacy/etcd:3.6 (config store), python:3-slim (upstream HTTP backend + client helper), and apache/apisix:3.16.0-debian (vulnerable) then apache/apisix:3.17.0-debian (fixed).
    • Uses the APISIX Admin API to create a consumer (jack) with the jwt-auth plugin configured for RS256 using the generated RSA public key, and a route (/) protected by jwt-auth with the upstream backend.
    • Verifies the route is genuinely protected: a request without a JWT returns HTTP 401.
    • Forges a JWT with header {"typ":"JWT","alg":"HS256"} and payload {"key":"jack-key"}, signing the header.payload input with HMAC-SHA256(..., RSA_public_key_PEM) — using the consumer's public key as the HMAC secret.
    • Sends the forged token to the protected route via Authorization: Bearer <jwt>.
    • On the vulnerable 3.16.0 gateway: the request is accepted (HTTP 200, proxied to upstream) → authentication bypass.
    • On the fixed 3.17.0 gateway (fresh etcd, same consumer/route): the identical forged token is rejected (HTTP 401, "failed to verify jwt") with the log algorithm mismatch, expected RS256 → fix confirmed.
  3. Expected evidence: Two consecutive runs both show vulnerable→200 (bypass) and fixed→401 (reject); APISIX error logs on 3.17.0 contain the jwt-auth.lua:338: ... algorithm mismatch, expected RS256 line.

Evidence

  • bundle/logs/harness_vulnerable.log — exploit harness output on 3.16.0 (forged HS256 JWT → HTTP 200, [+] VULNERABLE: forged HS256 JWT accepted -> authentication BYPASS).
  • bundle/logs/harness_fixed.log — exploit harness output on 3.17.0 (forged HS256 JWT → HTTP 401, [+] FIXED: forged HS256 JWT rejected).
  • bundle/logs/apisix_fixed.log — APISIX 3.17.0 nginx error log containing: [lua] jwt-auth.lua:338: find_consumer(): failed to verify jwt: algorithm mismatch, expected RS256.
  • bundle/artifacts/results_vulnerable.json{"forged_jwt_code": 200, "no_jwt_code": 401, ...}.
  • bundle/artifacts/results_fixed.json{"forged_jwt_code": 401, "no_jwt_code": 401, "forged_jwt_body": "{\"message\":\"failed to verify jwt\"}"}.
  • bundle/artifacts/rsa_public.pem / rsa_private.pem — the RSA keypair used.
  • bundle/artifacts/apisix_config.yaml — the APISIX deployment config (etcd + admin key).
  • bundle/repro/runtime_manifest.json — structured runtime evidence manifest.

Key excerpt (vulnerable, 3.16.0):

[*] no-jwt: 401 (expect 401)
[*] forged HS256 JWT: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkiOiJqYWNrLWtleSJ9...
[*] forged-jwt: 200  body=<!DOCTYPE HTML>...
[+] VULNERABLE: forged HS256 JWT accepted -> authentication BYPASS

Key excerpt (fixed, 3.17.0):

[*] forged-jwt: 401  body={"message":"failed to verify jwt"}
[+] FIXED: forged HS256 JWT rejected (401) -> algorithm mismatch enforced

Environment: Docker (Apache APISIX official images), isolated bridge network, OpenResty/LuaJIT runtime inside the apache/apisix:*-debian images; APISIX versions verified via apisix version (3.16.0 / 3.17.0).

Recommendations / Next Steps

  • Upgrade to Apache APISIX 3.17.0 or later, which enforces that the JWT header alg matches the consumer's configured algorithm before signature verification.
  • Defense in depth: Consider also key-type/algorithm binding at the verifier primitive level (e.g. refuse to use an asymmetric key material with an HMAC primitive, and vice versa), so that a single missing upper-layer check cannot reintroduce the confusion.
  • Audit existing jwt-auth consumers: any consumer configured with an asymmetric algorithm (RS*/ES*/PS*/EdDSA) whose public key is exposed was exploitable prior to the fix; rotate keys and review access logs for forged-token usage.
  • Testing: Add a regression test that submits an HS256-forged token against an RS256 consumer and asserts a 401 (the fix commit e4de423f already added such tests in t/plugin/jwt-auth.t).

Additional Notes

  • Idempotency: The script cleans up all containers/networks on exit and was run twice consecutively with identical results (vulnerable→200, fixed→401) — reproducibility confirmed.
  • The forged JWT contains only the key claim (matching the consumer) and omits exp/nbf; on 3.16.0 verify_claims only validates those claims when present, so the token passes claim validation. This keeps the proof focused on the algorithm confusion and avoids confounding it with expiry behavior.
  • The attack requires only the consumer's public key (which is public by design for RS256); no private key, no secrets, and no prior authentication are needed.
  • Networking note: the sandbox runs Docker-in-Docker where host port publishing is not directly reachable from the shell; the script therefore drives all HTTP traffic through a python:3-slim client container on the same bridge network, addressing the APISIX gateway by its container alias.

Variant Analysis & Alternative Triggers for CVE-2026-39999

Versions: versions (as tested): Apache APISIX 3.16.0 (vulnerable — confirmed

No bypass of the 3.17.0 fix was found, and no materially-distinct entry point to the vulnerable sink exists. The fix (commit e4de423f, "enforce algorithm match before signature verification") inserts a single guard in find_consumer that compares the JWT header alg to the consumer's configured algorithm before verify_signature. Because that check and verify_signature both read the same self.header.alg field, no token can simultaneously pass the check and dispatch to a mismatched (HMAC) primitive. The guard sits on the only path to the sink (verify_signature is called only from find_consumer), and is generic over all supported consumer algorithms. Runtime testing confirmed that the same forged-HS256 attack that bypasses RS256 consumers on 3.16.0 also bypasses ES256 (ECDSA) and EdDSA (Ed25519) consumers on 3.16.0 (alternate triggers, same root cause), and that the fix rejects all three families on 3.17.0 with explicit algorithm mismatch, expected <alg> log lines. These alternate triggers share the identical sink, entry point and trust boundary as the original CVE and are covered by the generic fix — they are not distinct variants under the strict definition and do not constitute a bypass.

Fix Coverage / Assumptions

Invariant the fix relies on: the JWT header field compared in the new guard (jwt.header.alg) is the same field that parser.lua's verify_signature dispatches on (alg_verify[self.header.alg]). Both are populated once by resty.jwt's load_jwt/parse_jwt as cjson.decode(base64url_decode(raw_header)).alg with no normalization, so the compared value and the dispatched value can never diverge.

Code paths explicitly covered: find_consumer is the sole caller of jwt:verify_signature (verified by source-wide search), and find_consumer is called only from _M.rewrite. The guard therefore covers every runtime path: header / query-string / cookie token delivery, standalone jwt-auth, jwt-auth under multi-auth, and all consumer algorithms in the schema enum (HS*, RS*, ES*, PS*, EdDSA) — because the check compares the generic consumer.auth_conf.algorithm string rather than a hardcoded value.

What the fix does NOT cover: no gap was found. Candidate bypass paths were evaluated and ruled out: (a) parsing quirks — load_jwt does no alg normalization/whitelist, but the check and dispatch share the same field so no value can pass the check and trigger HMAC; (b) an alternate entry point to the sink — none exists (verify_signature has one caller); (c) key-selection divergence — get_auth_secret keys off the consumer's algorithm, which the fix forces equal to the token's; (d) anonymous-consumer / multi-auth fallback — by design, grants only the configured anonymous privileges (same as no token). The only residual item is a defense-in-depth recommendation (bind key type to primitive type inside alg_verify), not an exploitable gap. See bundle/vuln_variant/patch_analysis.md for the full analysis.

Variant / Alternate Trigger

Bypass attempted: forge an HS256 JWT and submit it against a consumer configured with an asymmetric algorithm other than RS256 — specifically ES256 (ECDSA / prime256v1) and EdDSA (Ed25519) — signing the token with that consumer's public key PEM as the HMAC secret. If the fix only handled RS256 (or hardcoded an RS256 expectation), these would slip through on 3.17.0.

Entry point (identical for all candidates): HTTP GET / to the APISIX gateway (port 9080) with Authorization: Bearer <forged JWT>, against a route protected by the jwt-auth plugin. The forged token's payload key claim selects the targeted consumer (and thus the public key used as the HMAC secret).

Code path involved (same for every candidate):

  • apisix/plugins/jwt-auth.luafind_consumer: fetch_jwt_tokenjwt_parser.newconsumer_mod.find_consumer("key", …)get_auth_secret (returns consumer.auth_conf.public_key for any non-HS algorithm) → [fix guard] jwt.header.alg ~= consumer.auth_conf.algorithmjwt:verify_signature(auth_secret).
  • apisix/plugins/jwt-auth/parser.lua_M.verify_signature: alg_verify[self.header.alg](raw_header.."."..raw_payload, sig, key). For the forged token self.header.alg == "HS256", so alg_verify["HS256"] computes HMAC-SHA256(data, public_key_PEM) and compares it to the attacker-supplied signature.

Tested variants (all same root cause / sink / entry point / trust boundary):

  1. RS256 consumer + HS256 forged token — original CVE (control). 3.16.0: 200; 3.17.0: 401.
  2. ES256 consumer + HS256 forged token — EC public key as HMAC secret. 3.16.0: 200; 3.17.0: 401.
  3. EdDSA consumer + HS256 forged token — Ed25519 public key as HMAC secret. 3.16.0: 200; 3.17.0: 401.

Candidates #2 and #3 are alternate triggers (they prove the confusion is not RS256-specific) but are not distinct variants: the entry point, sink (alg_verify["HS256"] with the consumer public key), trust boundary (remote untrusted caller → authenticated route) and technique (forge HS256, sign with the public key PEM) are identical to the original CVE; only the consumer's configured algorithm and key material differ. Per the variant guidance, relabeling the same sink via a different consumer configuration is the same bug, not a new variant. Critically, none reproduce on the fixed version, so there is no bypass.

  • Package/component affected: apisix/plugins/jwt-auth (find_consumer, get_auth_secret) and apisix/plugins/jwt-auth/parser.lua (verify_signature).
  • Affected versions (as tested): Apache APISIX 3.16.0 (vulnerable — confirmed bypass for RS256, ES256, EdDSA); Apache APISIX 3.17.0 (fixed — all rejected). Advisory scope: 2.2.0–3.16.0; fixed in 3.17.0.
  • Risk level: Critical for the vulnerable version (unauthenticated remote authentication bypass). For the variant stage's bypass question: none — the fix holds, so there is no residual risk from the tested paths on 3.17.0.

Impact Parity

  • Disclosed/claimed maximum impact (parent CVE): authentication/authorization bypass (authz_bypass) on remote API requests authenticated via jwt-auth with an asymmetric consumer config.
  • Reproduced impact from this variant run: On 3.16.0 the alternate triggers (ES256, EdDSA) yield the same full auth bypass (HTTP 200, request proxied to upstream) as the original RS256 bypass — confirming the vulnerability's breadth across asymmetric key families. On 3.17.0 all forged tokens are rejected (401).
  • Parity (bypass question): none — no bypass reproduced on the fixed version.
  • Parity (alternate-trigger question on vulnerable): full — the same authz_bypass impact as the parent is reproduced for ES256 and EdDSA consumers on the vulnerable version.
  • Not demonstrated: N/A — the claim is authorization bypass, not code execution; the (non-)bypass and the alternate triggers were both demonstrated directly.

Root Cause

The underlying bug: jwt-auth selects the verification key from trusted consumer configuration (get_auth_secret → consumer's public_key for asymmetric algorithms) while selecting the verification primitive from the attacker-controlled JWT header alg (alg_verify[self.header.alg]). When the two disagree (token alg=HS256, consumer algorithm=RS256/ES256/EdDSA), the consumer's public key is fed to HMAC-SHA256 as the secret. Because the public key is public, the attacker can compute the identical HMAC and forge a valid token.

The fix enforces jwt.header.alg == consumer.auth_conf.algorithm before verify_signature. Since the same self.header.alg is both checked and dispatched, equality forces a consistent key/primitive pairing, eliminating the confusion. This is correct and complete: there is no value of header.alg that both satisfies the equality and selects HMAC against an asymmetric key. The same logic is why the ES256 and EdDSA alternate triggers — which rely on the same alg_verify["HS256"] dispatch with a different public key — are also blocked.

Fix commit: e4de423fc583fdf82f47a1dda6c666aa2f2b3dca (PR #13182, in 3.17.0).

Reproduction Steps

  1. Script: bundle/vuln_variant/reproduction_steps.sh (self-contained, idempotent, Docker-based).
  2. What it does:
    • Generates three keypairs with openssl: RSA-2048 (RS256), EC prime256v1 (ES256), Ed25519 (EdDSA).
    • Deploys the real Apache APISIX gateway (3.16.0 then 3.17.0) in Docker on an isolated bridge network with etcd + a python http.server upstream + a client helper.
    • Via the Admin API, creates three consumers (jack-rs RS256, jack-ec ES256, jack-ed EdDSA), each with a distinct key claim and its respective public key, plus one route / protected by jwt-auth (the consumer is selected by the token's key claim).
    • Verifies the route is protected (no JWT → 401 on both versions).
    • For each consumer, forges an HS256 JWT ({"typ":"JWT","alg":"HS256"} / payload {"key":"<that consumer's key>"}) signed with HMAC-SHA256(signing_input, <consumer public key PEM>) and sends it to /.
    • On 3.16.0: asserts each forged token is accepted (HTTP 200 = bypass).
    • On 3.17.0 (fresh etcd, same config): asserts each forged token is rejected (HTTP 401).
    • Exit 0 = a forged token accepted on the fixed 3.17.0 (true bypass); exit 1 = no bypass (alternate triggers reproduced on vulnerable only).
  3. Expected evidence: vulnerable harness shows RS256/ES256/EdDSA all 200 [BYPASS]; fixed harness shows all three 401 [REJECTED]; fixed APISIX error log contains algorithm mismatch, expected RS256, expected ES256, and expected EdDSA at jwt-auth.lua:338. Script exits 1 (no bypass).

Evidence

  • bundle/logs/variant_harness_vulnerable.log — 3.16.0: RS256/ES256/EdDSA forged HS256 → 200 [BYPASS] each; [+] VULNERABLE: all three asymmetric consumers bypassed via HS256 confusion.
  • bundle/logs/variant_harness_fixed.log — 3.17.0: RS256/ES256/EdDSA forged HS256 → 401 [REJECTED] each; [+] FIXED: all forged HS256 tokens rejected (401) -> fix covers RS256/ES256/EdDSA.
  • bundle/logs/variant_apisix_fixed.log — APISIX 3.17.0 nginx error log with the three definitive lines (all from jwt-auth.lua:338):
    • failed to verify jwt: algorithm mismatch, expected RS256
    • failed to verify jwt: algorithm mismatch, expected ES256
    • failed to verify jwt: algorithm mismatch, expected EdDSA
  • bundle/logs/variant_apisix_vulnerable.log — 3.16.0 startup/plugin-load log (no alg-mismatch lines — the guard does not exist in 3.16.0).
  • bundle/vuln_variant/artifacts/variant_results_vulnerable.json — per-alg forged_jwt_code: 200, bypass: true for RS256/ES256/EdDSA.
  • bundle/vuln_variant/artifacts/variant_results_fixed.json — per-alg forged_jwt_code: 401, bypass: false for RS256/ES256/EdDSA (with forged token strings).
  • bundle/vuln_variant/artifacts/{rsa,ec,ed}_public.pem — the public keys used as HMAC secrets.
  • bundle/vuln_variant/artifacts/variant_harness.py — the test harness.
  • bundle/vuln_variant/logs/{vulnerable,fixed}_version.txtapisix version output (3.16.0 / 3.17.0).
  • bundle/logs/variant_run1.stdout.log, bundle/logs/variant_run2.stdout.log — two consecutive full runs (idempotency).

Key excerpt (vulnerable, 3.16.0):

[*] no-jwt control: 401 (expect 401)
[*] RS256  forged HS256 -> 200  [BYPASS]  body=<!DOCTYPE HTML>...
[*] ES256  forged HS256 -> 200  [BYPASS]  body=<!DOCTYPE HTML>...
[*] EdDSA  forged HS256 -> 200  [BYPASS]  body=<!DOCTYPE HTML>...
[+] VULNERABLE: all three asymmetric consumers bypassed via HS256 confusion

Key excerpt (fixed, 3.17.0):

[*] RS256  forged HS256 -> 401  [REJECTED]  body={"message":"failed to verify jwt"}
[*] ES256  forged HS256 -> 401  [REJECTED]  body={"message":"failed to verify jwt"}
[*] EdDSA  forged HS256 -> 401  [REJECTED]  body={"message":"failed to verify jwt"}
[+] FIXED: all forged HS256 tokens rejected (401) -> fix covers RS256/ES256/EdDSA

Environment: Docker (official apache/apisix:3.16.0-debian and apache/apisix:3.17.0-debian images), isolated bridge network, OpenResty/LuaJIT runtime inside the images; apisix version reported 3.16.0 and 3.17.0 respectively. Image digests: 3.16.0-debian@sha256:b921bdef…, 3.17.0-debian@sha256:6cbf65f3….

Recommendations / Next Steps

  • The fix needs no extension for the algorithm-confusion class — it is complete and covers all consumer algorithms. No code change is required to close a bypass (none exists).
  • Defense in depth (recommended, not required): bind key type to primitive type inside parser.lua's alg_verify/alg_sign — e.g. refuse to construct an HMAC primitive when the supplied key parses as an asymmetric PEM (and vice-versa). This makes the confusion structurally impossible at the verifier layer, so a future regression that adds a second caller of verify_signature (or bypasses find_consumer) cannot reintroduce the bug. The repro RCA made the same recommendation.
  • Regression test coverage: the fix's TEST 52 covers RS256. Add parallel cases for ES256 and EdDSA (forged HS256 → 401 with expected ES256 / expected EdDSA) to pin the generic coverage demonstrated in this run.
  • Consumer audit: any pre-3.17.0 consumer configured with an asymmetric algorithm (RS*/ES*/PS*/EdDSA) whose public key was exposed was exploitable; rotate keys and review access logs. This applies to all asymmetric families, not just RS256.

Additional Notes

  • Idempotency: the script was run twice consecutively with identical results (run1 and run2 both: vulnerable → 200×3, fixed → 401×3, exit 1). It cleans up all containers/network on exit via a trap.
  • Bounded search: fewer than three materially distinct variants exist because verify_signature has exactly one caller (find_consumer, which has the fix) and the check/dispatch share the same header.alg. The three runtime candidates tested (RS256/ES256/EdDSA) are the same root cause/surface/entry point and differ only in consumer key material; HS384/HS512 HMAC-variant forgeries and query/cookie delivery channels are likewise the same sink and were ruled out by source analysis. Additional attempts would re-exercise the identical alg_verify["HS*"] sink, not a distinct path.
  • Why this is a negative (no-bypass) verdict, not a confirmed variant: the ES256/EdDSA results reproduce only on the vulnerable version and reach the identical sink via the identical entry point/trust boundary as the parent CVE. They confirm the fix's breadth, not a gap. Claiming them as a "distinct variant" would be relabeling the same behavior through a different consumer configuration, which the variant guidance explicitly excludes.

CVE-2026-39999 Reproduction Transcript

The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.

Full session Replay every step — scrub the timeline or play it back.

Event 1/40
0:000:53
0:00
session startedaccounts/fireworks/routers/glm-5p2-fast · CVE-2026-39999 · REPRO-20
0:03
0:04
web search
0:05
web search
0:07
0:08
0:10
web search
0:12
0:18
web search
0:21
0:23
0:24
web search
0:41
0:42
extract_facts
no facts extracted
0:43
0:43
0:43
supportrepro
0:44
0:44
0:44
0:45
0:45
0:45
0:46
0:46
0:49
0:49
0:49
0:49
$ls -la /data/pruva/project-cache/0a14f1a6-2023-402d-a83c-902eda6963c4/ 2>/dev/null && echo "---REPO---" && ls -la /data/pruva/project-cache/0a14f1a6-2023-402d-a83c-902eda6963c4/repo 2>/dev/null | head -30
total 0
drwxr-xr-x 1 vscode vscode   24 Jul 13 16:28 .
drwxr-xr-x 1 vscode vscode 6790 Jul 13 16:28 ..
drwxr-xr-x 1 vscode vscode    0 Jul 13 16:28 repo-mirrors
---REPO---
0:52

Artifacts and Evidence for CVE-2026-39999

Scripts, logs, diffs, and output captured during the reproduction.

08 · How to Fix

How to Fix CVE-2026-39999

Upgrade apache/apisix · github to 3.17.0 or later.

Coming soon

Step-by-step mitigation and hardening guidance for CVE-2026-39999 — configuration checks, workarounds where no patch exists, and how to verify you're protected — is on the way.

10 · FAQ

FAQ: CVE-2026-39999

How does the CVE-2026-39999 algorithm-confusion attack work?

An attacker who knows the RS256 consumer's public key forges a JWT with header alg=HS256 and an HMAC-SHA256 signature computed over the token using the public-key PEM as the HMAC secret. APISIX then verifies with HMAC-SHA256 using that same public key as the secret and accepts the forged token — the classic JWT algorithm-confusion (CVE-2015-9235) pattern realized in jwt-auth.

Which APISIX versions are affected by CVE-2026-39999, and where is it fixed?

Apache APISIX versions 2.2.0 through 3.16.0 are affected in the jwt-auth plugin. It is fixed in 3.17.0 — upgrade to 3.17.0 or later.

How severe is CVE-2026-39999?

It is rated critical severity: a complete authentication bypass against consumers configured with asymmetric (RS256-style) keys, requiring only knowledge of the public key.

How can I reproduce CVE-2026-39999?

Download the verified script from this page and run it in an isolated environment against APISIX 2.2.0-3.16.0 with an RS256 jwt-auth consumer. It forges an HS256 token signed with the public-key PEM and shows APISIX accepting it, then confirms 3.17.0 rejects it.
11 · References

References for CVE-2026-39999

Authoritative sources for CVE-2026-39999 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.