How JWT signing actually works (HS256 with Web Crypto)
By Flavio Copes
Learn how JWT signing works by building and signing an HS256 token by hand with the Web Crypto API: base64url, HMAC-SHA256, and verification.
Most of us use JWTs through a library. You call sign(), you call verify(), and it works.
But a JWT is simple enough to build by hand, and doing it once teaches you more than reading ten explainers. So let’s build and sign one with nothing but the Web Crypto API — the same built-in API behind crypto.randomUUID(), available in browsers, Node.js, and every edge runtime.
If you need a refresher on what JWTs are for, I wrote a general introduction to JSON Web Tokens. This post is about the mechanics.
The three parts
A JWT is three base64url-encoded strings joined by dots:
header.payload.signature
The header says which algorithm signed the token:
{ "alg": "HS256", "typ": "JWT" }
The payload carries the claims — the actual data:
{ "sub": "user-123", "name": "Jane Doe", "exp": 1735689600 }
The signature is what makes it a token instead of just two JSON blobs. It proves the header and payload haven’t been touched since the server signed them.
The client sends the whole thing back on every request, usually in an Authorization: Bearer header or in a cookie.
Step 1: base64url encoding
JWTs don’t use plain base64. They use base64url, a variant that’s safe to put in URLs and headers: + becomes -, / becomes _, and the trailing = padding is dropped.
There’s no built-in one-liner for this, so we write it:
function base64url(bytes) {
let binary = ''
for (const b of bytes) binary += String.fromCharCode(b)
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
function encodeJson(obj) {
return base64url(new TextEncoder().encode(JSON.stringify(obj)))
}
TextEncoder turns the JSON string into bytes, btoa() does the base64, and the three replaces convert it to base64url.
Now we can encode the first two parts:
const header = { alg: 'HS256', typ: 'JWT' }
const payload = {
sub: 'user-123',
name: 'Jane Doe',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
}
const signingInput = `${encodeJson(header)}.${encodeJson(payload)}`
That signingInput string — header dot payload — is exactly what gets signed.
Step 2: sign with HMAC-SHA256
HS256 means HMAC with SHA-256. An HMAC is a hash that mixes in a secret key. Same input plus same secret always produces the same signature. Without the secret, you can’t produce it, and you can’t check it.
With Web Crypto, we first import the secret as a key, then sign:
async function signJwt(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))
}
importKey() wraps our secret string into a CryptoKey object that Web Crypto accepts. The false means the key can’t be exported back out, and ['sign'] restricts what it can be used for.
crypto.subtle.sign() returns the raw 32-byte HMAC, which we base64url-encode like everything else.
Putting it together:
const signature = await signJwt(signingInput, 'a-random-secret-at-least-32-chars-long')
const token = `${signingInput}.${signature}`
That’s a complete, valid JWT. Paste it into any JWT debugger and it decodes.
A note on the secret: HS256 secrets should be at least 32 random bytes. A short or guessable secret can be brute-forced offline by anyone who captures one token, and then they can forge any token they want.
Step 3: verification
Here’s the part that clicks the whole thing into place. Verifying a JWT is just signing it again.
The server takes the incoming token’s header and payload, computes the HMAC with its own secret, and compares the result with the signature the client sent:
async function verifyJwt(token, secret) {
const [headerB64, payloadB64, sig] = token.split('.')
const expected = await signJwt(`${headerB64}.${payloadB64}`, secret)
return expected === sig
}
If a client changed even one character of the payload — say, flipped "admin": false to true — the recomputed HMAC won’t match, and the token is rejected.
One caveat: comparing with === leaks timing information, since string comparison exits at the first mismatched character. Real libraries use a constant-time comparison. Fine for learning, not for production.
After the signature check, a real verifier also checks the exp claim against the current time, and usually iss and aud too. A valid signature on an expired token is still a rejected token.
Signing is not encryption
This trips people up constantly, so let me say it plainly: the payload of a JWT is not encrypted. It’s base64url, which is an encoding, not encryption. Anyone who holds the token can read it:
JSON.parse(atob(token.split('.')[1]))
//{ sub: 'user-123', name: 'Jane Doe', ... }
The signature guarantees integrity (nobody modified it) and authenticity (your server issued it). It guarantees zero confidentiality.
Never put passwords, API keys or private data in a JWT payload. If a token ends up in a log file or a browser extension, its contents are public.
The alg:none attack
The header tells the verifier which algorithm to use. Early JWT libraries took that literally.
The spec includes "alg": "none" for unsecured tokens — no signature at all. So an attacker would take a real token, decode it, change the payload, set the header to alg: none, drop the signature, and send it back. Vulnerable libraries saw none, skipped verification, and accepted the forged token.
The lesson: never let the token choose how it gets verified. Your server knows it issues HS256 tokens, so it should verify with HS256 and reject everything else, no matter what the header claims. Modern libraries make you pass an explicit allowlist of algorithms. If yours doesn’t, that’s a red flag.
A related attack applies to RS256 (the asymmetric variant): an attacker switches the header to HS256 and signs with the server’s public key as the HMAC secret. Same root cause, same fix.
Play with it
Reading about this is one thing, watching the signature change as you edit the payload is another. I built a JWT signer and verifier that runs entirely in your browser using exactly the Web Crypto code from this post. Edit a claim, watch the token re-sign, then change one character of the secret and watch verification fail.
Related posts about js: