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`.

JavaScript
Frontend
3 snippets
jwt
authentication
security
hashing
chidiweber

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.