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 User Enumeration Happens in Django Forms and How to Fix It

A critical user enumeration vulnerability in the volunteers application allowed attackers to systematically discover registered email addresses through distinct error messages in signup and password reset forms. The fix replaces specific error messages with generic ones, preventing information disclosure while maintaining application functionality.

critical

How Authentication Bypass Happens in Node.js WebSocket Services and How to Fix It

The HousePanel push notification service exposed GET and POST endpoints without any authentication checks, allowing unauthenticated attackers to send arbitrary push notifications to connected smart devices. This critical vulnerability was fixed by implementing mandatory token validation on all protected endpoints, ensuring only authenticated requests can trigger push operations.

critical

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

The GitHub API integration in `src/github.mjs` was making unauthenticated requests, subjecting the application to GitHub's strict 60 requests/hour rate limit. This fix adds secure authentication token injection from environment variables using conditional header spreading, enabling authenticated requests with a much higher rate limit (5,000 requests/hour).

high

How OAuth 2.0 Authorization Code Interception happens in PHP and how to fix it

The Weibo OAuth login implementation in `trunk/web/login_weibo.php` was missing PKCE (Proof Key for Code Exchange), allowing attackers with network access to exchange intercepted authorization codes for access tokens. The fix adds cryptographic binding between the authorization request and token exchange using SHA256 code challenges.

high

How OAuth Token Binding Prevents Session Hijacking in Weibo Login Implementation

A critical vulnerability in the Weibo OAuth login implementation allowed attackers to replay stolen access tokens across different user sessions. By binding the OAuth access token to the session ID using cryptographic hashing, the fix ensures that intercepted tokens cannot be reused to hijack other sessions, even if compromised via MITM or XSS attacks.

high

How Unauthenticated Endpoint Exposure Happens in Node.js and How to Fix It

A high-severity unauthenticated endpoint exposure was discovered in `dep/src/server/index.js`, where the `/--ziko--` route served internal application state (`globalThis.Ziko`) to any network-connected client without any authentication or environment guard. The fix adds a single production environment check that returns a `404` before the sensitive data is ever sent. This kind of "debug route left in production" vulnerability is surprisingly common in Node.js applications and can silently leak c