Description
Parsing a JWT is different from verifying a trusted signature. Accepting an authentication token without verification lets an attacker forge user or permission claims. Configuring a verification key is insufficient if the parsing path also accepts unsigned tokens.
JJWT behavior varies by version. The generic parse() in the JJWT 0.11.x examples below can process unsigned JWTs. Version 0.12 and later reject unsigned JWTs by default, so a generic parsing call does not always mean signature verification is bypassed.
Potential impact
- Account impersonation or privilege escalation through forged claims
- Acceptance of tokens from an untrusted issuer or intended for another service
- Unauthorized data access or changes made in reliance on those tokens
Remediation
- Use an API that requires signed claims. With JJWT 0.12 and later, use
Jwts.parser().verifyWith(key).build().parseSignedClaims(token)and do not enable unsigned tokens. The corresponding 0.11.x API isparseClaimsJws(). - Select verification keys and permitted algorithms from trusted configuration. For key rotation, use a reviewed key set or
keyLocator; never fetch keys from an arbitrary token-supplied URL. - Check expiration, issuer, intended audience and required claims. Signature verification does not establish every authorization requirement.
- Treat
JwtExceptionand other validation failures as authentication rejection. Never fall back to decoding and trusting a failed token.
Examples
These Spring excerpts compare two JJWT 0.11.x parsing paths. They assume a sufficiently long SecretKey appropriate for the HMAC algorithm, injected from trusted configuration. Configure claim policies and authentication error responses in the application as well.
Before
import io.jsonwebtoken.*;
import javax.crypto.SecretKey;
import org.springframework.web.bind.annotation.*;
@RestController
class TokenController {
private final SecretKey key;
TokenController(SecretKey key) {
this.key = key;
}
@GetMapping("/me")
public String me(@RequestHeader("Authorization") String authz) {
if (!authz.startsWith("Bearer ")) {
throw new JwtException("missing bearer token");
}
String token = authz.substring(7);
// BAD: parse() can also accept an unsigned JWT
Jwt<?, ?> jwt = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parse(token);
Claims claims = (Claims) jwt.getBody(); // Claims may not have a verified signature
return claims.getSubject();
}
}
After
import io.jsonwebtoken.*;
import javax.crypto.SecretKey;
import org.springframework.web.bind.annotation.*;
@RestController
class TokenControllerSafe {
private final SecretKey key;
TokenControllerSafe(SecretKey key) {
this.key = key;
}
@GetMapping("/me")
public String me(@RequestHeader("Authorization") String authz) {
if (!authz.startsWith("Bearer ")) {
throw new JwtException("missing bearer token");
}
String token = authz.substring(7);
// GOOD: parseClaimsJws() requires signature verification
Jws<Claims> jws = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
Claims claims = jws.getBody();
// Additional validation
if (!"my-issuer".equals(claims.getIssuer())) {
throw new JwtException("invalid iss");
}
return claims.getSubject();
}
}
parseClaimsJws() requires signed claims and verifies the signature before the issuer check. Replace my-issuer with the actual trusted issuer.