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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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

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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #274

Related Articles

critical

Music Studio App Express Server: CWE-287 Authentication Bypass via

A critical authentication bypass vulnerability in a music studio application's Express server allowed remote attackers to access API endpoints despite the intended localhost-only restriction. The localRequest middleware validated the Host header but failed to verify the actual remote socket address, enabling trivial bypasses in containerized and multi-user environments.

high

CVE-2026-104850: MCP TypeScript SDK OAuth Credential Leak

The MCP TypeScript SDK (`@modelcontextprotocol/sdk`) contained a flaw in its OAuth client flow where the MCP server being connected to could influence which authorization server the client talked to, meaning client credentials and token-exchange traffic could be directed to an endpoint the attacker controls. This project was pinned to the affected `1.30.0`; the dependency has been moved to `^1.31.0`, which carries the upstream fix for CVE-2026-104850. Any MCP client that performs OAuth against t

high

SQLite Auth Database Plaintext Storage in InitAuthDB()

The `InitAuthDB()` function opened SQLite databases without restricting filesystem permissions, leaving bcrypt password hashes, session tokens, and user settings exposed to any local account with read access. The fix explicitly sets `os.Chmod(filepath, 0600)` immediately after database creation, ensuring only the owner can access authentication secrets.

high

undici 8.10.0 Cache Poisoning: CVE-2026-85152 Auth Bypass

undici, the HTTP client used by Node.js's `fetch()` implementation, shipped a caching layer that did not properly isolate cached responses by origin, letting a response poisoned on one origin be served to requests for another. This created a path to cross-origin authentication bypass, tracked as CVE-2026-85152 and fixed in undici 8.10.2.

critical

Hybridauth Telegram OAuth Timing Attack in `authenticateCheckError()`

The Telegram OAuth provider in Hybridauth relied on `strcmp()` to validate HMAC-SHA256 signatures, exposing a timing side-channel that could allow attackers to forge authentication tokens character by character. The fix replaces this with PHP's timing-safe `hash_equals()` function, eliminating the information leak.

critical

Node.js Auth Query SQL Injection via Group Code Interpolation

A sign-in authorization check built its SQL query by mapping an `authorizedGroups` array into quoted string literals and joining them directly into a template literal, creating a classic SQL injection point in a critical authentication path. The fix replaces every interpolated value — including the previously "typed" parameter — with `?` placeholders bound through a value-builder helper, closing off the injection vector entirely.