{"repro_id":"REPRO-2026-00287","version":6,"title":"Apache APISIX jwt-auth authentication bypass via algorithm confusion","repro_type":"security","status":"published","severity":"critical","description":"Apache APISIX jwt-auth trusted the JWT header alg value to choose the signature verification primitive, while separately selecting the verification key from trusted consumer configuration. Under RS256-style consumer configs, an attacker could swap the token header to HS256 and use the consumer’s public key as an HMAC secret, resulting in an authentication bypass. Affected versions: 2.2.0 through 3.16.0. Fixed in 3.17.0.","root_cause":"# Root Cause Analysis — CVE-2026-39999\n\n## Summary\n\nApache APISIX's `jwt-auth` plugin selected the JWT signature verification primitive\nfrom the **attacker-controlled JWT header `alg`** field, while independently selecting\nthe verification **key** from trusted consumer configuration. When a consumer was\nconfigured with an RS256 (asymmetric) algorithm, the plugin used the consumer's RSA\n**public key** as the verification key. An attacker who knew that public key could\nforge a JWT whose header declared `alg=HS256` and whose HMAC-SHA256 signature was\ncomputed over the token using the public key PEM as the HMAC secret. The plugin then\nverified the forged token with HMAC-SHA256 — using the same public key as the secret —\nand accepted it, bypassing authentication entirely. This is the classic JWT algorithm\nconfusion attack (CVE-2015-9235 pattern) realized in APISIX's `jwt-auth` verifier.\n\n## Impact\n\n- **Package/component affected:** `apisix/plugins/jwt-auth` (the `jwt-auth` authentication\n  plugin), specifically the `find_consumer()` → `get_auth_secret()` → `verify_signature()`\n  path in `apisix/plugins/jwt-auth.lua` and `apisix/plugins/jwt-auth/parser.lua`.\n- **Affected versions:** Apache APISIX 2.2.0 through 3.16.0 (all versions that supported\n  asymmetric algorithms in `jwt-auth` and lacked the algorithm-match enforcement).\n- **Fixed in:** 3.17.0 (commit `e4de423fc583fdf82f47a1dda6c666aa2f2b3dca` —\n  \"fix(jwt-auth): enforce algorithm match before signature verification (#13182)\").\n- **Risk level:** Critical. An unauthenticated remote attacker who can obtain a\n  consumer's configured public key (which is, by definition, public) can forge a\n  valid-looking JWT and access any route protected by that consumer's `jwt-auth`\n  configuration. No private key material is required.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Authentication bypass / authorization bypass\n  (`authz_bypass`) on remote API requests authenticated via `jwt-auth` with an\n  RS256-style consumer.\n- **Reproduced impact from this run:** Full authentication bypass demonstrated\n  end-to-end against a running Apache APISIX 3.16.0 gateway. A forged HS256 JWT\n  (signed with the consumer's RSA public key as the HMAC secret) was accepted by the\n  protected route (HTTP **200**, request proxied to upstream), while the identical\n  forged token was rejected by the fixed 3.17.0 gateway (HTTP **401**,\n  `\"failed to verify jwt: algorithm mismatch, expected RS256\"`).\n- **Parity:** **full**. The remote API authentication bypass described in the advisory\n  was reproduced exactly, and the negative control on the fixed version confirms the\n  fix closes the bypass.\n- **Not demonstrated:** N/A — the claim is authorization bypass, not code execution,\n  and the bypass itself was demonstrated in full.\n\n## Root Cause\n\nIn the vulnerable `find_consumer()` function (`apisix/plugins/jwt-auth.lua`, 3.16.0):\n\n1. The JWT is parsed and the consumer is located by the `key` claim in the payload\n   (`consumer_mod.find_consumer(plugin_name, \"key\", user_key)`).\n2. `get_auth_secret(consumer)` returns the verification key based on the **consumer's\n   configured** algorithm:\n   ```lua\n   local function get_auth_secret(consumer)\n       if not consumer.auth_conf.algorithm or\n          consumer.auth_conf.algorithm:sub(1, 2) == \"HS\" then\n           return get_secret(consumer.auth_conf)     -- HMAC secret\n       else\n           return consumer.auth_conf.public_key      -- RSA public key (for RS256)\n       end\n   end\n   ```\n   For an RS256 consumer, the returned key is the **RSA public key**.\n3. `jwt:verify_signature(auth_secret)` in `parser.lua` selects the verification\n   primitive from **`self.header.alg`** — the JWT header, which is attacker-controlled:\n   ```lua\n   function _M.verify_signature(self, key)\n       return alg_verify[self.header.alg](self.raw_header .. \".\" ..\n                  self.raw_payload, base64_decode(self.signature), key)\n   end\n   ```\n4. There was **no check** that `self.header.alg` matched `consumer.auth_conf.algorithm`.\n   So when the attacker sets `alg=HS256`, `alg_verify[\"HS256\"](data, signature, public_key)`\n   is invoked, which computes `HMAC-SHA256(data, public_key)` and compares it to the\n   attacker-supplied signature. Because the public key is public, the attacker can\n   compute the identical HMAC and produce a matching signature → **authentication\n   bypass**.\n\n**The fix** (commit `e4de423f`, released in 3.17.0) inserts an algorithm-match\nenforcement **before** `verify_signature`:\n```lua\n-- Enforce that the JWT header's \"alg\" matches the consumer's configured algorithm\nlocal expected_alg = consumer.auth_conf.algorithm or \"HS256\"\nlocal token_alg = jwt.header and jwt.header.alg\nif token_alg ~= expected_alg then\n    local err = \"failed to verify jwt: algorithm mismatch, expected \" .. expected_alg\n    ...\n    return nil, nil, \"failed to verify jwt\"\nend\n```\nThis rejects any token whose header `alg` differs from the consumer's configured\nalgorithm (e.g. an HS256 token against an RS256 consumer) before signature\nverification, closing the confusion.\n\n## Reproduction Steps\n\n1. **Script:** `bundle/repro/reproduction_steps.sh` (self-contained, idempotent).\n2. **What it does:**\n   - Generates a fresh RSA-2048 keypair with `openssl`.\n   - Deploys the **real** Apache APISIX gateway inside Docker containers on an\n     isolated bridge network: `bitnamilegacy/etcd:3.6` (config store),\n     `python:3-slim` (upstream HTTP backend + client helper), and\n     `apache/apisix:3.16.0-debian` (vulnerable) then `apache/apisix:3.17.0-debian`\n     (fixed).\n   - Uses the APISIX **Admin API** to create a consumer (`jack`) with the `jwt-auth`\n     plugin configured for **RS256** using the generated RSA public key, and a route\n     (`/`) protected by `jwt-auth` with the upstream backend.\n   - Verifies the route is genuinely protected: a request **without** a JWT returns\n     HTTP 401.\n   - **Forges** a JWT with header `{\"typ\":\"JWT\",\"alg\":\"HS256\"}` and payload\n     `{\"key\":\"jack-key\"}`, signing the `header.payload` input with\n     `HMAC-SHA256(..., RSA_public_key_PEM)` — using the consumer's **public key** as\n     the HMAC secret.\n   - Sends the forged token to the protected route via `Authorization: Bearer <jwt>`.\n   - On the **vulnerable 3.16.0** gateway: the request is **accepted** (HTTP 200,\n     proxied to upstream) → authentication bypass.\n   - On the **fixed 3.17.0** gateway (fresh etcd, same consumer/route): the identical\n     forged token is **rejected** (HTTP 401, `\"failed to verify jwt\"`) with the log\n     `algorithm mismatch, expected RS256` → fix confirmed.\n3. **Expected evidence:** Two consecutive runs both show vulnerable→200 (bypass) and\n   fixed→401 (reject); APISIX error logs on 3.17.0 contain the\n   `jwt-auth.lua:338: ... algorithm mismatch, expected RS256` line.\n\n## Evidence\n\n- `bundle/logs/harness_vulnerable.log` — exploit harness output on 3.16.0\n  (forged HS256 JWT → HTTP **200**, `[+] VULNERABLE: forged HS256 JWT accepted ->\n  authentication BYPASS`).\n- `bundle/logs/harness_fixed.log` — exploit harness output on 3.17.0\n  (forged HS256 JWT → HTTP **401**, `[+] FIXED: forged HS256 JWT rejected`).\n- `bundle/logs/apisix_fixed.log` — APISIX 3.17.0 nginx error log containing:\n  `[lua] jwt-auth.lua:338: find_consumer(): failed to verify jwt: algorithm mismatch,\n  expected RS256`.\n- `bundle/artifacts/results_vulnerable.json` —\n  `{\"forged_jwt_code\": 200, \"no_jwt_code\": 401, ...}`.\n- `bundle/artifacts/results_fixed.json` —\n  `{\"forged_jwt_code\": 401, \"no_jwt_code\": 401, \"forged_jwt_body\":\n  \"{\\\"message\\\":\\\"failed to verify jwt\\\"}\"}`.\n- `bundle/artifacts/rsa_public.pem` / `rsa_private.pem` — the RSA keypair used.\n- `bundle/artifacts/apisix_config.yaml` — the APISIX deployment config (etcd + admin key).\n- `bundle/repro/runtime_manifest.json` — structured runtime evidence manifest.\n\nKey excerpt (vulnerable, 3.16.0):\n```\n[*] no-jwt: 401 (expect 401)\n[*] forged HS256 JWT: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJrZXkiOiJqYWNrLWtleSJ9...\n[*] forged-jwt: 200  body=<!DOCTYPE HTML>...\n[+] VULNERABLE: forged HS256 JWT accepted -> authentication BYPASS\n```\nKey excerpt (fixed, 3.17.0):\n```\n[*] forged-jwt: 401  body={\"message\":\"failed to verify jwt\"}\n[+] FIXED: forged HS256 JWT rejected (401) -> algorithm mismatch enforced\n```\n\n**Environment:** Docker (Apache APISIX official images), isolated bridge network,\nOpenResty/LuaJIT runtime inside the `apache/apisix:*-debian` images; APISIX versions\nverified via `apisix version` (3.16.0 / 3.17.0).\n\n## Recommendations / Next Steps\n\n- **Upgrade** to Apache APISIX 3.17.0 or later, which enforces that the JWT header\n  `alg` matches the consumer's configured algorithm before signature verification.\n- **Defense in depth:** Consider also key-type/algorithm binding at the verifier\n  primitive level (e.g. refuse to use an asymmetric key material with an HMAC\n  primitive, and vice versa), so that a single missing upper-layer check cannot\n  reintroduce the confusion.\n- **Audit** existing `jwt-auth` consumers: any consumer configured with an asymmetric\n  algorithm (RS*/ES*/PS*/EdDSA) whose public key is exposed was exploitable prior to\n  the fix; rotate keys and review access logs for forged-token usage.\n- **Testing:** Add a regression test that submits an HS256-forged token against an\n  RS256 consumer and asserts a 401 (the fix commit `e4de423f` already added such tests\n  in `t/plugin/jwt-auth.t`).\n\n## Additional Notes\n\n- **Idempotency:** The script cleans up all containers/networks on exit and was run\n  **twice consecutively** with identical results (vulnerable→200, fixed→401) —\n  reproducibility confirmed.\n- The forged JWT contains **only** the `key` claim (matching the consumer) and omits\n  `exp`/`nbf`; on 3.16.0 `verify_claims` only validates those claims when present, so\n  the token passes claim validation. This keeps the proof focused on the algorithm\n  confusion and avoids confounding it with expiry behavior.\n- The attack requires only the consumer's **public key** (which is public by design\n  for RS256); no private key, no secrets, and no prior authentication are needed.\n- Networking note: the sandbox runs Docker-in-Docker where host port publishing is not\n  directly reachable from the shell; the script therefore drives all HTTP traffic\n  through a `python:3-slim` client container on the same bridge network, addressing\n  the APISIX gateway by its container alias.\n","cve_id":"CVE-2026-39999","cwe_id":"CWE-290 Authentication Bypass by Spoofing","source_url":"https://github.com/apache/apisix","package":{"name":"apache/apisix","ecosystem":"github","affected_versions":"2.2.0 through 3.16.0","fixed_version":"3.17.0"},"reproduced_at":"2026-07-14T04:20:37.892115+00:00","duration_secs":873.0,"tool_calls":183,"handoffs":2,"total_cost_usd":2.6219986200000003,"agent_costs":{"judge":0.0080649,"repro":1.1276955600000005,"support":0.0906192,"vuln_variant":1.3956189600000004},"cost_breakdown":{"judge":{"gpt-5.4-mini":0.0080649},"repro":{"accounts/fireworks/routers/glm-5p2-fast":1.1276955600000005},"support":{"accounts/fireworks/routers/glm-5p2-fast":0.0906192},"vuln_variant":{"accounts/fireworks/routers/glm-5p2-fast":1.3956189600000004}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-14T04:20:38.830106+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":14802,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":10487,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":14098,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":15224,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":15244,"category":"other"},{"path":"bundle/artifact_promotion_report.json","filename":"artifact_promotion_report.json","size":15262,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":2294,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":6305,"category":"other"},{"path":"bundle/logs/harness_vulnerable.log","filename":"harness_vulnerable.log","size":443,"category":"log"},{"path":"bundle/logs/harness_fixed.log","filename":"harness_fixed.log","size":360,"category":"log"},{"path":"bundle/logs/apisix_fixed.log","filename":"apisix_fixed.log","size":91320,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1085,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":877,"category":"other"},{"path":"bundle/logs/apisix_vulnerable.log","filename":"apisix_vulnerable.log","size":74084,"category":"log"},{"path":"bundle/logs/variant_apisix_fixed.log","filename":"variant_apisix_fixed.log","size":92340,"category":"log"},{"path":"bundle/logs/variant_harness_vulnerable.log","filename":"variant_harness_vulnerable.log","size":701,"category":"log"},{"path":"bundle/logs/variant_harness_fixed.log","filename":"variant_harness_fixed.log","size":543,"category":"log"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":3805,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":10541,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":4877,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":2773,"category":"other"},{"path":"bundle/logs/variant_apisix_vulnerable.log","filename":"variant_apisix_vulnerable.log","size":74080,"category":"log"},{"path":"bundle/vuln_variant/artifacts/variant_results_vulnerable.json","filename":"variant_results_vulnerable.json","size":1586,"category":"other"},{"path":"bundle/vuln_variant/artifacts/variant_results_fixed.json","filename":"variant_results_fixed.json","size":1050,"category":"other"},{"path":"bundle/vuln_variant/artifacts/rsa_public.pem","filename":"rsa_public.pem","size":451,"category":"other"},{"path":"bundle/vuln_variant/artifacts/ec_public.pem","filename":"ec_public.pem","size":178,"category":"other"},{"path":"bundle/vuln_variant/artifacts/ed_public.pem","filename":"ed_public.pem","size":113,"category":"other"},{"path":"bundle/vuln_variant/artifacts/variant_harness.py","filename":"variant_harness.py","size":5023,"category":"script"},{"path":"bundle/vuln_variant/artifacts/apisix_config.yaml","filename":"apisix_config.yaml","size":292,"category":"other"},{"path":"bundle/logs/variant_run1.stdout.log","filename":"variant_run1.stdout.log","size":2716,"category":"log"},{"path":"bundle/logs/variant_run2.stdout.log","filename":"variant_run2.stdout.log","size":2716,"category":"log"}]}