# REPRO-2026-00287: Apache APISIX jwt-auth authentication bypass via algorithm confusion ## Summary Status: published Severity: critical Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00287 CVE: CVE-2026-39999 ## Package Name: apache/apisix Ecosystem: github Affected: 2.2.0 through 3.16.0 Fixed: 3.17.0 ## Root Cause # Root Cause Analysis — CVE-2026-39999 ## Summary 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. ## Impact - **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: ```lua 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: ```lua 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`: ```lua -- 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 `. - 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=... [+] 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. ## Reproduction Details Reproduced: 2026-07-14T04:20:37.892Z Duration: 873 seconds Tool calls: 183 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00287 pruva-verify CVE-2026-39999 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00287&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00287/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-39999 - Source: https://github.com/apache/apisix ## Artifacts - bundle/repro/reproduction_steps.sh (reproduction_script, 14802 bytes) - bundle/repro/rca_report.md (analysis, 10487 bytes) - bundle/vuln_variant/reproduction_steps.sh (reproduction_script, 14098 bytes) - bundle/vuln_variant/rca_report.md (analysis, 15224 bytes) - bundle/artifact_promotion_manifest.json (other, 15244 bytes) - bundle/artifact_promotion_report.json (other, 15262 bytes) - bundle/vuln_variant/source_identity.json (other, 2294 bytes) - bundle/vuln_variant/root_cause_equivalence.json (other, 6305 bytes) - bundle/logs/harness_vulnerable.log (log, 443 bytes) - bundle/logs/harness_fixed.log (log, 360 bytes) - bundle/logs/apisix_fixed.log (log, 91320 bytes) - bundle/repro/runtime_manifest.json (other, 1085 bytes) - bundle/repro/validation_verdict.json (other, 877 bytes) - bundle/logs/apisix_vulnerable.log (log, 74084 bytes) - bundle/logs/variant_apisix_fixed.log (log, 92340 bytes) - bundle/logs/variant_harness_vulnerable.log (log, 701 bytes) - bundle/logs/variant_harness_fixed.log (log, 543 bytes) - bundle/vuln_variant/validation_verdict.json (other, 3805 bytes) - bundle/vuln_variant/patch_analysis.md (documentation, 10541 bytes) - bundle/vuln_variant/variant_manifest.json (other, 4877 bytes) - bundle/vuln_variant/runtime_manifest.json (other, 2773 bytes) - bundle/logs/variant_apisix_vulnerable.log (log, 74080 bytes) - bundle/vuln_variant/artifacts/variant_results_vulnerable.json (other, 1586 bytes) - bundle/vuln_variant/artifacts/variant_results_fixed.json (other, 1050 bytes) - bundle/vuln_variant/artifacts/rsa_public.pem (other, 451 bytes) - bundle/vuln_variant/artifacts/ec_public.pem (other, 178 bytes) - bundle/vuln_variant/artifacts/ed_public.pem (other, 113 bytes) - bundle/vuln_variant/artifacts/variant_harness.py (script, 5023 bytes) - bundle/vuln_variant/artifacts/apisix_config.yaml (other, 292 bytes) - bundle/logs/variant_run1.stdout.log (log, 2716 bytes) - bundle/logs/variant_run2.stdout.log (log, 2716 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00287 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00287/artifacts/bundle/repro/reproduction_steps.sh - Web: https://pruva.dev/r/REPRO-2026-00287 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://pruva.dev