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.
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.
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.
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.
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 clickedDecoding 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.
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).