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 NimbusJwtDecoder only accepts the algorithms you configure (RS256 by default), which blocks the old alg: none trick.