JWT, OAuth2 & Securing APIs

Generating & Validating JWTs

18 min Lesson 3 of 13

Generating & Validating JWTs

The previous lesson explained what a JWT is. This lesson shows you how to build one and — equally important — how to verify one safely in a Spring Boot 3 application. By the end you will have a self-contained JwtService that your authentication filter and your tests can both depend on.

Choosing a Library: JJWT

The Java JWT (JJWT) library by Stormpath/Okta is the most widely used JWT library in the Spring ecosystem. It provides a fluent builder API for both generation and parsing, and it handles the Base64url encoding, signature computation, and claim validation for you. Add the three required JARs to your pom.xml:

<!-- JJWT — add to pom.xml --> <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 split into -api, -impl, and -jackson is intentional: your own code compiles against the stable API JAR only. The implementation and JSON serializer are runtime dependencies, so upgrading them never requires recompiling your classes.

Storing the Signing Key Safely

Every JWT you issue must be signed. The signature is what prevents a client from tampering with the payload. For HS256 (HMAC-SHA-256) you need a secret key of at least 256 bits (32 bytes). Store it in your application.yml as a Base64-encoded string and inject it with @Value — never hard-code it in a class file.

# application.yml security: jwt: secret: "7Tz4k9mQwXpL2nRvYcBsJdEgAhFuOiKe3lNqCbPrMfTzUwVxDyGjHoSiZaLmNpQr" expiration-ms: 86400000 # 24 hours
Generate the secret properly. Do not invent a string by hand — run openssl rand -base64 32 (or its equivalent) and store the output in your secrets manager or CI/CD environment variable, then inject it at runtime. A weak or predictable secret defeats the entire signature scheme.

Building the JwtService

Encapsulate all JWT logic in a single @Service class. This keeps the rest of your application ignorant of the underlying library.

package com.example.security; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.util.Base64; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.function.Function; @Service public class JwtService { private final SecretKey signingKey; private final long expirationMs; public JwtService( @Value("${security.jwt.secret}") String base64Secret, @Value("${security.jwt.expiration-ms}") long expirationMs) { byte[] keyBytes = Base64.getDecoder().decode(base64Secret); this.signingKey = Keys.hmacShaKeyFor(keyBytes); // validates >= 256-bit length this.expirationMs = expirationMs; } // ─── Generation ────────────────────────────────────────────────────────── public String generateToken(UserDetails userDetails) { return generateToken(new HashMap<>(), userDetails); } public String generateToken(Map<String, Object> extraClaims, UserDetails userDetails) { return Jwts.builder() .claims(extraClaims) .subject(userDetails.getUsername()) // "sub" claim .issuedAt(new Date()) // "iat" .expiration(new Date(System.currentTimeMillis() + expirationMs)) // "exp" .signWith(signingKey) // defaults to HS256 .compact(); } // ─── Validation ────────────────────────────────────────────────────────── public boolean isTokenValid(String token, UserDetails userDetails) { final String username = extractUsername(token); return username.equals(userDetails.getUsername()) && !isTokenExpired(token); } // ─── Claim Extraction ───────────────────────────────────────────────────── public String extractUsername(String token) { return extractClaim(token, Claims::getSubject); } public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) { final Claims claims = extractAllClaims(token); return claimsResolver.apply(claims); } // ─── Internal ──────────────────────────────────────────────────────────── private Claims extractAllClaims(String token) { // throws JwtException (ExpiredJwtException, SignatureException, etc.) // if anything is wrong — the filter layer catches this return Jwts.parser() .verifyWith(signingKey) .build() .parseSignedClaims(token) .getPayload(); } private boolean isTokenExpired(String token) { return extractClaim(token, Claims::getExpiration).before(new Date()); } }
Why Keys.hmacShaKeyFor()? This factory method validates that the raw byte array is long enough for the requested algorithm before creating the key object. If you pass a key that is too short, it throws a WeakKeyException at startup — exactly when you want to catch misconfiguration, not at runtime on the first request.

What Happens Inside extractAllClaims

When you call Jwts.parser().verifyWith(signingKey).build().parseSignedClaims(token), JJWT does the following in order:

  1. Splits the token on the two . separators into header, payload, and signature.
  2. Base64url-decodes the header and payload JSON.
  3. Recomputes the HMAC-SHA256 over base64url(header).base64url(payload) using your signing key.
  4. Compares the result with the signature in the token (constant-time comparison to resist timing attacks).
  5. Checks standard claims: exp (not expired), nbf (not used before, if present), iss/aud (if you configured them).
  6. Returns the Claims map, or throws a subclass of JwtException.

Every exception subclass carries precise information: ExpiredJwtException, SignatureException, MalformedJwtException, UnsupportedJwtException. Your authentication filter (Lesson 4) will catch JwtException as the common parent and respond with 401 Unauthorized.

Adding Custom Claims

You can embed any JSON-serialisable value as an extra claim. A typical use case is embedding the user's roles so the authorisation layer can read them directly from the token without a database lookup:

public String generateTokenWithRoles(UserDetails userDetails) { Map<String, Object> claims = new HashMap<>(); claims.put("roles", userDetails.getAuthorities() .stream() .map(a -> a.getAuthority()) .toList()); return generateToken(claims, userDetails); } // Reading it back: @SuppressWarnings("unchecked") public List<String> extractRoles(String token) { return extractClaim(token, c -> (List<String>) c.get("roles")); }
Keep the payload small. Every HTTP request carries the token in an Authorization header. Embedding large objects (full user profiles, long role lists) inflates every request. Stick to what the downstream service genuinely needs — username, role codes, tenant ID — and look up anything else from a cache or database on demand.

Asymmetric Keys: When to Switch from HS256 to RS256

HS256 uses a shared secret: every service that validates tokens must hold the same key, which means every service is also capable of issuing tokens. In a microservices architecture that is a large blast radius if any service is compromised.

RS256 (RSA-SHA256) splits this into a private key (only the auth server ever touches it) and a public key (distributed freely to resource servers). A resource server that can only verify tokens cannot forge them. Switching in JJWT requires generating an RSAPrivateKey/RSAPublicKey pair and calling signWith(privateKey) on generation and verifyWith(publicKey) on parsing. Spring Security's OAuth2 Resource Server support (Lesson 9) can serve the public key through a standard /.well-known/jwks.json endpoint automatically.

Testing Your JwtService

The service is a plain Spring bean — no web layer required. A fast unit test covers the critical paths:

@SpringBootTest class JwtServiceTest { @Autowired JwtService jwtService; private UserDetails user = User.withUsername("alice") .password("n/a").roles("USER").build(); @Test void generatedTokenIsValidForSameUser() { String token = jwtService.generateToken(user); assertTrue(jwtService.isTokenValid(token, user)); } @Test void extractedUsernameMatchesSubject() { String token = jwtService.generateToken(user); assertEquals("alice", jwtService.extractUsername(token)); } @Test void tamperedTokenFailsValidation() { String token = jwtService.generateToken(user); String tampered = token.substring(0, token.lastIndexOf('.') + 1) + "INVALIDSIG"; assertThrows(JwtException.class, () -> jwtService.extractUsername(tampered)); } }

Summary

You now have a production-ready JwtService that generates signed tokens with configurable expiry and custom claims, and validates them through JJWT's parser — which handles signature verification, expiry checking, and structured error reporting in one call. The next lesson wires this service into a Spring Security filter so that every incoming request is authenticated automatically before it reaches a controller.