I Built a JWT Playground That Re-signs Tokens With Real HMAC-SHA256
Most JWT explainers cheat. They show you header.payload.signature, point at the third part, and say "...and then the server verifies it." But they never run the verification β so you never actually see what "tampered" or
Most JWT explainers cheat. They show you header.payload.signature, point at the third part, and say "...and then the server verifies it." But they never run the verification β so you never actually see what "tampered" or "expired" means in practice.
So I built a JWT Playground that runs the real crypto in your browser, then animates how a Spring Security resource server validates the token.
βΆ Live demo: https://dev48v.github.io/jwt-flow/
Source (zero dependencies): https://github.com/dev48v/jwt-flow
Real HMAC, not faked
The signing and verification use the Web Crypto API β actual HMAC-SHA256:
async function hmac(signingInput, secret) {
const key = await crypto.subtle.importKey(
"raw", new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]
);
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signingInput));
return base64url(new Uint8Array(sig));
}
// token = base64url(header) + "." + base64url(payload) + "." + hmac(those two, secret)
Because it's real, the demo behaves like the real thing:
- Edit the payload and re-sign β a new, valid signature is computed.
- Change one character of the token without re-signing β the verifier recomputes the HMAC, it no longer matches, and you get invalid signature.
- Change the secret β verification fails, exactly like a key mismatch.
The thing most people miss: it's three separate gates
A JWT being correctly signed does not mean the request is allowed. A Spring Security resource server runs three independent checks, and the demo lights each one up:
-
BearerTokenAuthenticationFilter extracts the token from
Authorization: Bearer β¦. -
JwtDecoder verifies the signature. Wrong signature β
401. -
OAuth2TokenValidators check
exp/nbf/iss/aud. A perfectly-signed but expired token β401. -
Authorization (
@PreAuthorize, scopes/roles) decides if the bearer may call this endpoint. Signed, unexpired, but missing the scope β403.
That 401 vs 403 distinction trips people up constantly:
-
401 Unauthorized= "I don't believe who you are" (bad/expired/missing token). -
403 Forbidden= "I know who you are, you're just not allowed to do this" (missing scope/role).
The playground has a button for each failure so you can watch where exactly the request dies.
Why a playground beats a diagram
A static diagram shows the boxes. It can't show you that tampering breaks the signature check specifically, or that expiry is a different gate from authorization. Poking at a live token β breaking it on purpose and watching the 401 β is what makes it stick.
It's one index.html, no build, no dependencies.
Security note
It only does symmetric HS256 so it can sign client-side for the demo. Real resource servers usually verify RS256/ES256 with the issuer's public key via JWKS and never hold the signing key. Never paste a production token into any web page.
If this made JWTs click, a star helps others find it: https://github.com/dev48v/jwt-flow
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.