JWT: Proper Usage and Common Security Mistakes

1. Overview

JWT (JSON Web Token) is used everywhere for API authentication, but many systems implement it incorrectly, leading to serious vulnerabilities. This article covers how to create and verify JWT correctly in Spring Boot, along with classic security mistakes (algorithm confusion, weak secrets, missing expiration checks, and more) and how to avoid them. After reading, you’ll know how to configure JWT safely and review your colleagues’ JWT code with confidence.

2. JWT Structure and How It Works

JWT consists of three parts: header.payload.signature, base64url-encoded, separated by dots.

eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbGljZSJ9.4Z... (signature)
  • Header: contains alg (signing algorithm) and typ.
  • Payload: contains claims like sub, exp, iat, roles.
  • Signature: a signature to verify the token hasn’t been tampered with.

Note: JWT does not encrypt the payload, only encodes it. Anyone with the token can read the contents. Don’t include passwords or sensitive PII in the claims.

3. Add Dependencies

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

The jjwt library (io.jsonwebtoken) is the most popular for Java, with a clear API and built-in protection against common mistakes.

4. Creating and Verifying Tokens Correctly

4.1. Generating a Strong Secret Key

import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;

public class JwtKeyFactory {

    public static SecretKey generateKey() {
        return Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
    }
}

Keys.secretKeyFor generates a random key of sufficient length for HS256 (≥ 256 bits). In production, store this key in a secret manager (Vault, AWS Secrets Manager), not hardcoded in the code.

4.2. Creating Tokens with TokenService

import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;
import java.time.Instant;
import java.util.Date;

public class TokenService {

    private final SecretKey key;

    public TokenService(SecretKey key) {
        this.key = key;
    }

    public String generateToken(String username) {
        Instant now = Instant.now();
        return Jwts.builder()
                .subject(username)
                .issuedAt(Date.from(now))
                .expiration(Date.from(now.plusSeconds(900))) // 15 minutes
                .signWith(key)
                .compact();
    }
}
  • expiration is set to 15 minutes — access tokens should be short-lived.
  • signWith(key) automatically chooses an appropriate algorithm based on the key type, preventing clients from tampering with alg.

4.3. Verifying Tokens

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import javax.crypto.SecretKey;

public class TokenVerifier {

    private final SecretKey key;

    public TokenVerifier(SecretKey key) {
        this.key = key;
    }

    public Claims verify(String token) {
        try {
            return Jwts.parser()
                    .verifyWith(key)
                    .build()
                    .parseSignedClaims(token)
                    .getPayload();
        } catch (JwtException e) {
            throw new InvalidTokenException("Token is invalid or has expired", e);
        }
    }
}

parseSignedClaims automatically verifies the signature and exp claim. If the signature is invalid or the token is expired, it throws JwtException — which you can catch and convert to your own domain exception.

5. Security Issue #1: alg=none or Algorithm Confusion

An attacker modifies the header to {"alg":"none"} and sends a token without a signature. If the backend doesn’t enforce the algorithm, it may accept the forged token.

// WRONG: no algorithm whitelist
Jwts.parser().build().parseSignedClaims(token); // old libraries are easily fooled

How to prevent it — always whitelist the algorithm:

import io.jsonwebtoken.Jwts;

Jwts.parser()
        .verifyWith(key)          // only accept the known key
        .build()
        .parseSignedClaims(token); // jjwt 0.12+ blocks alg=none by default

Note: With other JWT libraries (jose4j, nimbus-jose-jwt), always configure the algorithm whitelist explicitly (for example, only allow HS256). Never trust the alg field in tokens sent by the client.

A more dangerous variant: algorithm confusion between RS256 and HS256. If the server verifies using an RSA public key but the code uses it as a secret for HMAC when alg=HS256, an attacker can sign tokens themselves using the public key (which is public). Solution: pin the algorithm to your use case; don’t let the code read alg from the token and dynamically choose how to verify.

6. Security Issue #2: Weak or Exposed Secrets

// WRONG: short secret, easily brute-forced
String secret = "mysecret123";
SecretKey key = Keys.hmacShaKeyFor(secret.getBytes());

HS256 requires a key ≥ 256 bits (32 bytes). Short secrets can be brute-forced offline using tools like hashcat in a few hours.

// CORRECT: long, random secret
SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);

In addition:

  • Don’t commit secrets to Git. Use environment variables or a secret manager.
  • Rotate secrets periodically, especially after personnel with secret access leave.
# application.properties - read from env, not hardcoded
jwt.secret=${JWT_SECRET}

7. Security Issue #3: Missing Expiration Checks or Long-Lived Tokens

// WRONG: access token lives for 30 days
.expiration(Date.from(now.plus(30, ChronoUnit.DAYS)))

Long-lived access tokens mean that if compromised, an attacker has long-term access that you can’t revoke (JWT is stateless; there’s no logout mechanism by default).

Correct pattern: short-lived access token (5–15 minutes) + longer-lived refresh token stored on the server (or rotated) so you can revoke it.

public class TokenPair {
    private final String accessToken;   // lives 15 minutes
    private final String refreshToken;  // lives 7 days, stored in DB so it can be revoked

    // constructor, getters
}
public class RefreshTokenRepository {

    public boolean isRevoked(String refreshTokenId) {
        // query DB/Redis, if token is revoked return true
        return false;
    }
}

When a refresh token is revoked, the old access token still lives until it expires — this is why access tokens must be short-lived.

8. Security Issue #4: Putting Sensitive Data in Payload

// WRONG: payload is public, anyone with the token can read it
.claim("password", user.getPassword())
.claim("creditCard", user.getCardNumber())

The payload is only base64-encoded, not encrypted. Anyone can decode it with jwt.io and see everything. Only include claims needed for authorization: sub, roles, exp, scope.

// CORRECT
.subject(user.getUsername())
.claim("roles", user.getRoles())

9. Security Issue #5: Missing Issuer/Audience Validation When Sharing Secrets Across Services

If multiple services share the same secret to sign tokens, a token issued for Service A can be replayed to Service B if you don’t check the aud (audience) claim.

import io.jsonwebtoken.Jwts;

Claims claims = Jwts.parser()
        .verifyWith(key)
        .requireAudience("order-service") // only accept tokens intended for this service
        .requireIssuer("auth-server")
        .build()
        .parseSignedClaims(token)
        .getPayload();

requireAudience and requireIssuer force the parser to throw an exception if the claims don’t match — preventing tokens from being used for the wrong purpose.

10. Integration with Spring Security Filter

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;

public class JwtAuthFilter extends OncePerRequestFilter {

    private final TokenVerifier verifier;

    public JwtAuthFilter(TokenVerifier verifier) {
        this.verifier = verifier;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain chain) throws IOException, ServletException {
        String header = request.getHeader("Authorization");
        if (header != null && header.startsWith("Bearer ")) {
            try {
                var claims = verifier.verify(header.substring(7));
                var auth = new UsernamePasswordAuthenticationToken(
                        claims.getSubject(), null, List.of());
                SecurityContextHolder.getContext().setAuthentication(auth);
            } catch (InvalidTokenException e) {
                SecurityContextHolder.clearContext();
            }
        }
        chain.doFilter(request, response);
    }
}
  • Extract the token from the Authorization: Bearer <token> header.
  • Verify using the TokenVerifier written in section 4.3.
  • If the token is invalid, clear the context — the request will be blocked by Spring Security in the next authorization step, without throwing an exception directly in the filter.

Note: Don’t store JWT in localStorage if your frontend is a public SPA — it can be stolen via XSS. Prefer storing it in HttpOnly, Secure, SameSite=Strict cookies when possible.

11. Testing Token Verification Behavior

import io.jsonwebtoken.security.Keys;
import org.junit.jupiter.api.Test;
import javax.crypto.SecretKey;
import java.time.Instant;

import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThat;

class TokenVerifierTest {

    private final SecretKey key = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
    private final TokenService tokenService = new TokenService(key);
    private final TokenVerifier verifier = new TokenVerifier(key);

    @Test
    void shouldVerifyValidToken() {
        String token = tokenService.generateToken("alice");

        var claims = verifier.verify(token);

        assertThat(claims.getSubject()).isEqualTo("alice");
    }

    @Test
    void shouldRejectTokenSignedWithDifferentKey() {
        SecretKey otherKey = Keys.secretKeyFor(io.jsonwebtoken.SignatureAlgorithm.HS256);
        TokenService fakeService = new TokenService(otherKey);
        String forgedToken = fakeService.generateToken("attacker");

        assertThatThrownBy(() -> verifier.verify(forgedToken))
                .isInstanceOf(InvalidTokenException.class);
    }
}

The second test accurately simulates an attacker signing a token with a different key — the verifier must reject it, never silently accept it.

shouldVerifyValidToken()                 PASS
shouldRejectTokenSignedWithDifferentKey() PASS

12. Quick Checklist Before Deployment

  • Secret ≥ 256 bits, stored in a secret manager, never hardcoded.
  • Whitelist the algorithm, don’t trust alg from the token.
  • Keep access tokens short-lived (≤ 15 minutes) with a refresh token for revocation.
  • Don’t include sensitive data in the payload.
  • Validate exp, iss, aud when necessary.
  • Store tokens in HttpOnly cookies instead of localStorage for SPA frontends.
  • Have a revocation/blacklist mechanism for refresh tokens.

13. Conclusion

JWT itself is not flawed — most issues come from implementation: weak secrets, trusting alg from the client, overly long-lived tokens, or putting sensitive data in the public payload. Follow the checklist in section 12 and always use tested JWT libraries (jjwt, nimbus-jose-jwt) instead of writing your own parser, and you’ll avoid most common vulnerabilities. The next step worth exploring: OAuth 2.0 / OpenID Connect for better token lifecycle management across multiple services.

jwtweb-securityspring-bootauthenticationjava