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)
// JWT structure: header.payload.signature, all base64url.
// Stage 1: decode the parts so you can see them. NOT for trust decisions.
function base64UrlDecode(seg) {
const pad = '='.repeat((4 - (seg.length % 4)) % 4);
const b64 = (seg + pad).replace(/-/g, '+').replace(/_/g, '/');
if (typeof atob === 'function') return atob(b64);
return Buffer.from(b64, 'base64').toString('binary');
}
function decodeJwt(token) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('jwt_malformed');
const [h, p, s] = parts;
return {
header: JSON.parse(base64UrlDecode(h)),
payload: JSON.parse(base64UrlDecode(p)),
signature: s,
signingInput: `${h}.${p}`,
};
}
// A real-looking HS256 JWT (signature is fake; we are only decoding).
const token =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' +
'.eyJzdWIiOiJ1c2VyXzciLCJpYXQiOjE3MTAwMDAwMDAsImV4cCI6MjAwMDAwMDAwMH0' +
'.aGVsbG8td29ybGQtZmFrZS1zaWc';
const decoded = decodeJwt(token);
console.log('header:', decoded.header);
console.log('payload:', decoded.payload);
console.log('signing input length:', decoded.signingInput.length);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.
// Stage 2: verify an HS256 signature against a shared secret.
// Uses Web Crypto subtle, which is in Node 18+ globalThis.crypto.
function base64UrlDecode(seg) {
const pad = '='.repeat((4 - (seg.length % 4)) % 4);
const b64 = (seg + pad).replace(/-/g, '+').replace(/_/g, '/');
return typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
}
function base64UrlToBytes(seg) {
const bin = base64UrlDecode(seg);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function verifyHs256(token, secret) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('jwt_malformed');
const [h, p, s] = parts;
const header = JSON.parse(base64UrlDecode(h));
if (header.alg !== 'HS256') throw new Error(`alg_not_supported:${header.alg}`);
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const signingInput = new TextEncoder().encode(`${h}.${p}`);
const sigBytes = base64UrlToBytes(s);
const ok = await crypto.subtle.verify('HMAC', key, sigBytes, signingInput);
if (!ok) throw new Error('signature_invalid');
return JSON.parse(base64UrlDecode(p));
}
// Helper to mint a token for the demo.
async function signHs256(payload, secret) {
const enc = new TextEncoder();
const b64u = (b) => Buffer.from(b).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
const header = b64u(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
const body = b64u(enc.encode(JSON.stringify(payload)));
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(`${header}.${body}`));
return `${header}.${body}.${b64u(new Uint8Array(sig))}`;
}
(async () => {
const secret = 'shhhhh';
const token = await signHs256({ sub: 'user_7', exp: Date.now() / 1000 + 60 }, secret);
const claims = await verifyHs256(token, secret);
console.log('verified claims:', claims);
try {
await verifyHs256(token, 'wrong-secret');
} catch (e) {
console.log('rejected wrong secret:', e.message);
}
})();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.
// Stage 3: lock down the verifier the way a production service should.
// Reject `alg: none`, enforce exp/nbf, and pin the expected algorithm.
function base64UrlDecode(seg) {
const pad = '='.repeat((4 - (seg.length % 4)) % 4);
const b64 = (seg + pad).replace(/-/g, '+').replace(/_/g, '/');
return typeof atob === 'function' ? atob(b64) : Buffer.from(b64, 'base64').toString('binary');
}
function base64UrlToBytes(seg) {
const bin = base64UrlDecode(seg);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64u(bytes) {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
const b64 = typeof btoa === 'function' ? btoa(bin) : Buffer.from(bytes).toString('base64');
return b64.replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
}
async function verifyJwt(token, { secret, expectedAlg = 'HS256', clockSkewSec = 30, now = () => Date.now() / 1000 }) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('jwt_malformed');
const [h, p, s] = parts;
const header = JSON.parse(base64UrlDecode(h));
if (header.alg === 'none' || header.alg !== expectedAlg) {
throw new Error(`alg_rejected:${header.alg}`);
}
const enc = new TextEncoder();
const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
const ok = await crypto.subtle.verify('HMAC', key, base64UrlToBytes(s), enc.encode(`${h}.${p}`));
if (!ok) throw new Error('signature_invalid');
const claims = JSON.parse(base64UrlDecode(p));
const t = now();
if (typeof claims.exp === 'number' && t > claims.exp + clockSkewSec) throw new Error('token_expired');
if (typeof claims.nbf === 'number' && t + clockSkewSec < claims.nbf) throw new Error('token_not_yet_valid');
return claims;
}
// Build a forged 'alg: none' token to confirm we reject it.
function strB64u(s) {
const b64 = typeof btoa === 'function' ? btoa(s) : Buffer.from(s).toString('base64');
return b64.replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
}
const forged = `${strB64u(JSON.stringify({ alg: 'none', typ: 'JWT' }))}.${strB64u(JSON.stringify({ sub: 'admin' }))}.`;
(async () => {
try {
await verifyJwt(forged, { secret: 'shhhhh' });
} catch (e) {
console.log('rejected forgery:', e.message);
}
// Build an expired but properly signed token using the byte-safe encoder.
const enc = new TextEncoder();
const key = await crypto.subtle.importKey('raw', enc.encode('shhhhh'), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const h = strB64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
const p = strB64u(JSON.stringify({ sub: 'u', exp: 1 }));
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(`${h}.${p}`));
const expired = `${h}.${p}.${bytesToB64u(new Uint8Array(sig))}`;
try {
await verifyJwt(expired, { secret: 'shhhhh' });
} catch (e) {
console.log('rejected stale token:', e.message);
}
})();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.
