# Patch Analysis — CVE-2026-39999 (Apache APISIX jwt-auth algorithm confusion)

## 1. What the fix changes

**Fix commit:** `e4de423fc583fdf82f47a1dda6c666aa2f2b3dca`
**PR:** #13182 — "fix(jwt-auth): enforce algorithm match before signature verification"
**Released in:** Apache APISIX 3.17.0 (release tag commit `9ef2ecab67f652d38365049613610ef649bb4ad0`)
**File changed:** `apisix/plugins/jwt-auth.lua` (function `find_consumer`), +12 lines.

The fix inserts a single guard **between** key retrieval (`get_auth_secret`) and
signature verification (`jwt:verify_signature`) inside `find_consumer`:

```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
    if auth_utils.is_running_under_multi_auth(ctx) then
        return nil, nil, err
    end
    core.log.warn(err)
    return nil, nil, "failed to verify jwt"
end
```

Behavior before the fix (3.16.0): `find_consumer` retrieved the verification key
from the **consumer's** configured algorithm (`get_auth_secret`) and then called
`jwt:verify_signature(auth_secret)`, which dispatched the verification primitive
from the **token's** attacker-controlled `header.alg` (`alg_verify[self.header.alg]`).
Because the key source and the primitive source were independent, an attacker could
set `alg=HS256` against an RS256 consumer and have the consumer's RSA **public key**
used as the HMAC secret — the classic JWT algorithm-confusion bypass.

Behavior after the fix (3.17.0): before reaching `verify_signature`, the token's
`header.alg` must exactly equal the consumer's configured `algorithm` (defaulting to
`"HS256"` when unset). A mismatch returns `nil, nil, "failed to verify jwt"` (HTTP
401) with a `core.log.warn` of `algorithm mismatch, expected <alg>`. Because the
token alg now must match the consumer alg, the key selected by `get_auth_secret`
and the primitive selected by `alg_verify[self.header.alg]` are guaranteed to be
consistent (symmetric key + HMAC, or asymmetric key + RSA/EC/EdDSA verify).

Tests added by the fix (`t/plugin/jwt-auth.t` TEST 52) submit an HS256-forged token
against an RS256 consumer and assert 401 with `algorithm mismatch, expected RS256`
in the error log.

## 2. What invariant the fix relies on

The fix relies on **one invariant**: the JWT header field used for the
algorithm-match check is the **same** field used to dispatch the verification
primitive. Concretely:

- The check reads `token_alg = jwt.header and jwt.header.alg`.
- `parser.lua`'s `_M.verify_signature` reads `alg_verify[self.header.alg]`.

Both read `self.header.alg`, which is set once by `resty.jwt`'s `load_jwt` →
`parse_jwt` as `cjson.decode(base64url_decode(raw_header)).alg`. There is no
normalization, no second parse, and no divergence between the value compared and
the value dispatched. Therefore **any** `header.alg` value that passes the
`token_alg == expected_alg` check will dispatch to the *correct* primitive for the
consumer's algorithm — and `get_auth_secret` will have returned the matching key
type. The confusion (asymmetric key fed to HMAC, or vice-versa) is impossible once
the two algs are forced equal.

A secondary reliance: `get_auth_secret` selects the key from the **consumer's**
configured algorithm (`consumer.auth_conf.algorithm:sub(1,2) == "HS"` → HMAC
secret; otherwise `public_key`). After the fix the consumer alg and token alg are
equal, so the key type and primitive type always agree.

## 3. Code paths the fix explicitly covers

`find_consumer` is the **only** caller of `jwt:verify_signature` in the entire
codebase (verified by `search_code` for `verify_signature|jwt_parser|jwt-auth.parser`).
The only caller of `find_consumer` is `_M.rewrite`. There is no alternate
verification path. Therefore the single guard in `find_consumer` covers **every**
runtime path that verifies a jwt-auth token:

- Header delivery (`Authorization: Bearer …` / bare header)
- Query-string delivery (`?jwt=…`, configurable `query` name)
- Cookie delivery (`cookie_<conf.cookie>`)
- Standalone `jwt-auth` on a route
- `jwt-auth` chained under `multi-auth` (the `is_running_under_multi_auth` branch
  returns the same rejection, just with a detailed error for the chain)
- All consumer algorithms in the schema enum: `HS256/384/512`, `RS256/384/512`,
  `ES256/384/512`, `PS256/384/512`, `EdDSA` — because the check compares the
  generic `consumer.auth_conf.algorithm` string, not a hardcoded `RS256`.

This generic coverage was confirmed at runtime in this variant run: forged HS256
tokens against **RS256**, **ES256**, and **EdDSA** consumers were all rejected on
3.17.0 with the specific log lines `algorithm mismatch, expected RS256`,
`expected ES256`, and `expected EdDSA` (all from `jwt-auth.lua:338`).

## 4. What the fix does NOT cover / gaps

**No bypass gap was found.** The analysis below documents the candidate paths that
were evaluated and ruled out, plus residual defense-in-depth observations.

### 4a. Parsing-based bypass (ruled out)
`resty.jwt` `load_jwt` → `parse_jwt` sets `header.alg` directly from the decoded
header JSON with **no** normalization, no whitelist, and no `alg`-validity check
at load time (the `alg_whitelist` / "No algorithm supplied" checks live only in
`verify_jwt_obj`, which APISIX does **not** call — APISIX uses its own
`alg_verify`). Because the fix's check and `verify_signature` both read
`self.header.alg`, there is no value that can simultaneously (i) equal
`expected_alg` and (ii) dispatch to HMAC:
- `alg = expected_alg` (e.g. `"RS256"`) → `alg_verify["RS256"]` runs RSA verify with the public key. Correct.
- `alg = "HS256"` against an RS256 consumer → `~= "RS256"` → rejected before verify.
- Duplicate JSON keys (`{"alg":"RS256","alg":"HS256"}`) → cjson keeps the last → `"HS256"` → `~= "RS256"` → rejected.
- Non-string `alg` (number/table/nil) → `~= expected_alg` (type-mismatched) → rejected; and `alg_verify[<non-string>]` would be nil anyway.
- `alg = "none"` / missing `alg` → `~=` expected → rejected (and `alg_verify["none"]` is nil → would error in verify, unreachable after the check).

### 4b. Alternate entry point to the sink (ruled out)
`verify_signature` is reached only via `find_consumer` → `rewrite`. No other
plugin or phase calls the jwt-auth parser's `verify_signature`. The
`openid-connect` plugin performs its own JWT verification via a different library
(`lua-resty-openidc`), so it is a separate codebase, not the same sink.

### 4c. Key-selection divergence (ruled out)
`get_auth_secret` selects the key from the consumer's algorithm. Because the fix
forces token alg == consumer alg, the key type (HMAC secret vs public key) always
matches the primitive type. A consumer configured `HS256` with a stray
`public_key` field still returns the HMAC `secret` from `get_secret`, and the
attacker would need that secret to forge a valid HS256 token — not algorithm
confusion.

### 4d. Anonymous-consumer / multi-auth fallback (by-design, not a bypass)
`_M.rewrite` falls back to `conf.anonymous_consumer` when `find_consumer` returns
nil (which includes the alg-mismatch rejection). This grants only the **anonymous**
consumer's privileges — identical to sending no token at all. It is an explicitly
configured operator feature, not a privilege escalation beyond what unauthenticated
access already yields. The later hardening commit `b6f80f5d` (#13468) further
strips credentials before the anonymous fallback (a credential-leakage hardening,
not an alg-confusion concern).

### 4e. Residual defense-in-depth observation (not a gap in the fix)
The fix is an **upper-layer string comparison**. The underlying design weakness —
"key chosen from consumer config, primitive chosen from token header" — is patched
only by enforcing equality at the APISIX layer. If a future change re-introduced a
second call site to `verify_signature` (or bypassed `find_consumer`), the confusion
could recur. A defense-in-depth improvement (also recommended in the repro RCA)
would be to bind key **type** to primitive **type** inside `parser.lua`'s
`alg_verify` (refuse to use an asymmetric key PEM with an HMAC primitive and
vice-versa), so a single missing upper-layer check cannot reintroduce confusion.
This is a hardening recommendation, **not** an exploitable gap in the current fix.

## 5. Behavior comparison before vs after

| Scenario (consumer alg → forged token alg) | 3.16.0 (before) | 3.17.0 (after) |
|---|---|---|
| RS256 → HS256 (original CVE) | 200 bypass | 401 `algorithm mismatch, expected RS256` |
| ES256 → HS256 (variant) | 200 bypass | 401 `algorithm mismatch, expected ES256` |
| EdDSA → HS256 (variant) | 200 bypass | 401 `algorithm mismatch, expected EdDSA` |
| RS256 → RS256 (legit) | 200 (valid sig) | 200 (valid sig) |
| no token | 401 | 401 |

## 6. Target threat-model scope

APISIX's `THREAT_MODEL.md` states the gateway "needs to be able to run in front of
untrusted downstream traffic." Authentication plugins (jwt-auth) are explicitly in
the trust boundary that protects downstream-facing routes from unauthenticated
callers — so an authentication bypass via a forged token from a remote untrusted
caller is squarely in scope and is a valid security finding. The jwt-auth
algorithm-confusion attack crosses the untrusted-downstream → authenticated-route
trust boundary, which the threat model requires APISIX to defend.

(Out of scope and explicitly **not** claimed here: bugs reachable only from local
files chosen by the operator, or from the trusted Admin API — those do not cross
the untrusted-downstream boundary.)

## 7. Conclusion

The fix is **complete for the algorithm-confusion class**. The alg-match guard is
placed on the only path to the vulnerable sink, uses the same `header.alg` value
that the verifier dispatches on (so no value can both pass the check and trigger
HMAC confusion), and is generic over every supported consumer algorithm. No bypass
and no materially-distinct entry point to the sink were found. Alternate
triggers (different asymmetric consumer families: ES256, EdDSA, PS*; different
HMAC variants HS384/HS512; different token delivery channels) share the same root
cause, sink, entry point and trust boundary, and are all covered by the generic
check — confirmed by runtime testing on 3.17.0.
