Code Security

Mar 2, 2026

Encryption Does Not Prove Identity

Amartya | CodeAnt AI Code Review Platform
Sonali Sood

Founding GTM, CodeAnt AI

Authentication Systems Rarely Fail Because Cryptography is Broken

More often, they fail because developers misunderstand what cryptography actually guarantees.

Encryption, signatures, key exchange, each primitive provides a specific security property. But when systems combine them incorrectly, those guarantees can silently disappear.

Recently, the CodeAnt AI Security Research team encountered exactly this situation while auditing authentication flows in the Java ecosystem.

The vulnerability, later assigned CVE-2026-29000 with a CVSS score of 10.0, affected the authentication library pac4j-jwt.

The bug did not involve weak encryption, broken signatures, or compromised keys.

Instead, the system made a subtle but critical mistake:

It treated encryption as proof of identity.

That assumption allowed attackers to generate authentication tokens for any user in the system, including administrators, using only the server’s public key.

This article explains how that mistake happened, how it led to a full authentication bypass, and why confusing confidentiality with authenticity remains one of the most common security design errors in modern software systems.

The Security Property Most Developers Confuse

In cryptographic systems, two properties are often implemented together but serve entirely different purposes:

Confidentiality

  • Ensures that data cannot be read by unauthorized parties.

  • Encryption provides confidentiality.

Authenticity

  • Ensures that data was created by a trusted party and has not been tampered with.

  • Digital signatures provide authenticity.

The distinction is critical.

Encryption answers the question:

“Can someone read this message?”

Authentication answers the question:

“Who created this message?”

Many systems implement both protections together, which makes the difference easy to overlook.

JWT authentication is one such system.

How JWT Authentication Is Supposed to Work

Modern authentication flows frequently use two nested JWT layers.

The outer layer is an encrypted token known as JWE (JSON Web Encryption).

The inner token is a signed JWT (JWS).

This structure provides two independent security guarantees.



Encryption ensures the token contents remain private during transit.

Signature verification ensures the token was generated by a trusted authority.

If either step fails, authentication must fail.

But if an implementation mistakenly assumes encryption implies authenticity, the system can become vulnerable.

That is exactly what happened here.

Where the Assumption Appeared

The pac4j-jwt authentication flow processes tokens through a class called JwtAuthenticator.

The code performs three main steps:

  1. decrypt the encrypted token

  2. parse the inner JWT

  3. verify its signature

The simplified logic looks roughly like this:



At first glance, this looks reasonable.

But a subtle interaction between JWT formats and library behavior created an unexpected path.

The JWT Format Developers Rarely Think About

The JWT specification actually defines three token types:

Type

Purpose

JWS

signed token

JWE

encrypted token

PlainJWT

unsigned token

Most developers only encounter the first two.

PlainJWT is rarely used in production authentication systems, but it is fully defined in the specification and supported by libraries.

When a library encounters a PlainJWT payload, it behaves exactly as expected.

It simply returns a token without a signature.

This is where the pac4j logic encountered trouble.

The Null Check That Disabled Authentication

The Nimbus JOSE+JWT library includes a helper method:

Its behavior is simple:

  • If the payload is a signed JWT, return the parsed object.

  • If the payload is not signed, return null.

That behavior is correct.

But the pac4j authentication code used this method in a conditional check:



Which means the system would only verify the signature if a signature existed.

If the payload was an unsigned JWT, the verification block would simply never run.

And yet the authentication flow continued.

Eventually, the code reached the step that converts JWT claims into an authenticated user profile.

The token’s claims were accepted as legitimate.

Authentication succeeded.

Even though no signature had ever been verified.

Turning That Behavior Into an Attack

Once our team understood this execution path, the next step was determining whether an attacker could intentionally trigger it.

The answer was straightforward.

Instead of creating a signed JWT, an attacker could create an unsigned PlainJWT.

Then they could encrypt it inside a JWE wrapper using the server’s public key.

Because the outer token was encrypted correctly, the server would decrypt it successfully.

But because the inner token was unsigned, the signature verification logic would never run.

The server would accept the claims as authenticated.

Why the Public Key Was Enough

In RSA-based encryption setups, the server’s public key is intentionally public.

Clients need it in order to encrypt tokens.

Common places where public keys appear include:

  • JWKS endpoints

  • configuration files

  • TLS certificate metadata

  • public source repositories

This is normal.

The public key is supposed to allow encryption.

Only the private key should allow decryption.

The vulnerability exploited a subtle consequence of this design.

Because the system trusted encrypted tokens before verifying their signatures, anyone who possessed the public key could create tokens the server would accept.

Crafting the Malicious Token

The exploit required only a few steps.

First, the attacker creates arbitrary claims.

For example:



Next, the attacker creates a PlainJWT containing those claims.

No signature is applied.

Finally, the token is encrypted using the server’s public key.

The resulting token appears perfectly valid.

When submitted to the server:



In testing, our proof-of-concept authenticated successfully on the first attempt.

When Security Components Compose Incorrectly

What makes this vulnerability particularly interesting is that no individual component behaved incorrectly.

The JWT specification allows PlainJWT.

The Nimbus library correctly returns null for non-signed tokens.

The pac4j code correctly checks for null.

Each piece of logic works exactly as designed.

But when these components interact, an unexpected behavior appears.

This type of issue is known as a composition vulnerability.

The primitives are secure.

The interaction between them is not.

These bugs are often harder to detect than traditional vulnerabilities because the individual lines of code appear correct during review.

Only when the entire execution path is analyzed does the flaw become visible.

Responsible Disclosure

Once our security engineers verified the vulnerability and built a working proof-of-concept, we privately disclosed the issue to pac4j maintainer Jérôme Leleu.

The disclosure included:

  • full technical analysis

  • proof-of-concept exploit

  • remediation suggestions

The maintainer responded quickly and confirmed the vulnerability.

Within two business days:

  • patched versions were released across three major pac4j versions

  • a public security advisory was published

  • our research team was credited for the discovery

This rapid response highlights the dedication of open-source maintainers who safeguard critical infrastructure used across the industry.

Are You Using a Vulnerable Version?

Applications may be affected if they use:

  • pac4j-jwt

  • RSA-based encrypted JWTs

  • JwtAuthenticator with both encryption and signature configurations

The vulnerability has been patched in the following versions:



Organizations should upgrade immediately.

The Larger Lesson

Security failures rarely occur because cryptographic primitives are weak.

They occur because systems make assumptions about how those primitives will be used.

Encryption protects confidentiality.

Signatures protect authenticity.

Confusing those two properties can collapse an entire authentication system.

The pac4j vulnerability is a clear example.

A system designed to enforce two layers of protection ended up relying on only one.

And encryption alone is never enough to prove identity.

Encryption Protects Secrets: Not Identity

The pac4j vulnerability demonstrates a recurring lesson in application security.

Cryptography rarely fails because the algorithms are weak.

It fails because systems misinterpret what those algorithms guarantee.

Encryption answers one question:

Can someone read this data?

Authentication answers a completely different one:

Who created this data?

When systems blur the boundary between those guarantees, security assumptions collapse.

In the pac4j case, the authentication pipeline assumed that a successfully decrypted token must also be trustworthy.

But encryption alone never proves identity.

Only signature verification can provide that guarantee.

The result was a CVSS-10 authentication bypass capable of granting administrative access using nothing more than a public key.

This type of vulnerability is increasingly common in modern software systems built from layered frameworks and dependencies.

Every component may behave correctly in isolation.

But when assumptions between those components diverge, security guarantees can disappear.

The pac4j finding highlights why modern security research increasingly focuses on trust boundaries and execution paths rather than individual lines of code.

And it shows how subtle design assumptions can create vulnerabilities with ecosystem-wide impact.

For the full technical analysis and exploit walkthrough, see the original research disclosure from the CodeAnt AI Security Research team.

Authentication Systems Rarely Fail Because Cryptography is Broken

More often, they fail because developers misunderstand what cryptography actually guarantees.

Encryption, signatures, key exchange, each primitive provides a specific security property. But when systems combine them incorrectly, those guarantees can silently disappear.

Recently, the CodeAnt AI Security Research team encountered exactly this situation while auditing authentication flows in the Java ecosystem.

The vulnerability, later assigned CVE-2026-29000 with a CVSS score of 10.0, affected the authentication library pac4j-jwt.

The bug did not involve weak encryption, broken signatures, or compromised keys.

Instead, the system made a subtle but critical mistake:

It treated encryption as proof of identity.

That assumption allowed attackers to generate authentication tokens for any user in the system, including administrators, using only the server’s public key.

This article explains how that mistake happened, how it led to a full authentication bypass, and why confusing confidentiality with authenticity remains one of the most common security design errors in modern software systems.

The Security Property Most Developers Confuse

In cryptographic systems, two properties are often implemented together but serve entirely different purposes:

Confidentiality

  • Ensures that data cannot be read by unauthorized parties.

  • Encryption provides confidentiality.

Authenticity

  • Ensures that data was created by a trusted party and has not been tampered with.

  • Digital signatures provide authenticity.

The distinction is critical.

Encryption answers the question:

“Can someone read this message?”

Authentication answers the question:

“Who created this message?”

Many systems implement both protections together, which makes the difference easy to overlook.

JWT authentication is one such system.

How JWT Authentication Is Supposed to Work

Modern authentication flows frequently use two nested JWT layers.

The outer layer is an encrypted token known as JWE (JSON Web Encryption).

The inner token is a signed JWT (JWS).

This structure provides two independent security guarantees.


Encryption ensures the token contents remain private during transit.

Signature verification ensures the token was generated by a trusted authority.

If either step fails, authentication must fail.

But if an implementation mistakenly assumes encryption implies authenticity, the system can become vulnerable.

That is exactly what happened here.

Where the Assumption Appeared

The pac4j-jwt authentication flow processes tokens through a class called JwtAuthenticator.

The code performs three main steps:

  1. decrypt the encrypted token

  2. parse the inner JWT

  3. verify its signature

The simplified logic looks roughly like this:


At first glance, this looks reasonable.

But a subtle interaction between JWT formats and library behavior created an unexpected path.

The JWT Format Developers Rarely Think About

The JWT specification actually defines three token types:

Type

Purpose

JWS

signed token

JWE

encrypted token

PlainJWT

unsigned token

Most developers only encounter the first two.

PlainJWT is rarely used in production authentication systems, but it is fully defined in the specification and supported by libraries.

When a library encounters a PlainJWT payload, it behaves exactly as expected.

It simply returns a token without a signature.

This is where the pac4j logic encountered trouble.

The Null Check That Disabled Authentication

The Nimbus JOSE+JWT library includes a helper method:

Its behavior is simple:

  • If the payload is a signed JWT, return the parsed object.

  • If the payload is not signed, return null.

That behavior is correct.

But the pac4j authentication code used this method in a conditional check:


Which means the system would only verify the signature if a signature existed.

If the payload was an unsigned JWT, the verification block would simply never run.

And yet the authentication flow continued.

Eventually, the code reached the step that converts JWT claims into an authenticated user profile.

The token’s claims were accepted as legitimate.

Authentication succeeded.

Even though no signature had ever been verified.

Turning That Behavior Into an Attack

Once our team understood this execution path, the next step was determining whether an attacker could intentionally trigger it.

The answer was straightforward.

Instead of creating a signed JWT, an attacker could create an unsigned PlainJWT.

Then they could encrypt it inside a JWE wrapper using the server’s public key.

Because the outer token was encrypted correctly, the server would decrypt it successfully.

But because the inner token was unsigned, the signature verification logic would never run.

The server would accept the claims as authenticated.

Why the Public Key Was Enough

In RSA-based encryption setups, the server’s public key is intentionally public.

Clients need it in order to encrypt tokens.

Common places where public keys appear include:

  • JWKS endpoints

  • configuration files

  • TLS certificate metadata

  • public source repositories

This is normal.

The public key is supposed to allow encryption.

Only the private key should allow decryption.

The vulnerability exploited a subtle consequence of this design.

Because the system trusted encrypted tokens before verifying their signatures, anyone who possessed the public key could create tokens the server would accept.

Crafting the Malicious Token

The exploit required only a few steps.

First, the attacker creates arbitrary claims.

For example:


Next, the attacker creates a PlainJWT containing those claims.

No signature is applied.

Finally, the token is encrypted using the server’s public key.

The resulting token appears perfectly valid.

When submitted to the server:


In testing, our proof-of-concept authenticated successfully on the first attempt.

When Security Components Compose Incorrectly

What makes this vulnerability particularly interesting is that no individual component behaved incorrectly.

The JWT specification allows PlainJWT.

The Nimbus library correctly returns null for non-signed tokens.

The pac4j code correctly checks for null.

Each piece of logic works exactly as designed.

But when these components interact, an unexpected behavior appears.

This type of issue is known as a composition vulnerability.

The primitives are secure.

The interaction between them is not.

These bugs are often harder to detect than traditional vulnerabilities because the individual lines of code appear correct during review.

Only when the entire execution path is analyzed does the flaw become visible.

Responsible Disclosure

Once our security engineers verified the vulnerability and built a working proof-of-concept, we privately disclosed the issue to pac4j maintainer Jérôme Leleu.

The disclosure included:

  • full technical analysis

  • proof-of-concept exploit

  • remediation suggestions

The maintainer responded quickly and confirmed the vulnerability.

Within two business days:

  • patched versions were released across three major pac4j versions

  • a public security advisory was published

  • our research team was credited for the discovery

This rapid response highlights the dedication of open-source maintainers who safeguard critical infrastructure used across the industry.

Are You Using a Vulnerable Version?

Applications may be affected if they use:

  • pac4j-jwt

  • RSA-based encrypted JWTs

  • JwtAuthenticator with both encryption and signature configurations

The vulnerability has been patched in the following versions:


Organizations should upgrade immediately.

The Larger Lesson

Security failures rarely occur because cryptographic primitives are weak.

They occur because systems make assumptions about how those primitives will be used.

Encryption protects confidentiality.

Signatures protect authenticity.

Confusing those two properties can collapse an entire authentication system.

The pac4j vulnerability is a clear example.

A system designed to enforce two layers of protection ended up relying on only one.

And encryption alone is never enough to prove identity.

Encryption Protects Secrets: Not Identity

The pac4j vulnerability demonstrates a recurring lesson in application security.

Cryptography rarely fails because the algorithms are weak.

It fails because systems misinterpret what those algorithms guarantee.

Encryption answers one question:

Can someone read this data?

Authentication answers a completely different one:

Who created this data?

When systems blur the boundary between those guarantees, security assumptions collapse.

In the pac4j case, the authentication pipeline assumed that a successfully decrypted token must also be trustworthy.

But encryption alone never proves identity.

Only signature verification can provide that guarantee.

The result was a CVSS-10 authentication bypass capable of granting administrative access using nothing more than a public key.

This type of vulnerability is increasingly common in modern software systems built from layered frameworks and dependencies.

Every component may behave correctly in isolation.

But when assumptions between those components diverge, security guarantees can disappear.

The pac4j finding highlights why modern security research increasingly focuses on trust boundaries and execution paths rather than individual lines of code.

And it shows how subtle design assumptions can create vulnerabilities with ecosystem-wide impact.

For the full technical analysis and exploit walkthrough, see the original research disclosure from the CodeAnt AI Security Research team.

FAQs

What is CVE-2026-29000?

Why does encryption not prove identity?

What caused the pac4j JWT vulnerability?

Why was a public key enough to exploit the vulnerability?

Which pac4j versions are affected?

Table of Contents

Start Your 14-Day Free Trial

AI code reviews, security, and quality trusted by modern engineering teams. No credit card required!

Share blog: