Careeroza
Validating JWTs Step by Step
What Spring Checks for You
Before configuring Spring, see the checks in plain Java. The authorization server signs with its private key (RS256); every API verifies with the public key. Changing even one character of the payload breaks the signature:
Example
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.time.Instant;
import java.util.Base64;
import java.util.regex.*;
public class Main {
static final Base64.Encoder ENC = Base64.getUrlEncoder().withoutPadding();
static final Base64.Decoder DEC = Base64.getUrlDecoder();
static final String ISSUER = "https://auth.shopease.in/realms/shopease";
static String b64(String json) { return ENC.encodeToString(json.getBytes(StandardCharsets.UTF_8)); }
static String sign(String payloadJson, PrivateKey key) throws Exception {
String data = b64("{\"alg\":\"RS256\",\"kid\":\"key-1\"}") + "." + b64(payloadJson);
Signature s = Signature.getInstance("SHA256withRSA");
s.initSign(key);
s.update(data.getBytes(StandardCharsets.US_ASCII));
return data + "." + ENC.encodeToString(s.sign());
}
static String claim(String json, String name) {
Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*\"?([^\",}]+)\"?").matcher(json);
return m.find() ? m.group(1) : null;
}
static String validate(String token, PublicKey key, Instant now) throws Exception {
String[] parts = token.split("\\.");
if (parts.length != 3) return "401 not a JWT";
Signature s = Signature.getInstance("SHA256withRSA");
s.initVerify(key);
s.update((parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII));
if (!s.verify(DEC.decode(parts[2]))) return "401 invalid signature";
String payload = new String(DEC.decode(parts[1]), StandardCharsets.UTF_8);
if (!ISSUER.equals(claim(payload, "iss"))) return "401 wrong issuer";
if (!"shopease-api".equals(claim(payload, "aud"))) return "401 wrong audience";
if (now.getEpochSecond() >= Long.parseLong(claim(payload, "exp"))) return "401 token expired";
return "200 OK, user=" + claim(payload, "sub");
}
public static void main(String[] args) throws Exception {
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
gen.initialize(2048);
KeyPair authServer = gen.generateKeyPair();
KeyPair attacker = gen.generateKeyPair();
PublicKey jwks = authServer.getPublic(); // what the API downloads from jwks_uri
Instant now = Instant.parse("2025-12-31T23:00:00Z");
String payload = "{\"iss\":\"" + ISSUER + "\",\"aud\":\"shopease-api\",\"sub\":\"asha\",\"exp\":1767225600}";
String good = sign(payload, authServer.getPrivate());
String[] p = good.split("\\.");
String tampered = p[0] + "." + b64(payload.replace("asha", "admin")) + "." + p[2];
String forged = sign(payload, attacker.getPrivate());
String otherApi = sign(payload.replace("shopease-api", "payment-api"), authServer.getPrivate());
System.out.println("valid token -> " + validate(good, jwks, now));
System.out.println("tampered payload -> " + validate(tampered, jwks, now));
System.out.println("attacker's key -> " + validate(forged, jwks, now));
System.out.println("other audience -> " + validate(otherApi, jwks, now));
System.out.println("one hour later -> " + validate(good, jwks, now.plusSeconds(3600)));
System.out.println("garbage -> " + validate("hello", jwks, now));
}
}Output
valid token -> 200 OK, user=asha tampered payload -> 401 invalid signature attacker's key -> 401 invalid signature other audience -> 401 wrong audience one hour later -> 401 token expired garbage -> 401 not a JWT
Takeaways
- The signature check comes first. Never read claims from a token you have not verified.
- The audience check stops a token issued for one API (payment-api) from being replayed against another (shopease-api).
- Always pin the algorithm. Spring's
NimbusJwtDecoderonly accepts the algorithms you configure (RS256 by default), which blocks the oldalg: nonetrick.