Build and Verify a JWT in Java

A JWT from Scratch

Libraries hide the details, so let us build an HS256 JWT with only the JDK: javax.crypto.Mac for HMAC-SHA256 and Base64.getUrlEncoder().withoutPadding() for Base64url. Then we verify it, tamper with it, and let it expire. Timestamps are fixed so the output is always the same.

Example

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;

public class Main {
    static final byte[] SECRET = "shopease-demo-secret-key-32-bytes!!".getBytes(StandardCharsets.UTF_8);
    static final Base64.Encoder ENC = Base64.getUrlEncoder().withoutPadding();
    static final Base64.Decoder DEC = Base64.getUrlDecoder();

    static String b64(String s) { return ENC.encodeToString(s.getBytes(StandardCharsets.UTF_8)); }
    static String unb64(String s) { return new String(DEC.decode(s), StandardCharsets.UTF_8); }

    static String sign(String data) throws Exception {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(SECRET, "HmacSHA256"));
        return ENC.encodeToString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8)));
    }

    static String create(String email, String role, long iat, long exp) throws Exception {
        String header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
        String payload = "{\"sub\":\"" + email + "\",\"role\":\"" + role
                + "\",\"iat\":" + iat + ",\"exp\":" + exp + "}";
        String unsigned = b64(header) + "." + b64(payload);
        return unsigned + "." + sign(unsigned);
    }

    static String verify(String token, long now) throws Exception {
        String[] parts = token.split("[.]");
        if (parts.length != 3) return "REJECTED: not a JWT";
        byte[] expected = sign(parts[0] + "." + parts[1]).getBytes(StandardCharsets.UTF_8);
        if (!MessageDigest.isEqual(expected, parts[2].getBytes(StandardCharsets.UTF_8))) {
            return "REJECTED: bad signature";
        }
        String payload = unb64(parts[1]);
        int at = payload.indexOf("\"exp\":") + 6;
        long exp = Long.parseLong(payload.substring(at, payload.indexOf('}', at)));
        if (now >= exp) return "REJECTED: token expired";
        return "VALID -> " + payload;
    }

    public static void main(String[] args) throws Exception {
        long iat = 1767225600L;            // 2026-01-01T00:00:00Z
        long exp = iat + 15 * 60;          // valid for 15 minutes
        String token = create("[email protected]", "ROLE_CUSTOMER", iat, exp);
        String[] parts = token.split("[.]");
        System.out.println("header    = " + parts[0] + "  ->  " + unb64(parts[0]));
        System.out.println("payload   = " + unb64(parts[1]));
        System.out.println("signature = " + parts[2]);
        System.out.println("token length = " + token.length());

        System.out.println("1) original, 1 min later : " + verify(token, iat + 60));

        String evil = unb64(parts[1]).replace("ROLE_CUSTOMER", "ROLE_ADMIN");
        String tampered = parts[0] + "." + b64(evil) + "." + parts[2];
        System.out.println("2) role changed to ADMIN  : " + verify(tampered, iat + 60));

        System.out.println("3) original, 20 min later: " + verify(token, iat + 20 * 60));
    }
}

Output

header    = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9  ->  {"alg":"HS256","typ":"JWT"}
payload   = {"sub":"[email protected]","role":"ROLE_CUSTOMER","iat":1767225600,"exp":1767226500}
signature = KtSQ-9XFrbnFKVQJEigQMVXZs3hQqCycZt6m3rP6JGo
token length = 192
1) original, 1 min later : VALID -> {"sub":"[email protected]","role":"ROLE_CUSTOMER","iat":1767225600,"exp":1767226500}
2) role changed to ADMIN  : REJECTED: bad signature
3) original, 20 min later: REJECTED: token expired

What Just Happened

  1. The header and payload are JSON, Base64url-encoded (URL-safe alphabet, no = padding).
  2. The signature is HMAC-SHA256 of header.payload using the server's secret.
  3. To verify, the server recomputes the signature and compares. MessageDigest.isEqual compares in constant time, so attackers cannot learn the signature byte by byte from timing.
  4. The tampered token has a new payload but the old signature. The attacker cannot make a new signature without the secret, so it is rejected.
  5. After exp, even a perfectly signed token is rejected.

Rules for the Secret

  • HS256 needs a key of at least 256 bits (32 bytes). Libraries like jjwt refuse shorter keys.
  • Generate it randomly (openssl rand -base64 32), keep it in an environment variable or a secrets manager, never in Git.
  • Always check the algorithm on the server. Never accept "alg":"none" (a famous JWT attack). Good libraries do this for you when you configure a key.

Exercise

Exercise: Change the program so that verify also rejects a token whose sub is not an email ending with @shopease.in. What does the tampered token print, and why is the signature still checked first?

Show answer
// after the signature check:
String payload = unb64(parts[1]);
int s = payload.indexOf("\"sub\":\"") + 7;
String sub = payload.substring(s, payload.indexOf('"', s));
if (!sub.endsWith("@shopease.in")) return "REJECTED: unknown issuer domain";

// The tampered token still prints "REJECTED: bad signature".
// Signature first: until the signature is valid, nothing in the payload can be trusted.