JWT is a Format, Not an Auth Protocol: Fixing Your Java Security Model

I rejected three pull requests last Tuesday for the exact same architectural misunderstanding. Actually, let me back up — someone had written “implemented JWT authentication” in their commit message, and it made my eye twitch.

Look, JWT is just a string. It’s a base64-encoded JSON object with a cryptographic signature attached to it. It doesn’t authenticate anyone. Conflating the format used to transmit state with the actual process of verifying a user’s identity is how you end up with security holes that don’t get caught until a penetration tester hands you a very expensive PDF report.

Authentication is the act of proving you are who you say you are. That means checking a password against a hash, validating a biometric challenge, or performing an OAuth2 flow. A JSON Web Token is just the temporary nametag the bouncer hands you after you’re already inside the club.

And if your Java backend treats token parsing as the actual authentication step, your abstractions are probably bleeding into each other. Let’s fix that.

Separating the Bouncer from the Nametag

The biggest mistake I see in Java codebases is a massive AuthService class that handles database lookups, password hashing, and token generation all in one 500-line method.

JSON code screen - Close-up of computer screen displaying lines of code in green and ...
JSON code screen – Close-up of computer screen displaying lines of code in green and …

We need to separate the identity provider from the token issuer. I usually start by defining strict interfaces. I’m using Java 21 records here because they are — in my opinion — perfect for immutable security principals.

public record UserPrincipal(String userId, String email, List<String> roles) {}

// This handles actual authentication
public interface IdentityProvider {
    UserPrincipal verifyCredentials(String username, char[] password);
}

// This handles state transmission (the nametag)
public interface TokenIssuer {
    String mintToken(UserPrincipal principal);
}

By splitting these up, you immediately clarify your architecture. If you decide to drop passwords and move entirely to passkeys next year, your TokenIssuer doesn’t care. It just takes a UserPrincipal and spits out a signed string.

The Symmetric Key Disaster

I burned an entire weekend in late 2024 dealing with a compromised microservice. The original developers had used a symmetric key (HS256) to sign their JWTs. They shared that single secret key across five different services so they could all validate the incoming tokens.

Do you see the problem?

If you use a symmetric key, anyone who can read the token can also forge the token. A low-priority analytics service was compromised, the attacker grabbed the shared secret from the environment variables, and suddenly they were minting their own admin tokens.

Always use asymmetric encryption (RS256 or ES256) for distributed systems. The authentication server holds the private key to sign the tokens. Every other service only gets the public key to verify them.

cybersecurity padlock - Free Global Cybersecurity Lock Image - Technology, Cybersecurity ...
cybersecurity padlock – Free Global Cybersecurity Lock Image – Technology, Cybersecurity …

Here is how I typically implement the validation side in a downstream service using the jjwt library (version 0.12.5). Notice how we’re only doing authorization checks here, not authentication.

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import java.security.PublicKey;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

public class SecurityContextBuilder {
    private final PublicKey rsaPublicKey;

    public SecurityContextBuilder(PublicKey rsaPublicKey) {
        this.rsaPublicKey = rsaPublicKey;
    }

    public Set<String> extractHighPrivilegeRoles(String token) {
        // The parser verifies the signature automatically using the public key.
        // If the signature is invalid or the token is expired, this throws a JwtException.
        Claims claims = Jwts.parser()
            .verifyWith(rsaPublicKey)
            .build()
            .parseSignedClaims(token)
            .getPayload();

        List<?> rawRoles = claims.get("roles", List.class);
        if (rawRoles == null || rawRoles.isEmpty()) {
            return Set.of();
        }

        // Using streams to safely filter and map the untyped claims
        return rawRoles.stream()
            .map(Object::toString)
            .filter(role -> role.startsWith("ADMIN_") || role.equals("SUPERUSER"))
            .collect(Collectors.toUnmodifiableSet());
    }
}

When we finally migrated our staging cluster to an asymmetric RS256 setup using Java’s updated crypto providers, we actually saw a slight performance hit. It added about 4ms extra per validation compared to the old symmetric check. But I will gladly trade 4 milliseconds for the guarantee that a rogue reporting service can’t elevate its own privileges.

The Revocation Problem

You can’t talk about tokens without addressing the elephant in the room. JWTs are stateless. Once you issue one, it’s valid until it expires. If a user clicks “log out,” deleting the token from their browser doesn’t invalidate it on your server. If an attacker copied it, they still have access.

I’ve seen teams try to fix this by building a database table of “blacklisted” JWTs that every request checks against. At that point, congratulations—you’ve just reinvented stateful session cookies, but with significantly more overhead and worse latency.

My approach is to keep token lifespans incredibly short. Five minutes, maximum. Pair that with an opaque refresh token stored in an HTTP-only, secure cookie. When the short-lived token expires, the client hits a refresh endpoint. That’s where you actually check the database to see if the user was banned or logged out.

By Q1 2027, I probably expect we’ll see fewer raw JWTs being passed around entirely. More enterprise teams are generally shifting toward opaque tokens with introspection endpoints for their high-security boundaries, leaving JWTs only for internal service-to-service communication behind the firewall.

Until then, just remember what the tool is actually built for. Get your identity verification out of your token parsing logic, stop sharing symmetric secrets, and stop calling it JWT authentication.