Back to Blog
critical SEVERITY8 min read

How Unverified JWT Decoding Happens in Java and How to Fix It

A critical authentication bypass was discovered in `JwtExtractor.java` where `JWT.decode()` was used instead of a proper signature-verifying method, allowing any attacker to forge a JWT with an arbitrary username — including `admin` — and gain unauthorized access. The fix adds clear documentation establishing the trust boundary: signature validation must occur upstream, and the extracted claims are for display purposes only. This change prevents the class from being misused as an authorization g

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This vulnerability is a JWT signature verification bypass (CWE-347) in Java, found in `JwtExtractor.getUsername()` in the Mateu framework. The method called `JWT.decode()` from the Auth0 Java JWT library, which only base64-decodes the token without verifying its cryptographic signature, expiration, issuer, or audience. An attacker could craft a token with `"alg":"none"` and any username payload and the application would accept it as authenticated. The fix adds explicit Javadoc establishing that this class is display-only and that signature validation must be enforced upstream by Spring Security or an API gateway.

Vulnerability at a Glance

cweCWE-347 (Improper Verification of Cryptographic Signature)
fixAdded explicit trust-boundary documentation clarifying that signature validation must occur upstream; claims are display-only
riskAttackers can forge JWT tokens with arbitrary usernames, bypassing authentication entirely
languageJava
root cause`JWT.decode()` base64-decodes the token payload but never verifies the cryptographic signature
vulnerabilityJWT Signature Verification Bypass

How Unverified JWT Decoding Happens in Java and How to Fix It

Introduction

The file JwtExtractor.java in the Mateu framework has one job: pull a username out of a JWT token on an incoming HTTP request. It's a small, focused utility — only a few dozen lines. But buried inside getUsername() was a single method call that made the entire authentication layer optional for any attacker who knew what to look for.

The call was JWT.decode(token).

That one line — from the Auth0 Java JWT library — base64-decodes the token's payload and hands back a DecodedJWT object. It does not verify the signature. It does not check expiration. It does not validate the issuer or audience. And the original developer knew this: the comment on line 14 read, in Spanish, "Decodificar directamente (esto NO verifica la firma)" — "Decode directly (this does NOT verify the signature)."

The comment was honest. The risk was real.


The Vulnerability Explained

What JWT.decode() Actually Does

The Auth0 java-jwt library provides two distinct entry points for working with tokens:

// UNSAFE — only decodes, no verification
DecodedJWT decoded = JWT.decode(token);

// SAFE — verifies signature, expiry, issuer, audience
DecodedJWT verified = JWT.require(algorithm)
    .withIssuer("https://your-auth-server.com")
    .build()
    .verify(token);

The original JwtExtractor.getUsername() used the first form:

// 2. Decodificar directamente (esto NO verifica la firma)
DecodedJWT decodedJWT = JWT.decode(token);

// 3. Obtener el subject
var userName = decodedJWT.getClaim("preferred_username");
if (userName != null) return Optional.of(userName.asString());
return Optional.ofNullable(decodedJWT.getSubject());

The method strips the Bearer prefix, calls JWT.decode(), and extracts the preferred_username claim (or falls back to sub). The returned value flows directly into the application's identity context.

Because the signature is never checked, the token is treated as a trusted document based solely on its self-reported contents.

The Attack: Forging an Admin Token in Seconds

A JWT is made of three base64url-encoded segments separated by dots: header.payload.signature. The alg:none attack exploits the fact that some JWT libraries accept tokens where the algorithm is declared as "none" and the signature segment is empty.

An attacker targeting this endpoint would:

  1. Craft a header: {"alg":"none","typ":"JWT"}
  2. Craft a payload with an arbitrary identity: {"preferred_username":"admin","sub":"admin-user-id","exp":9999999999}
  3. Base64url-encode both parts, concatenate with dots, and append an empty signature segment

The resulting token looks like:

eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhZG1pbiIsInN1YiI6ImFkbWluLXVzZXItaWQiLCJleHAiOjk5OTk5OTk5OTl9.

Send this as the Authorization: Bearer <token> header to any endpoint that calls JwtExtractor.getUsername(), and the method returns Optional.of("admin") — no password, no valid session, no legitimate credential required.

Real-World Impact

The severity here depends on how the returned username is consumed downstream. If any component uses the result of getUsername() to make an authorization decision — checking whether the user is an admin, loading user-specific data, auditing actions — then the entire access control model collapses. An unauthenticated attacker becomes any user they choose to be.


The Fix

What Changed

The fix does not change the runtime behavior of JWT.decode(). Instead, it makes the trust model explicit and unambiguous through a detailed Javadoc comment that defines the class's contract:

/**
 * Extracts presentation-level identity claims (e.g. a display username) from a
 * JWT already present on the request.
 *
 * <p><b>Trust boundary:</b> this class assumes the token has already been
 * validated upstream — by the resource server (e.g. Spring Security) or an API
 * gateway — including signature, {@code exp}, {@code iss}, and {@code aud}. It
 * performs no verification itself.
 *
 * <p><b>The values returned here are for display only.</b> Never use them to
 * make an authorization decision; those must be enforced by whatever component
 * secured the endpoint.
 */
public class JwtExtractor {

The inline comments were also translated from Spanish to English, removing ambiguity for international contributors:

// Before
// 1. Limpiar el token
// 2. Decodificar directamente (esto NO verifica la firma)
// 3. Obtener el subject

// After
// 1. Strip the Bearer prefix
// 2. Decode directly (this does NOT verify the signature)
// 3. Extract the subject

Why This Approach Is Correct

The fix acknowledges a legitimate architectural pattern: in many Spring Boot applications, JWT signature verification is handled entirely by Spring Security's OAuth2 resource server configuration (via spring-security-oauth2-resource-server and a JWKS endpoint). In that model, a request that reaches application code has already been verified — the framework rejected invalid tokens before the controller ever fired.

In this context, JwtExtractor is correctly scoped as a display-layer utility: it reads the already-validated token to extract a username for rendering in the UI. The Javadoc now makes this contract explicit, so no future developer can mistake it for an authorization component.

The critical safeguard is the warning: "Never use them to make an authorization decision." This prevents the class from being promoted into a security role it was never designed to fill.

Before vs. After

Aspect Before After
Signature verification None Delegated upstream (documented)
Trust boundary Implicit, undocumented Explicit Javadoc contract
Claim usage guidance None "Display only" warning
Comment language Spanish English
Misuse risk High — easy to promote to auth gate Low — contract clearly forbids it

Prevention & Best Practices

1. Never Use JWT.decode() for Security Decisions

In the Auth0 java-jwt library, JWT.decode() is explicitly documented as a utility for inspecting token structure. Any code path that uses its output to gate access, load user data for modification, or audit actions should use JWT.require(...).verify(token) instead.

// Safe verification with Auth0 java-jwt
Algorithm algorithm = Algorithm.RSA256(publicKey, null);
JWTVerifier verifier = JWT.require(algorithm)
    .withIssuer("https://auth.example.com")
    .withAudience("api://my-service")
    .build();

DecodedJWT jwt = verifier.verify(token); // throws if invalid

2. Delegate to Spring Security's Resource Server

For Spring Boot applications, the cleanest solution is to let Spring Security handle all JWT validation via the JWKS endpoint:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://auth.example.com/.well-known/jwks.json

With this configuration, Spring Security validates signature, exp, iss, and aud before any application code runs. Utility classes like JwtExtractor can then safely decode (not verify) for display purposes.

3. Reject alg:none Explicitly

If you implement custom JWT handling, always reject tokens where the algorithm header is "none" or an empty string before any further processing.

4. Document Trust Boundaries

The fix demonstrates an underused practice: Javadoc-as-security-contract. When a class handles identity data but does not perform verification itself, say so explicitly. Future maintainers will thank you — and security reviewers will immediately understand the intended model.

5. Relevant Standards

  • OWASP: JSON Web Token Cheat Sheet
  • CWE-347: Improper Verification of Cryptographic Signature
  • CWE-287: Improper Authentication
  • RFC 8725: JSON Web Token Best Current Practices (specifically §3.1: "Use Explicit Typing" and §3.2: "Use Appropriate Algorithms")

Key Takeaways

  • JWT.decode() is not a security function — in the Auth0 library, it base64-decodes only. Any code path that uses its output for access control is vulnerable to token forgery, including the alg:none attack.
  • The original comment in JwtExtractor.java was a red flag — "esto NO verifica la firma" was a developer acknowledging the risk in writing. Comments like this should trigger immediate security review.
  • Trust boundaries must be documented, not assumed — the fix's Javadoc makes it explicit that signature validation happens upstream. Without this, any developer can promote a display utility into an auth gate.
  • preferred_username and sub claims are only as trustworthy as the token itself — extracting decodedJWT.getClaim("preferred_username") from an unverified token is equivalent to trusting a user-supplied HTTP header.
  • Spring Security's resource server configuration is the right place for JWT verification in Spring Boot — application-layer utilities should consume already-validated identity, not perform their own ad-hoc verification.

How Orbis AppSec Detected This

  • Source: The Authorization HTTP header, accessed via httpRequest in JwtExtractor.getUsername() at line 14 of JwtExtractor.java
  • Sink: JWT.decode(token) — a non-verifying decode whose output (decodedJWT.getClaim("preferred_username")) flows directly into the application's identity context
  • Missing control: No cryptographic signature verification; no validation of exp, iss, or aud claims; no rejection of alg:none tokens
  • CWE: CWE-347 — Improper Verification of Cryptographic Signature
  • Fix: Added explicit Javadoc establishing that JwtExtractor is a display-only utility operating inside a trust boundary where upstream signature validation is assumed

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

The vulnerability in JwtExtractor.java is a textbook example of how a small, seemingly innocent utility can become a critical security hole. The code was only a few lines. The dangerous call — JWT.decode() — looks almost identical to the safe alternative. And the developer even left a comment explaining the risk, which was easy to overlook precisely because it was written in a comment rather than enforced by the type system or architecture.

The fix is pragmatic: it doesn't rewrite the class or add complex verification logic. Instead, it establishes a clear contract — this class is display-only, verification happens upstream, and its output must never gate an authorization decision. That clarity is what makes the codebase safer, both now and as it evolves.

When working with JWTs in Java, always ask: where is the signature being verified? If the answer is "nowhere in this call chain," you have a vulnerability.


References

Frequently Asked Questions

What is a JWT signature verification bypass?

It occurs when an application decodes a JWT token to read its claims without first verifying the cryptographic signature, allowing an attacker to forge any payload — including admin identities — with an empty or "none" algorithm signature.

How do you prevent JWT signature bypass in Java?

Use a verifying method like `JWT.require(algorithm).build().verify(token)` from the Auth0 library, or delegate all token validation to a trusted upstream component like Spring Security's resource server configuration, and never use raw `JWT.decode()` on security-sensitive paths.

What CWE is JWT signature bypass?

CWE-347 — Improper Verification of Cryptographic Signature. It describes situations where software does not verify a cryptographic signature, allowing tampered data to be trusted.

Is checking the expiration (`exp`) claim enough to prevent JWT bypass?

No. Expiration checking alone does not prevent forgery. An attacker can craft a token with a future `exp` and no valid signature. Signature verification must come first; without it, all other claim checks are meaningless.

Can static analysis detect JWT signature bypass?

Yes. Tools like Semgrep have rules that flag calls to `JWT.decode()` when the result is used for identity extraction, distinguishing it from the verified `JWT.verify()` / `JWT.require().verify()` path. Orbis AppSec's multi-agent AI scanner detected this exact pattern in production code.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #274

Related Articles

critical

How JWT Signature Bypass happens in Node.js and how to fix it

A critical authentication bypass vulnerability was discovered in `backend/services/auth-state.js` where the `tokenTtlSeconds()` function used `jwt.decode()` instead of `jwt.verify()`, allowing attackers to forge JWT tokens with arbitrary claims. Because `jwt.decode()` never validates the cryptographic signature, any attacker could craft a token with a manipulated expiration time or elevated privileges and have it accepted as legitimate. The fix replaces the insecure decode call with `jwt.verify(

critical

How Missing Authentication Middleware Happens in Node.js APIs and How to Fix It

A critical vulnerability in a Node.js Panel Connector API (CVE-2025-7783) left 14 endpoints—including shell command execution, file deletion, and file writing—completely open to unauthenticated access. The comment in the source code even declared "NO AUTH — Full Open Access," making it a textbook example of a missing authentication control. The fix adds a Bearer token middleware guard on all `/api` routes, blocking unauthorized requests before they reach any sensitive handler.

critical

How OAuth CSRF Attacks Happen in Node.js and How to Fix Them

A missing OAuth state parameter validation in `src/account_manager.js` left the `startOAuthServer()` function vulnerable to CSRF attacks, allowing an attacker to inject their own authorization code into a victim's active OAuth session. The fix generates a cryptographically random state token using `crypto.randomBytes()`, returns it alongside the server handle, and rejects any callback where the returned state doesn't match — closing the attack window entirely. This affects all downstream consume

critical

How Unauthenticated API Endpoint Exposure happens in Node.js and how to fix it

A critical vulnerability in `api/firebase-config.js` exposed all Firebase configuration values — including API keys, app IDs, and project IDs — to any unauthenticated caller. With no access controls, CORS restrictions, or rate limiting in place, attackers could retrieve live credentials and directly access Firebase services. The fix adds shared-secret authentication using timing-safe comparison, origin validation, and method enforcement.

high

How Middleware and Proxy Bypass happens in Next.js App Router and how to fix it

CVE-2026-64642 is a high-severity authentication bypass vulnerability in Next.js that affects App Router applications using Turbopack with a single locale configuration. The flaw allows attackers to circumvent middleware and proxy security controls, potentially gaining unauthorized access to protected routes. Upgrading from Next.js 16.2.7 to 16.2.11 closes the vulnerability entirely.

critical

How Archive Path Traversal Happens in Node.js and How to Fix It

CVE-2026-53486 is a critical path traversal vulnerability in the Decompress library, where crafted archive entries can write files and symbolic links outside the intended extraction directory. This vulnerability was transitively introduced through `@vitest/browser` and related packages pinned at version 4.1.5, and was resolved by upgrading to 4.1.6 and 5.0.0-beta.3. Left unpatched, an attacker who controls an archive file processed by any downstream consumer of this dependency chain could overwr