← Back to the tool

Protocols · Under the hood

JWT builder & decoder

What each button in the tool actually computes, step by step — with the real formulas.

Encoding header and payload

"Build" first turns the fixed header object and your claims object into JSON text, then base64url-encodes each one separately (base64url is ordinary base64 with URL-unsafe characters swapped out and padding removed). Nothing here is encrypted — it's a length-preserving, fully reversible encoding.

The actual code (no WebCrypto involved here)
const header = { alg: "HS256", typ: "JWT" };

const headerB64 = bufToBase64Url(new TextEncoder().encode(JSON.stringify(header)));
const payloadB64 = bufToBase64Url(new TextEncoder().encode(JSON.stringify(claims)));

Nothing here calls crypto.subtle — this step is plain JSON.stringify plus a base64url encoding helper. That's the point: a JWT's claims are encoded, not encrypted, and readable by anyone without any key.

Signing: HMAC over the literal string "header.payload"

The signing input isn't the header and payload objects — it's the literal ASCII string formed by joining their two base64url encodings with a single period. That exact string is what gets fed into HMAC-SHA256, using the same nested double-hash construction covered in the HMAC tool, alongside your secret.

The actual WebCrypto call
const signingInput = `${headerB64}.${payloadB64}`;   // the literal string HMAC covers — not the JSON objects

const key = await crypto.subtle.importKey(
  "raw",
  new TextEncoder().encode(secret),
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign"]
);
const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(signingInput));

const token = `${signingInput}.${bufToBase64Url(signature)}`;

Identical importKey/sign pair as the HMAC tool — a JWT signature is exactly an HMAC tag, just computed over this specific dot-joined string and base64url-encoded instead of hex.

signature=HMAC-SHA256(base64url(header) ∥ "." ∥ base64url(payload), secret)\text{signature} = \mathrm{HMAC\text{-}SHA256}(\text{base64url(header)} \,\|\, \texttt{"."} \,\|\, \text{base64url(payload)},\ \text{secret})

Decoding: split first, verify second

"Decode" does nothing cryptographic at all — it splits the token on its two dots and base64url-decodes the first two segments back into readable JSON. This is exactly why anyone can read a JWT's claims without knowing the secret: decoding and verifying are two entirely separate operations in this tool, and only the second one ever touches the signature.

The actual code (again, no WebCrypto)
const [headerB64, payloadB64, signatureB64] = token.trim().split(".");

const header = JSON.parse(new TextDecoder().decode(base64UrlToBuf(headerB64)));
const payload = JSON.parse(new TextDecoder().decode(base64UrlToBuf(payloadB64)));
// signatureB64 is captured but untouched until Verify is clicked

Decoding a JWT to read its claims takes zero cryptographic operations — any JSON and base64url library can do it, which is exactly why a JWT is not a safe place to put a secret.

Verifying: recompute, don't decrypt

"Verify signature" recomputes HMAC-SHA256 over the token's own header.payload string, using whatever secret you've entered, and compares the result to the signature segment already in the token — the same recompute-and-compare pattern as the HMAC tool, just applied to a specific, standardized signing input.

The actual WebCrypto call
const expected = await hmacSign(secret, `${headerB64}.${payloadB64}`);
// hmacSign() runs the same importKey + sign calls shown above

if (expected === signatureB64) {
  // valid — same plain string comparison the HMAC tool uses, and the same
  // caveat applies: production code should prefer crypto.subtle.verify("HMAC", ...)
} else {
  // invalid
}

This tool never looks at the alg field in the header to decide how to verify — it always does HS256. Real JWT libraries that trust the header's alg field unconditionally are exactly how the infamous "alg: none" attack works (covered in the JWT module).

Now try it

Back to JWT builder & decoder →

Build a real HMAC-signed JWT from your own claims, or paste one in to decode its header and payload and verify its signature.