This line reads a JWT’s payload in any browser and in Node 16 and later:
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzgyMzEiLCJuYW1lIjoiWm_DqyIsImFkbWluIjpmYWxzZSwiZXhwIjoxODkzNDU2MDAwfQ.72UkVFWtIXY6nrOr15MKLJZHX3siVEPNHrmmE3MkQBs';
JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0))));
// { sub: 'user_8231', name: 'Zoë', admin: false, exp: 1893456000 }
A JWT is three base64url parts joined by dots: header, payload and signature. The line takes the middle one, turns base64url back into standard Base64, decodes the bytes as UTF-8 and parses the JSON. As a function, for either part:
function decodePart(part) {
const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
const [header, payload] = token.split('.');
decodePart(header);
// { alg: 'HS256', typ: 'JWT' }
decodePart(payload);
// { sub: 'user_8231', name: 'Zoë', admin: false, exp: 1893456000 }
The decoder below shows the same header and payload for this token. In Node, Buffer reads base64url directly:
JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString());
// { sub: 'user_8231', name: 'Zoë', admin: false, exp: 1893456000 }
The details that break it
- The alphabet. JWTs use base64url (RFC 7515), with
-and_.atobreads only+and/, soatob(payload)on this token throwsInvalidCharacterErrorbecause of its_. - Padding. JWTs leave off the
=padding.atobdoesn’t need it, so there’s nothing to add back; Python’s decoder and some libraries do need it. - Unicode.
atobreturns one character per byte. WithoutTextDecoder,JSON.parse(atob(...))succeeds and gives the name asZoë, because the two UTF-8 bytes ofëbecome two characters. - Seconds.
exp,iatandnbfcount seconds, andDatecounts milliseconds: the token has expired onceDate.now() >= payload.exp * 1000. Here,new Date(payload.exp * 1000).toISOString()is2030-01-01T00:00:00.000Z.
Decoding isn’t verifying
Anyone can write a token that decodes to whatever they like. Change false to true, re-encode the payload, keep the old signature, and the code above reads it without complaint:
const [h, , signature] = token.split('.');
const claims = { sub: 'user_8231', name: 'Zoë', admin: true, exp: 1893456000 };
const forged = `${h}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.${signature}`;
decodePart(forged.split('.')[1]);
// { sub: 'user_8231', name: 'Zoë', admin: true, exp: 1893456000 }
Decode in the browser to show a name or schedule a refresh. A server that acts on a claim must verify the signature first, with the algorithms it expects named (RFC 8725). With jose:
import { jwtVerify } from 'jose';
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload: verified } = await jwtVerify(token, secret, { algorithms: ['HS256'] });
verified;
// { sub: 'user_8231', name: 'Zoë', admin: false, exp: 1893456000 }
await jwtVerify(forged, secret, { algorithms: ['HS256'] });
// JWSSignatureVerificationFailed: signature verification failed
With jsonwebtoken, jwt.verify(token, secret, { algorithms: ['HS256'] }) returns the payload, and the forged token throws JsonWebTokenError: invalid signature. Its jwt.decode() is the decode-only call. Both libraries check exp as part of verifying: once it has passed, jose throws JWTExpired and jsonwebtoken throws TokenExpiredError: jwt expired.