jsonwebtoken
jsonwebtoken is Auth0's Node.js implementation of JSON Web Tokens (RFC 7519). You call jwt.sign() to create a signed token from a payload plus a secret or private key, and jwt.verify() to check the signature and standard claims like exp, aud, and iss on the way back in. It supports HMAC, RSA, RSA-PSS, and ECDSA algorithms, and it is the package nearly every Node auth tutorial reaches for, which is why it still moves 54M downloads a week.
It works and half the Node auth code on the internet depends on it, but this is maintenance-mode software with a multi-year release gap. For new projects jose is the better default; pick jsonwebtoken mainly for consistency with an existing codebase.
Use it if
- You need to sign and verify JWTs in a plain Node.js service and want the API every tutorial and Stack Overflow answer uses
- You verify tokens against a JWKS endpoint; the getKey callback pairs directly with jwks-rsa
- You want claim validation (exp, nbf, aud, iss, sub, maxAge, clockTolerance) handled by options instead of hand-rolled checks
- You run on edge runtimes, Cloudflare Workers, Deno, or in the browser. This package needs Node's crypto module; jose runs everywhere
- You want an actively developed library. There was no release at all between 9.0.2 in August 2023 and 9.0.3 in December 2025, and the repo has 206 open issues and PRs
- You need EdDSA (Ed25519) signatures or JWE encrypted tokens; jsonwebtoken supports neither, jose supports both
- Token verification sits on your hot path and throughput matters; fast-jwt exists for exactly that reason
Setup reality
npm install jsonwebtoken works everywhere Node runs, but it drags in ten dependencies including five lodash.* micro-packages and a full copy of semver, which is a lot for a token signer. The API is CommonJS with sync-or-callback style and no promises, so you either use the sync form or wrap it in util.promisify. TypeScript types live in the separate @types/jsonwebtoken package. Coming from v8, read the v9 migration notes: RSA keys under 2048 bits are rejected unless you explicitly opt out with allowInsecureKeySizes.
Patterns
Sign a token with a shared secret (HS256)sign-hs256
const jwt = require('jsonwebtoken');
const token = jwt.sign({ sub: 'user-123', role: 'admin' }, process.env.JWT_SECRET);HS256 is the default algorithm; an iat (issued at) claim is added automatically unless you pass noTimestamp.
Sign a token that expiressign-with-expiry
const jwt = require('jsonwebtoken');
const token = jwt.sign({ sub: 'user-123' }, process.env.JWT_SECRET, {
expiresIn: '1h'
});A bare number means seconds but a numeric string like '120' means milliseconds; always include units in string values.
Sign with an RSA private key (RS256)sign-rs256
const fs = require('fs');
const jwt = require('jsonwebtoken');
const privateKey = fs.readFileSync('private.key');
const token = jwt.sign({ sub: 'user-123' }, privateKey, { algorithm: 'RS256' });Since v9, RSA keys under 2048 bits throw unless you set allowInsecureKeySizes: true, which you should not.
Verify a token and read its payloadverify-token
const jwt = require('jsonwebtoken');
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
console.log(decoded.sub);
} catch (err) {
// TokenExpiredError, JsonWebTokenError, or NotBeforeError
}The README warns to treat the decoded payload like any other user input; verify checks the signature, not your business rules.
Pin the allowed algorithms on verifypin-allowed-algorithms
const jwt = require('jsonwebtoken');
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256']
});Always pin algorithms when verifying; accepting whatever the token header claims is the classic JWT confusion attack.
Verify audience, issuer, and subjectverify-claims
const jwt = require('jsonwebtoken');
const decoded = jwt.verify(token, publicKey, {
audience: 'urn:my-api',
issuer: 'https://issuer.example.com/',
subject: 'user-123',
clockTolerance: 5
});clockTolerance (in seconds) absorbs small clock drift between servers when checking exp and nbf.
Distinguish an expired token from an invalid onehandle-expired-token
const jwt = require('jsonwebtoken');
try {
jwt.verify(token, secret);
} catch (err) {
if (err.name === 'TokenExpiredError') {
console.log('expired at', err.expiredAt);
} else if (err.name === 'JsonWebTokenError') {
console.log('invalid:', err.message);
}
}Expired tokens usually mean 'refresh', invalid tokens usually mean 'reject'; branching on err.name is how you tell them apart.
Peek into a token without verifying itdecode-without-verify
const jwt = require('jsonwebtoken');
const decoded = jwt.decode(token, { complete: true });
console.log(decoded.header.alg, decoded.header.kid);
console.log(decoded.payload);decode() does not check the signature at all; never make auth decisions from it, only routing decisions like picking a key by kid.
Verify against a remote JWKS endpointverify-with-jwks
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://tenant.auth0.com/.well-known/jwks.json'
});
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.publicKey || key.rsaPublicKey);
});
}
jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => {
if (err) return console.error(err);
console.log(decoded.sub);
});The getKey callback form only works with the async verify signature; the sync form cannot fetch keys.
Use verify with async/awaitpromisify-verify
const util = require('util');
const jwt = require('jsonwebtoken');
const verifyAsync = util.promisify(jwt.verify);
const decoded = await verifyAsync(token, process.env.JWT_SECRET);The library predates promises and never added them; promisify works because the callback is error-first.