Careeroza
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 expiredWhat Just Happened
- The header and payload are JSON, Base64url-encoded (URL-safe alphabet, no
=padding). - The signature is HMAC-SHA256 of
header.payloadusing the server's secret. - To verify, the server recomputes the signature and compares.
MessageDigest.isEqualcompares in constant time, so attackers cannot learn the signature byte by byte from timing. - 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.
- 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.