Decode JWT in Node.js: jose vs jsonwebtoken vs hand-rolled
A JWT in Node.js is three Base64URL chunks separated by dots, per RFC 7519. Decoding the payload is trivial. Verifying the signature is what stops attackers, and the library you pick decides whether you do it right.
Decoding without verifying is a footgun
The header and payload are public. Anyone can split a JWT on the dots, Base64URL-decode the middle segment, and read the claims. That is decoding, not validation, and treating decoded claims as trusted is how authentication bugs ship.
const [, payload] = token.split(".");
const claims = JSON.parse(
Buffer.from(payload, "base64url").toString("utf8"),
);
Use this exactly once: in tests, or in a debug script. In production code, the only safe path is verify-then-read. If a function in your auth flow calls it without verifying first, you have a vulnerability waiting for the right token.
jose is the modern pick
jose (panva/jose) is the actively maintained successor of the JWT space in Node. It is ESM-first, supports the full JOSE suite (JWS, JWE, JWK, JWKS), and exposes typed APIs. Verification is async because key import is async:
```js import { jwtVerify, createRemoteJWKSet } from "jose";
const jwks = createRemoteJWKSet( new URL("https://example.com/.well-known/jwks.json"), );
const { payload } = await jwtVerify(token, jwks, { issuer: "https://example.com", audience: "api.example.com", }); ```
The issuer and audience checks are not optional in any production system. A token signed by your IdP for a different audience is still cryptographically valid; rejecting it is your job, not the library's.
jsonwebtoken still works for HS256
jsonwebtoken is the historic pick. It is callback-first, Buffer-friendly, and fine for symmetric (HS256) tokens with a static secret. For asymmetric or JWKS-backed verification, the ergonomics fall behind jose.
```js const jwt = require("jsonwebtoken");
try { const decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ["HS256"], issuer: "https://example.com", audience: "api.example.com", }); } catch (err) { // expired, malformed, bad signature, wrong alg } ```
The algorithms array is mandatory. Omit it and you reopen the 2015 algorithm-confusion bug, where a token with alg: none or a swapped alg value passes verification. Pin the algorithms you accept; reject everything else.
Common Node-specific traps
Buffer treats base64url as a real encoding since Node 16. Older code that hand-built the URL-safe alphabet from base64 plus replace calls is now redundant: Buffer.from(seg, "base64url") is correct.
Clock skew is the #1 false-negative in production. exp is in seconds, not milliseconds, and a 30-second drift between your server and the IdP turns valid tokens into rejections. Both libraries take a clockTolerance option (jose) or clockTolerance in jsonwebtoken; set 30 to 60 seconds and move on.
If you decode a token in middleware and pass the claims downstream, set req.user only after verification succeeds. Keeping a req.unverifiedClaims is fine for debug logs, never for authorization.
Working example
jsimport { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://example.com/.well-known/jwks.json"),
);
export async function authenticate(token) {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: "https://example.com",
audience: "api.example.com",
clockTolerance: 30,
});
return { sub: payload.sub, alg: protectedHeader.alg };
} Just need the result?
When you just want to read what is inside a JWT you grabbed from a curl response or a network panel, opening Node and importing jose is overkill. Paste it into the browser-based JWT decoder and see header, payload and signature bytes split apart, with the alg highlighted, in zero round-trips.
Open JWT Decoder →Frequently asked questions
Should I pick jose or jsonwebtoken in 2026?
jose for any new code. It supports JWKS rotation, ESM, and the full JOSE spec. jsonwebtoken is fine for legacy CommonJS apps with HS256 and a static secret, but it is feature-frozen relative to jose.
Why does my verified token still fail in production?
Almost always clock skew or audience mismatch. Set clockTolerance to 30 seconds, log the failed exp and iat values, and confirm the audience claim matches exactly what the IdP issued.
Is decoding a JWT on the client safe?
Decoding the payload is safe (the data is public). Trusting the decoded claims for any authorization decision is not. The browser cannot verify the signature against a server-held key, so route any decision through your backend.