How JWTs Are Signed

Build and verify an HS256 JSON Web Token by hand to understand what a JWT proves, what it does not hide, and which checks a verifier must make.

Syntaxjwt = b64url(header) + "." + b64url(payload) + "." + b64url(signature) signature = HMAC-SHA256(key, b64url(header) + "." + b64url(payload)) // HS256 b64url = Base64.getUrlEncoder().withoutPadding()

A JSON Web Token is three Base64url strings joined by dots: header.payload.signature. The header names the algorithm, for example {"alg":"HS256","typ":"JWT"}. The payload holds claims such as sub (who), exp (expiry, in seconds since the epoch), iss (issuer) and aud (audience). The signature covers the first two parts exactly as encoded.

The signature

String signingInput = b64url(headerJson) + "." + b64url(payloadJson);
byte[] sig = hmacSha256(key, signingInput.getBytes(StandardCharsets.US_ASCII));
String jwt = signingInput + "." + b64url(sig);

That is all HS256 is: HMAC-SHA256 over the ASCII text header.payload, with Base64url and no padding throughout. RS256 and ES256 sign the same input with a private key (Signature with SHA256withRSA, or SHA256withECDSAinP1363Format for ES256, because a JWT carries the raw 64-byte signature while plain SHA256withECDSA produces DER), so services that only verify need just the public key. That is why identity providers publish their public keys and use RS256 or ES256: the API that checks tokens cannot mint them.

What a JWT does not do

The payload is encoded, not encrypted. Anyone holding the token can read every claim, so never put a password, a card number or internal notes in it. A JWT also cannot be revoked by itself: until exp passes, a stolen token works. Keep access tokens short-lived, minutes rather than days, and use refresh tokens or a deny list when you need to cut access early.

Verifying, in order

  1. Split into exactly three parts.
  2. Decide the algorithm from your configuration, not from the header. Accepting whatever alg says led to real attacks: alg: none with an empty signature, and switching RS256 to HS256 so the public key was used as an HMAC secret.
  3. Recompute the signature and compare in constant time.
  4. Only then parse the payload, and check exp and nbf against an injected clock with a small leeway, plus iss and aud.

Each step is easy to get subtly wrong, so production code should use a maintained library such as Nimbus JOSE + JWT or jjwt, or Spring Security's resource server support, which uses Nimbus. Writing one by hand, as below, is how you learn what those libraries check for you.

Mistakes people make

  • Decoding without verifying. Reading the payload of an unverified token and trusting sub or role lets anyone log in as anyone.
  • A weak HS256 key. The key can be brute-forced offline from any token. Use 32 or more random bytes.
  • Storing tokens in localStorage in a browser app, where any injected script can read them. An HttpOnly cookie is safer for web front ends.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Verify the bearer token on every API request with a pinned algorithm before trusting its sub and role claims.
  • Check exp, iss and aud against an injected Clock and configuration so tokens from another environment are refused.
  • Validate RS256 tokens from an identity provider using its published public keys, with no shared secret.

More examples

Rejecting alg none and edited claims

Because the verifier never reads alg to decide how to verify, an unsigned token is just a token with a wrong signature. Changing a single claim changes the signing input, so the old signature no longer matches.

Example · java
static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();
static final byte[] KEY = "demo-jwt-key-32-bytes-not-real!!".getBytes(StandardCharsets.UTF_8);

String b64(String json) { return B64URL.encodeToString(json.getBytes(StandardCharsets.UTF_8)); }

String sig(String input) throws GeneralSecurityException {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(KEY, "HmacSHA256"));
    return B64URL.encodeToString(mac.doFinal(input.getBytes(StandardCharsets.US_ASCII)));
}

String verify(String jwt) throws GeneralSecurityException {
    String[] p = jwt.split("\\.", -1);
    if (p.length != 3) return "rejected: malformed";
    // Always HS256 here, whatever the header claims.
    boolean ok = MessageDigest.isEqual(sig(p[0] + "." + p[1]).getBytes(StandardCharsets.US_ASCII),
                                       p[2].getBytes(StandardCharsets.US_ASCII));
    return ok ? "accepted" : "rejected: bad signature";
}

void main() throws Exception {
    String header = b64("{\"alg\":\"HS256\",\"typ\":\"JWT\"}");
    String payload = b64("{\"sub\":\"user-42\",\"role\":\"editor\"}");
    String good = header + "." + payload + "." + sig(header + "." + payload);

    String admin = b64("{\"sub\":\"user-42\",\"role\":\"admin\"}");
    String edited = header + "." + admin + "." + good.split("\\.")[2];
    String none = b64("{\"alg\":\"none\",\"typ\":\"JWT\"}") + "." + admin + ".";

    IO.println("original:  " + verify(good));
    IO.println("role edit: " + verify(edited));
    IO.println("alg none:  " + verify(none));
    IO.println("two parts: " + verify(header + "." + payload));
}
// Output:
// original:  accepted
// role edit: rejected: bad signature
// alg none:  rejected: bad signature
// two parts: rejected: malformed

Checking exp with a fixed clock

The 30-second leeway absorbs small clock differences between the issuer and your server, which is why 09:15:20 still passes. Refusing tokens with no exp at all stops a token that never expires.

Example · java
static final Pattern EXP = Pattern.compile("\"exp\"\\s*:\\s*(\\d+)");
static final Duration LEEWAY = Duration.ofSeconds(30);

// Runs after the signature has been verified. Real code would use a JSON parser.
String checkExpiry(String payloadJson, Clock clock) {
    Matcher m = EXP.matcher(payloadJson);
    if (!m.find()) return "401 no exp claim";
    Instant exp = Instant.ofEpochSecond(Long.parseLong(m.group(1)));
    Instant now = clock.instant();
    return now.isAfter(exp.plus(LEEWAY)) ? "401 expired at " + exp : "ok until " + exp;
}

void main() {
    String payload = "{\"sub\":\"user-42\",\"iat\":1791882000,\"exp\":1791882900}";
    for (String time : List.of("2026-10-13T09:10:00Z", "2026-10-13T09:15:20Z", "2026-10-13T09:16:00Z")) {
        Clock clock = Clock.fixed(Instant.parse(time), ZoneOffset.UTC);
        IO.println(time + " -> " + checkExpiry(payload, clock));
    }
    IO.println(checkExpiry("{\"sub\":\"user-42\"}", Clock.systemUTC()));
}
// Output:
// 2026-10-13T09:10:00Z -> ok until 2026-10-13T09:15:00Z
// 2026-10-13T09:15:20Z -> ok until 2026-10-13T09:15:00Z
// 2026-10-13T09:16:00Z -> 401 expired at 2026-10-13T09:15:00Z
// 401 no exp claim

RS256: sign with a private key, verify with the public key

A 2048-bit RSA signature is 256 bytes, which is 342 Base64url characters without padding. Services that hold only the public key can check tokens but can never create one.

Example · java
// java.lang.classfile also has a Signature type, so name the one we mean.
import java.security.Signature;

static final Base64.Encoder B64URL = Base64.getUrlEncoder().withoutPadding();

String b64(String json) { return B64URL.encodeToString(json.getBytes(StandardCharsets.UTF_8)); }

void main() throws Exception {
    KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
    gen.initialize(2048);
    KeyPair keys = gen.generateKeyPair();          // the issuer keeps the private key

    String input = b64("{\"alg\":\"RS256\",\"typ\":\"JWT\"}") + "." + b64("{\"sub\":\"user-42\"}");
    Signature signer = Signature.getInstance("SHA256withRSA");
    signer.initSign(keys.getPrivate());
    signer.update(input.getBytes(StandardCharsets.US_ASCII));
    String jwt = input + "." + B64URL.encodeToString(signer.sign());
    IO.println("signature chars: " + jwt.split("\\.")[2].length());

    for (String token : List.of(jwt, jwt.replace(b64("{\"sub\":\"user-42\"}"), b64("{\"sub\":\"user-1\"}")))) {
        String[] p = token.split("\\.");
        Signature verifier = Signature.getInstance("SHA256withRSA");
        verifier.initVerify(keys.getPublic());     // APIs only need the public key
        verifier.update((p[0] + "." + p[1]).getBytes(StandardCharsets.US_ASCII));
        IO.println("verified: " + verifier.verify(Base64.getUrlDecoder().decode(p[2])));
    }
}
// Output:
// signature chars: 342
// verified: true
// verified: false

Discussion

  • Be the first to comment on this lesson.