TL;DR I found a JWT algorithm confusion bug in Apache APISIX that let me bypass authentication without credentials. The trick was simple, ugly, and very real: APISIX trusted the server-side key selection, but trusted the attacker-controlled JWT header for the verification algorithm.
I found a full authentication bypass in Apache APISIX's jwt-auth plugin, and the root cause was exactly the kind of JWT mistake that should have died years ago.
Find official PR fix here
If a consumer was configured for an asymmetric algorithm like RS256, APISIX would fetch the consumer's public key from server-side config, then verify the token using whatever algorithm I put in the JWT header. That meant I could take an RSA public key, switch the header to HS256, use the public key as an HMAC secret, and mint a valid token as any known consumer.
That is not a weird edge case. That is game over.
What I saw
The interesting part showed up in how APISIX handled verification. It made two separate decisions.
First, it picked the verification key from the consumer's configuration. For symmetric algorithms it used the shared secret. For asymmetric algorithms like RS256 it used the configured public key.
Second, it picked the verification function from the JWT header's
Those two choices were completely disconnected.
That is the whole bug.
Here is the relevant logic in plain form.
And then later:
So APISIX said, more or less:
"I will choose the key based on trusted config."
Then immediately after:
"I will choose the algorithm based on attacker input."
That should never happen in JWT verification. Ever.
Why this was exploitable
If a consumer is configured with RS256, the server stores a public key for verification. Public keys are public by design. That is not a leak. That is the whole point of asymmetric crypto.
Now take that setup and send a JWT with this header:
And a payload like this:
Then sign the token with HMAC-SHA256 using the RSA public key PEM as the HMAC secret.
That sounds stupid because it is stupid. But it works when the verifier mixes trust boundaries like this.
The server looks up consumer
No private key needed.
No shared secret needed.
No valid account needed.
Just network access to a protected route, a known consumer key identifier, and the consumer's public key.
The actual attack path
This was the flow:
1. A target consumer is configured with
2. I create a JWT with
3. I put the consumer identifier in the payload so APISIX knows which consumer config to load.
4. I sign the token with HMAC-SHA256 using the target's RSA public key as the secret.
5. APISIX retrieves the public key because the consumer is configured for RS256.
6. APISIX verifies the signature with HS256 because my JWT header says HS256.
7. Signature passes. I am authenticated as that consumer.
That is the bypass.
Why it worked
Because the implementation violated the one rule that matters here: the key and algorithm must be bound together.
The server must not say "this user is configured for RS256" and then happily verify a token using HS256 just because the token asked nicely.
JWT libraries and integrations keep repeating this lesson because people keep making the same mistake in slightly different ways. In this case the bug was not in some exotic crypto primitive. It was in decision flow. The implementation let trusted configuration and untrusted token metadata drift apart.
That is enough.
This was not a misconfiguration
This part matters, because these bugs often get waved away with "well, you configured it wrong."
No.
Using RS256 in APISIX was supported behavior. It was in the schema, in the docs, and in the tests. The vulnerable state was literally the documented setup. There was no option to pin allowed verification algorithms. No warning. No safeguard. No extra switch an admin was supposed to know about.
So this was not an operator footgun. It was a product bug.
What I reported
I sent the report to the Apache Security Team on March 26, 2026. I described it as a JWT algorithm confusion issue in the APISIX
I also included the core reasoning, the vulnerable code paths, the attack flow, and the minimal fix: enforce that the JWT header algorithm matches the consumer's configured algorithm before verification.
The fix was basically this:
And at the call site:
That is it. No complicated redesign. No breaking config change. Just stop trusting attacker input to pick the verification primitive when the server already knows what algorithm that key is supposed to be used with.
Vendor response
The initial acknowledgment from the Apache Security Team came the next day.
A few days later, A.C. confirmed the issue was valid and classified it as a security vulnerability. They also shared that the APISIX PMC had prioritized it.
Then on April 8, 2026, A.C. sent over the fix PR and a draft advisory. The assigned identifier was
That turnaround was solid.
What I think about it
This bug was satisfying because the exploit path was clean. No weird race, no unreliable chain, no fragile parser trick. Just a straightforward algorithm confusion issue caused by trusting the wrong piece of data at the wrong time.
Also, this is exactly why "the public key is public anyway" is not some boring background detail. In asymmetric systems, public key exposure is normal. Security is supposed to hold even when everyone has it. The moment your verifier can be tricked into treating that public key like an HMAC secret, the whole model collapses.
And honestly, this class of bug is old enough that seeing it in a modern auth plugin is a bit worrying.
Still, credit where it is due. Once reported, the issue was handled properly, confirmed, fixed, and prepared for advisory publication without drama.
Cheers!
I found a full authentication bypass in Apache APISIX's jwt-auth plugin, and the root cause was exactly the kind of JWT mistake that should have died years ago.
Find official PR fix here
If a consumer was configured for an asymmetric algorithm like RS256, APISIX would fetch the consumer's public key from server-side config, then verify the token using whatever algorithm I put in the JWT header. That meant I could take an RSA public key, switch the header to HS256, use the public key as an HMAC secret, and mint a valid token as any known consumer.
That is not a weird edge case. That is game over.
What I saw
The interesting part showed up in how APISIX handled verification. It made two separate decisions.
First, it picked the verification key from the consumer's configuration. For symmetric algorithms it used the shared secret. For asymmetric algorithms like RS256 it used the configured public key.
Second, it picked the verification function from the JWT header's
alg value.Those two choices were completely disconnected.
That is the whole bug.
Here is the relevant logic in plain form.
Code:
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)
else
return consumer.auth_conf.public_key
end
end
And then later:
Code:
function _M.verify_signature(self, key)
return alg_verify[self.header.alg](
self.raw_header .. "." .. self.raw_payload,
base64_decode(self.signature),
key
)
end
So APISIX said, more or less:
"I will choose the key based on trusted config."
Then immediately after:
"I will choose the algorithm based on attacker input."
That should never happen in JWT verification. Ever.
Why this was exploitable
If a consumer is configured with RS256, the server stores a public key for verification. Public keys are public by design. That is not a leak. That is the whole point of asymmetric crypto.
Now take that setup and send a JWT with this header:
JSON:
{
"alg": "HS256",
"typ": "JWT"
}
And a payload like this:
JSON:
{
"key": "alice",
"exp": 9999999999
}
Then sign the token with HMAC-SHA256 using the RSA public key PEM as the HMAC secret.
That sounds stupid because it is stupid. But it works when the verifier mixes trust boundaries like this.
The server looks up consumer
alice, sees that the configured algorithm is RS256, fetches the public key, then calls the verification function for HS256 because that is what I put in the token header. Since I used the same public key as the HMAC secret, the signature matches.No private key needed.
No shared secret needed.
No valid account needed.
Just network access to a protected route, a known consumer key identifier, and the consumer's public key.
The actual attack path
This was the flow:
1. A target consumer is configured with
algorithm: "RS256" and a public_key.2. I create a JWT with
alg: HS256.3. I put the consumer identifier in the payload so APISIX knows which consumer config to load.
4. I sign the token with HMAC-SHA256 using the target's RSA public key as the secret.
5. APISIX retrieves the public key because the consumer is configured for RS256.
6. APISIX verifies the signature with HS256 because my JWT header says HS256.
7. Signature passes. I am authenticated as that consumer.
That is the bypass.
Why it worked
Because the implementation violated the one rule that matters here: the key and algorithm must be bound together.
The server must not say "this user is configured for RS256" and then happily verify a token using HS256 just because the token asked nicely.
JWT libraries and integrations keep repeating this lesson because people keep making the same mistake in slightly different ways. In this case the bug was not in some exotic crypto primitive. It was in decision flow. The implementation let trusted configuration and untrusted token metadata drift apart.
That is enough.
This was not a misconfiguration
This part matters, because these bugs often get waved away with "well, you configured it wrong."
No.
Using RS256 in APISIX was supported behavior. It was in the schema, in the docs, and in the tests. The vulnerable state was literally the documented setup. There was no option to pin allowed verification algorithms. No warning. No safeguard. No extra switch an admin was supposed to know about.
So this was not an operator footgun. It was a product bug.
What I reported
I sent the report to the Apache Security Team on March 26, 2026. I described it as a JWT algorithm confusion issue in the APISIX
jwt-auth plugin that allowed unauthenticated attackers to forge valid tokens and bypass authentication entirely.I also included the core reasoning, the vulnerable code paths, the attack flow, and the minimal fix: enforce that the JWT header algorithm matches the consumer's configured algorithm before verification.
The fix was basically this:
Code:
function _M.verify_signature(self, key, expected_alg)
if self.header.alg ~= expected_alg then
return false
end
return alg_verify[self.header.alg](
self.raw_header .. "." .. self.raw_payload,
base64_decode(self.signature),
key
)
end
And at the call site:
Code:
if not jwt:verify_signature(
auth_secret,
consumer.auth_conf.algorithm
) then
That is it. No complicated redesign. No breaking config change. Just stop trusting attacker input to pick the verification primitive when the server already knows what algorithm that key is supposed to be used with.
Vendor response
The initial acknowledgment from the Apache Security Team came the next day.
A few days later, A.C. confirmed the issue was valid and classified it as a security vulnerability. They also shared that the APISIX PMC had prioritized it.
Then on April 8, 2026, A.C. sent over the fix PR and a draft advisory. The assigned identifier was
CVE-2026-39999, with affected versions listed as Apache APISIX v2.2 through v3.16.0, and the fix shipping in v3.16.1.That turnaround was solid.
What I think about it
This bug was satisfying because the exploit path was clean. No weird race, no unreliable chain, no fragile parser trick. Just a straightforward algorithm confusion issue caused by trusting the wrong piece of data at the wrong time.
Also, this is exactly why "the public key is public anyway" is not some boring background detail. In asymmetric systems, public key exposure is normal. Security is supposed to hold even when everyone has it. The moment your verifier can be tricked into treating that public key like an HMAC secret, the whole model collapses.
And honestly, this class of bug is old enough that seeing it in a modern auth plugin is a bit worrying.
Still, credit where it is due. Once reported, the issue was handled properly, confirmed, fixed, and prepared for advisory publication without drama.
Cheers!