Verifying a JWT in the browser with Web Crypto: four things that bite
Web Crypto can verify every common JWT signature (HS256, RS256, PS256, ES256 and their bigger siblings) with no library at all. The API call itself is one line. The bugs live around it. Here are the four I had to get rig
Web Crypto can verify every common JWT signature (HS256, RS256, PS256, ES256 and their bigger siblings) with no library at all. The API call itself is one line. The bugs live around it. Here are the four I had to get right while building the JWT signature verifier on A2Z Tools.
1. Never take the algorithm from the token
A JWT header says which algorithm signed it:
{ "alg": "RS256", "typ": "JWT" }
It's tempting to read alg and verify with whatever it says. Don't. The header is attacker-controlled, and that opens two classic holes:
-
alg: none. The token claims it has no signature, and a naive verifier accepts it. - RS256 to HS256 confusion. The attacker re-signs the token with HMAC, using your RSA public key as the HMAC secret. A verifier that follows the header and feeds that same public key into HMAC will accept it.
The fix is to have the caller choose the algorithm and ignore the header's choice:
const ALGS = {
HS256: { name: 'HMAC', hash: 'SHA-256' },
RS256: { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
PS256: { name: 'RSA-PSS', hash: 'SHA-256', saltLength: 32 },
ES256: { name: 'ECDSA', hash: 'SHA-256', crv: 'P-256' },
// ...384 and 512 variants follow the same pattern
};
// `alg` comes from your config or the user's choice, never from decoded.header.alg
async function verify(decoded, key, alg) {
const info = ALGS[alg];
if (!info) throw new Error(`Unsupported algorithm "${alg}"`);
// ...
}
none isn't in the table, so it can't get through. In the tool, the dropdown starts on the header's value because that's almost always right. But the value passed to verify() is the one in the dropdown, and if the header disagrees, the page says so.
2. ECDSA signatures in JOSE are raw r||s, not DER
This one produces the worst kind of bug: the right key and an untouched token, but the result is "not verified".
JOSE (RFC 7518) encodes an ECDSA signature as the two integers r and s, each padded to the curve size and joined together: 64 bytes for P-256. OpenSSL and many server libraries emit a DER SEQUENCE of two INTEGERs instead. Web Crypto's verify() wants the raw form. So if a signature starts with 0x30 and isn't exactly 2 * size bytes, convert it:
function derToRaw(der, size) {
let i = 0;
if (der[i++] !== 0x30) throw new Error('Not a DER SEQUENCE');
let len = der[i++];
if (len & 0x80) i += len & 0x7f; // long-form length
const readInt = () => {
if (der[i++] !== 0x02) throw new Error('Expected a DER INTEGER');
const l = der[i++];
let v = der.slice(i, i + l);
i += l;
while (v.length && v[0] === 0) v = v.slice(1); // strip sign padding
const out = new Uint8Array(size);
out.set(v, size - v.length); // left-pad to curve size
return out;
};
const r = readInt(), s = readInt();
const raw = new Uint8Array(size * 2);
raw.set(r, 0);
raw.set(s, size);
return raw;
}
const size = { 'P-256': 32, 'P-384': 48, 'P-521': 66 }[info.crv];
if (sig.length !== size * 2 && sig[0] === 0x30) sig = derToRaw(sig, size);
Note P-521 is 66 bytes per integer, not 65. 521 bits rounds up to 66 bytes.
3. The actual verify call
With the algorithm pinned and the signature in the right shape, the check itself is short. The signed data is the ASCII header.payload string, exactly as it appeared in the token:
const params =
info.name === 'HMAC' ? { name: 'HMAC' } :
info.name === 'ECDSA' ? { name: 'ECDSA', hash: info.hash } :
info.name === 'RSA-PSS' ? { name: 'RSA-PSS', saltLength: info.saltLength } :
{ name: info.name };
const ok = await crypto.subtle.verify(
params, key, sig, new TextEncoder().encode(decoded.signingInput)
);
Wrap it in a try. A key imported for the wrong algorithm throws instead of returning false, and you want to report that as "this key can't check this signature", not as a crash.
Also reject private keys outright. Verification only needs the public half. If someone pastes a PEM containing PRIVATE KEY, or a JWK with a d member, the kind thing to do is stop and tell them not to paste private keys into web pages.
4. Check the HMAC secret length, and say what a pass doesn't mean
RFC 7518 requires an HMAC key at least as long as the hash: 256 bits for HS256. Anyone holding a single token signed with a short secret can brute-force it offline, then mint their own tokens. If the secret is shorter than that, say so even when the signature verifies.
And a valid signature only proves the token is authentic and unmodified. It doesn't prove it hasn't expired (exp), that it was meant for you (aud), or that the key you checked against really belongs to the issuer. Those are separate checks, and a verifier UI should say so next to the green tick. I put the claim checks in a separate JWT security analyzer so the two answers don't get mixed up.
Disclosure: I'm the founder of A2Z Tools, where these tools live. Everything above runs in the browser and the token never leaves the tab. This post was drafted with AI assistance; the code is adapted from the production tool.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.