Decoding a JWT in Node.js — Three Approaches
Before reaching for a library, note that you can decode a JWT payload with nothing but the standard library. A JWT is just three Base64URL-encoded strings separated by dots:
// Node.js — no dependencies needed
const [header, payload, signature] = token.split('.');
const decodedPayload = JSON.parse(
Buffer.from(payload, 'base64url').toString('utf8')
);
console.log(decodedPayload.sub, decodedPayload.exp);This works in Node.js 16+ (which added 'base64url' as a native Buffer encoding). It gives you the raw claims but no signature check whatsoever. Do not use this for authorization — use it only for logging, debugging, and building developer tools.
Using jsonwebtoken — The Standard npm Package
npm install jsonwebtoken
npm install --save-dev @types/jsonwebtoken # TypeScriptDecode Without Verifying
const jwt = require('jsonwebtoken');
const decoded = jwt.decode(token);
// Returns: { iss, sub, aud, exp, iat, jti, ...custom_claims }
// Returns null for a malformed token (not three segments)
// Does NOT throw on invalid signature
const decodedWithHeader = jwt.decode(token, { complete: true });
// Returns: { header: { alg, typ }, payload: {...}, signature: 'raw_sig' }Verify with HS256 Secret
const jwt = require('jsonwebtoken');
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'], // Always specify — prevents algorithm confusion attacks
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
// payload.sub, payload.role etc. are verified and safe to use
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
// exp is in the past — tell client to refresh
} else if (err instanceof jwt.JsonWebTokenError) {
// Signature invalid, malformed token, wrong alg, wrong issuer/audience
} else if (err instanceof jwt.NotBeforeError) {
// nbf is in the future
}
}Verify with RS256 Public Key
const jwt = require('jsonwebtoken');
const fs = require('fs');
const publicKey = fs.readFileSync('./public.pem', 'utf8');
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: process.env.JWT_ISSUER,
audience: process.env.JWT_AUDIENCE,
});Using jose — For JWKS and Async Environments
The jose library (ESM-first, zero native dependencies, runs in browsers and edge runtimes) is the better choice when your identity provider exposes a JWKS endpoint:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json')
);
async function verifyToken(token) {
const { payload, protectedHeader } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
// jose resolves the correct key by kid in the token header automatically
return payload;
}Express.js JWT Authentication Middleware
const jwt = require('jsonwebtoken');
function requireAuth(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing Authorization header' });
}
const token = authHeader.split(' ')[1];
try {
req.user = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: process.env.JWT_ISSUER,
audience: process.env.JWT_AUDIENCE,
});
next();
} catch (err) {
const message = err instanceof jwt.TokenExpiredError
? 'Token expired'
: 'Invalid token';
return res.status(401).json({ error: message });
}
}
// Usage:
app.get('/api/private/profile', requireAuth, (req, res) => {
res.json({ userId: req.user.sub, role: req.user.role });
});Role-Based Authorization Guard
function requireRole(...allowedRoles) {
return (req, res, next) => {
// requireAuth middleware must run first
if (!req.user) return res.status(401).json({ error: 'Unauthorized' });
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
app.delete('/api/admin/users/:id',
requireAuth,
requireRole('admin', 'super_admin'),
async (req, res) => { /* ... */ }
);Debugging JWT Issues in Node.js
- JsonWebTokenError: invalid signature — your server-side secret does not match the one used to sign. Double-check environment variables across services.
- TokenExpiredError: jwt expired — the token's
expis in the past. The client must use its refresh token to obtain a new access token. - JsonWebTokenError: jwt audience invalid — the
audclaim in the token does not match theaudienceoption you passed to verify(). Check both values. - JsonWebTokenError: jwt issuer invalid — same issue for
issvsissueroption. - Payload is null from jwt.decode() — the token has fewer than three dot-separated segments. It is malformed, not a JWT.
- exp is a future year (year 55000) — timestamp was passed in milliseconds instead of seconds. Use
Math.floor(Date.now() / 1000).
Key Facts
- Library:
- jsonwebtoken (npm) — most widely used; jose for async/edge
- Decode only:
- jwt.decode(token) or Buffer.from(token.split(".")[1], "base64url")
- Verify HS256:
- jwt.verify(token, secret, {algorithms: ["HS256"]})
- Verify RS256:
- jwt.verify(token, publicKey, {algorithms: ["RS256"]}) or jose + JWKS
- Always specify:
- algorithms: [] — prevents algorithm-confusion attacks
Related JWT Tools
- JWT Decoder Online — decode any token to see its claims instantly
- JWT Decoder in JavaScript — browser-side atob() + jose patterns
- JWT Decoder in Python — PyJWT patterns for Flask/Django/FastAPI
- Verify JWT Signature — HS256/RS256 verification walkthrough
- JWT Generator Online — create signed test tokens in-browser