Community JavaScript Snippet
Verify a JWT Without a JWT Library
How I verify HS256 JSON Web Tokens with the Web Crypto API and zero npm dependencies. Decodes the segments, checks the signature in constant time, and refuses to trust `alg: none`.
Verify a JWT Without a JWT Library
How I verify HS256 JSON Web Tokens with the Web Crypto API and zero npm dependencies. Decodes the segments, checks the signature in constant time, and refuses to trust `alg: none`.
By @chidiweber
April 30, 2026
·
Updated May 20, 2026
457 views
3
4.1 (9)
Decoding a JWT is just three base64url segments separated by dots. The padding step is the part most homegrown decoders get wrong: base64url drops = padding, and feeding an unpadded string to atob or Buffer.from('...', 'base64') succeeds silently with truncated bytes on some boundaries. The signingInput is the literal header.payload substring, which I keep around because in the next stage we sign exactly those bytes (not the parsed JSON). I label this stage debugging-only because reading payload.userId here is fine for log output but a security disaster for authorization.
crypto.subtle.verify does the constant-time comparison for you, which is the part you absolutely cannot do with === over hex strings unless you want to leak the signature one nibble at a time through timing. Importing the secret as a raw HMAC key with SHA-256 maps cleanly to RFC 7518's HS256. The signing input is header.payload as raw bytes, not the JSON; that distinction matters because two re-encoded JSON objects with the same fields can produce different bytes (key order, whitespace) and your signature will not match. I always reject any algorithm other than the exact one I expect rather than trusting header.alg as a switch.
Pinning expectedAlg and rejecting alg: none is the single most important line in any JWT verifier. The classic exploit is forging a token with alg: none and an empty signature; libraries that dispatch on header.alg and have a none handler turn that into instant authorization bypass. I always pass an explicit clock and a clockSkewSec window because in real distributed systems your worker host's clock is rarely within a second of your auth server's clock, and a 30-second cushion turns a flaky integration test into a calm one. Returning the parsed claims only after every check means a caller cannot accidentally read an expired payload.
