DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

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 "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)
Enter fullscreen mode Exit fullscreen mode

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:

  1. BearerTokenAuthenticationFilter extracts the token from Authorization: Bearer ….
  2. JwtDecoder verifies the signature. Wrong signature → 401.
  3. OAuth2TokenValidators check exp / nbf / iss / aud. A perfectly-signed but expired token → 401.
  4. Authorization (@PreAuthorize, scopes/roles) decides if the bearer may call this endpoint. Signed, unexpired, but missing the scope403.

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

Top comments (0)