Skip to content

Migrate from jsonwebtoken

This page describes polyjose 0.2.0, compared with jsonwebtoken 9.0.3.

Both sides of every sample on this page run as tests: the jsonwebtoken code against that exact version, the polyjose code against @polyjose/node and again against @polyjose/wasm. The same tests check that a token signed by either library verifies in the other, with ES256 and with HS256.

jsonwebtoken signs and verifies JWTs, which are JWSs. It has no encryption, so a migration from it is about JWS only. polyjose also does JWE: see Encrypt and decrypt when you need it.

Terminal window
npm install @polyjose/node@0.2.0

polyjose is ESM only. CommonJS code can still load it with require("@polyjose/node") on Node.js 22.12 or later.

jsonwebtoken
import jwt from 'jsonwebtoken';
polyjose
import { base64urlEncode, jwkFromPem, jwtSign, jwtVerify, jwtVerifyWithJwks, PolyjoseError } from '@polyjose/node';

The same code runs in browsers and Workers with @polyjose/wasm: see Web (WASM).

jsonwebtoken APIs and their polyjose counterparts
jsonwebtokenpolyjose
jwt.sign(payload, key, options)await jwtSign(claims, key, { alg })
jwt.verify(token, key, options), with or without a callbackawait jwtVerify(token, key, { allowedAlgs, … }), which returns { claims, header, alg }
jwt.verify(token, getKey, …) with a key functionawait jwtVerifyWithJwks(token, jwks, options)
jwt.decode(token)None: polyjose reads a token only by verifying it
A secret string or BufferAn oct JWK whose k is the base64url of the secret’s bytes. A string’s bytes are its UTF-8 encoding, new TextEncoder().encode(secret)
A PEM string or KeyObjectA JWK: convert PEM with jwkFromPem
TokenExpiredError, NotBeforeError, JsonWebTokenErrorPolyjoseError, with a code (Handle errors)

jwtSign takes the whole Claims Set: the options that jwt.sign turns into claims become claims you write. Times are whole seconds since the epoch. polyjose adds no claim of its own, not even iat.

jsonwebtoken
// `privatePem` is a PKCS #8 PEM string: -----BEGIN PRIVATE KEY-----
const token = jwt.sign({ sub: 'user-123' }, privatePem, {
algorithm: 'ES256',
expiresIn: '5m',
issuer: 'https://issuer.example',
audience: 'https://api.example',
});
polyjose
// `privateKey` is a JWK: {"kty":"EC","crv":"P-256","x":"...","y":"...","d":"..."}
const now = Math.floor(Date.now() / 1000);
const token = await jwtSign(
{
sub: 'user-123',
iss: 'https://issuer.example',
aud: 'https://api.example',
iat: now,
exp: now + 300, // expiresIn: '5m'
},
privateKey,
{ alg: 'ES256' },
);
jwt.sign options in polyjose
jwt.sign optionWith polyjose
algorithm (default HS256)alg in the options. Required: there is no default.
expiresIn: ‘5m’The claim exp: now + 300
notBefore: ‘1m’The claim nbf: now + 60
audience, issuer, subject, jwtidThe claims aud, iss, sub, jti
The iat it adds, unless noTimestampThe claim iat: now, if you want one
keyidA kid member in the key’s JWK, which is copied into the header
headerheader in the options, for extra header parameters

now is Math.floor(Date.now() / 1000).

A shared secret becomes an oct JWK. Its k is the base64url of the secret’s bytes, so tokens signed with the same bytes verify in both libraries.

jsonwebtoken
// `secret` holds the shared secret's bytes (a Buffer).
const token = jwt.sign({ sub: 'user-123' }, secret, { algorithm: 'HS256', expiresIn: '5m' });
const claims = jwt.verify(token, secret, { algorithms: ['HS256'] });
polyjose
// The same bytes as an oct JWK: "k" is their base64url (RFC 7518 §6.4.1).
const key = { kty: 'oct', k: await base64urlEncode(secret) };
const now = Math.floor(Date.now() / 1000);
const token = await jwtSign({ sub: 'user-123', iat: now, exp: now + 300 }, key, { alg: 'HS256' });
const { claims } = await jwtVerify(token, key, { allowedAlgs: ['HS256'] });

The key must be at least as long as the hash output: 32 bytes for HS256, 48 for HS384 and 64 for HS512 (RFC 7518 §3.2). jsonwebtoken accepts shorter secrets. polyjose refuses them with InvalidKey, for signing and for verifying, so tokens signed with a short secret can’t be verified after the move. Plan to replace such a secret with 32 random bytes or more, for example from jwkGenerateForAlg('HS256').

jsonwebtoken takes PEM text. polyjose takes JWKs, and converts PEM once, when you load the key. A key protected with a passphrase has to be decrypted first: polyjose reads no encrypted PEM, and fails with UnsupportedAlgorithm.

polyjose
// PKCS #8, SEC 1 and PKCS #1 private keys, and SPKI and PKCS #1 public keys.
const signingKey = await jwkFromPem(privatePem);
const verifyingKey = await jwkFromPem(publicPem);
// polyjose doesn't read an encrypted PEM. Decrypt it with node:crypto and export a JWK.
const unlocked = createPrivateKey({ key: encryptedPem, passphrase }).export({ format: 'jwk' });

Pass the JWKs from then on. Passing PEM text where a JWK belongs fails with InvalidJson, and a KeyObject is a TypeError. Manage keys covers PEM and DER in both directions.

algorithms becomes allowedAlgs, and it is required. issuer and audience become the arrays issuers and audiences. clockTolerance becomes leeway.

jsonwebtoken
const claims = jwt.verify(token, publicPem, {
algorithms: ['ES256'],
issuer: 'https://issuer.example',
audience: 'https://api.example',
clockTolerance: 30,
});
// claims.sub === "user-123"
polyjose
const { claims } = await jwtVerify(token, publicKey, {
allowedAlgs: ['ES256'],
issuers: ['https://issuer.example'],
audiences: ['https://api.example'],
leeway: 30, // clockTolerance; polyjose's default is 60 seconds
});
// claims.sub === "user-123"
jwt.verify options in polyjose
jwt.verify optionpolyjose
algorithmsallowedAlgs, required
issuer, audience, subjectissuers, audiences (strings, matched exactly, no regular expressions), subject
clockTolerance (default 0)leeway (default 60, at most 600 seconds)
maxAgemaxAge, in seconds; it also makes iat required
clockTimestampnow, in seconds
complete: trueAlways: the result has header and claims
jwtid, nonceNo option. Require the claim with requiredClaims: [‘jti’], then compare its value in your code.
ignoreExpiration, ignoreNotBeforeNone. An expired token always fails; leeway is the only allowance.

polyjose rejects options it doesn’t know with a TypeError, so a leftover ignoreExpiration or clockTolerance fails loudly instead of being ignored. Durations are numbers of seconds: a string such as '2 days' is a TypeError too. Validate a JWT lists every option and default.

There is no callback form and no synchronous form: every polyjose call returns a Promise. Replace the callback with await, or with .then:

jsonwebtoken
jwt.verify(token, publicPem, { algorithms: ['ES256'], audience: 'https://api.example' }, (err, claims) => {
if (err) return reply(401);
reply(200, claims.sub);
});
polyjose
// Every polyjose call returns a Promise: there is no callback form, and no synchronous one.
jwtVerify(token, publicKey, { allowedAlgs: ['ES256'], audiences: ['https://api.example'] }).then(
(verified) => reply(200, verified.claims.sub),
() => reply(401),
);

The work runs on the libuv thread pool, so verifying doesn’t block the event loop.

With jsonwebtoken, a key function receives the decoded header and looks up the key, often through a JWKS client. With polyjose, pass the issuer’s whole JWK Set to jwtVerifyWithJwks: it chooses the candidate keys from the token’s kid and alg, and the result says which kid verified.

jsonwebtoken
// A key function looks up the token's kid, often through a JWKS client.
const getKey = (header, callback) => callback(null, keyById[header.kid]);
jwt.verify(token, getKey, { algorithms: ['ES256'], audience: 'https://api.example' }, (err, claims) => {
if (err) return reply(401);
reply(200, claims.sub);
});
polyjose
// `jwks` is the issuer's JWK Set, which your application fetches and caches.
const verified = await jwtVerifyWithJwks(token, jwks, {
allowedAlgs: ['ES256'],
issuers: ['https://issuer.example'],
audiences: ['https://api.example'],
});
// verified.kid === "p256-2026"

polyjose makes no network requests: fetching the set from the issuer’s jwks_uri and caching it is your application’s job. Work with JWK Sets shows how keys are chosen and how to cache a set safely.

jwt.decode returns the claims of a token whose signature nobody checked. polyjose has no such function, under any name: claims read that way could have been written by anyone. Verify first, and read the header and the claims from the result.

jsonwebtoken
// Not verified: anyone could have written these claims.
const { header, payload } = jwt.decode(token, { complete: true });
polyjose
// polyjose reads the header and claims only from a token it has verified.
const { header, claims } = await jwtVerify(token, publicKey, {
allowedAlgs: ['ES256'],
audiences: ['https://api.example'],
});

The usual reason to decode first is to find the kid, or the issuer, to pick a key. Pass the issuer’s JWK Set instead, as above, and choose the set from your endpoint’s configuration, not from the token. See Not supported › Decode without verify.

jsonwebtoken throws three classes. polyjose throws one, PolyjoseError, whose code says what failed. Branch on the code, never on the message: messages are for logs and may change between versions.

jsonwebtoken
try {
jwt.verify(token, publicPem, { algorithms: ['ES256'], audience: 'https://api.example' });
} catch (err) {
if (err instanceof jwt.TokenExpiredError) reject('expired');
else if (err instanceof jwt.NotBeforeError) reject('not yet valid');
else if (err instanceof jwt.JsonWebTokenError) reject('invalid');
else throw err;
}
polyjose
try {
await jwtVerify(token, publicKey, { allowedAlgs: ['ES256'], audiences: ['https://api.example'] });
} catch (err) {
if (!(err instanceof PolyjoseError)) throw err;
if (err.code === 'TokenExpired') reject('expired');
else if (err.code === 'TokenNotYetValid') reject('not yet valid');
else reject('invalid'); // every other code: see Errors
}
jsonwebtoken errors and the polyjose codes for the same failure
jsonwebtokenpolyjose code
TokenExpiredErrorTokenExpired
NotBeforeErrorTokenNotYetValid
JsonWebTokenError: invalid signatureInvalidSignature
JsonWebTokenError: invalid algorithmAlgorithmNotAllowed
JsonWebTokenError: jwt issuer invalid, jwt audience invalidInvalidClaim
JsonWebTokenError: jwt malformedInvalidFormat; other parse failures are InvalidBase64, InvalidJson or InvalidHeader

A token that is wrong in several ways reports the first failing check, in a fixed order: see Errors, which also lists every code.

What changes in every migration states the rules that apply whichever library you leave. Here is what changes against jsonwebtoken:

  • The allow-list is required. Without algorithms, jsonwebtoken picks default algorithms from the type of key. polyjose has no default: a verify without allowedAlgs fails with AlgorithmNotAllowed. Name only the algorithms your issuer uses, and never mix HS* with public-key algorithms.
  • none is never accepted. jsonwebtoken can sign and verify unsecured tokens (algorithm: 'none'). polyjose refuses them with AlgorithmNotAllowed, even when "none" is in the allow-list, and can’t create one.
  • Keys are bound to their algorithm family. An EC key verifies only ECDSA with its curve, an oct key only HMAC, and a JWK with an alg member only that algorithm. Anything else fails with KeyAlgorithmMismatch. There is no counterpart to allowInvalidAsymmetricKeyTypes.
  • exp is required. jwt.sign writes no exp without expiresIn, and jwt.verify accepts such tokens. polyjose rejects them with InvalidClaim, unless you pass allowMissingExp: true. Check what your issuers send before you switch.
  • An aud is always checked. jwt.verify ignores aud when you pass no audience. polyjose rejects a token that has an aud with InvalidClaim unless you configure audiences, or pass ignoreAudience: true.
  • 60 seconds of leeway by default, where jsonwebtoken has none. A token that expired 30 seconds ago fails in jsonwebtoken and passes in polyjose. Pass leeway: 0 to keep the old behaviour. The most it can be is 600 seconds.
  • No weak keys. HMAC secrets shorter than the hash and RSA keys under 2048 bits fail with InvalidKey. jsonwebtoken’s allowInsecureKeySizes has no counterpart.
  • Errors are codes, not classes: see Handle errors.
  • JWE. Not part of this migration, because jsonwebtoken has none. polyjose supports it: see Encrypt and decrypt.
  • jwt.decode. No unverified decoding; see above.
  • Encrypted PEM ({ key, passphrase }). Decrypt the key with node:crypto and export a JWK, as in PEM keys.
  • Regular expressions in audience, and the jwtid and nonce checks. Audiences are exact strings; compare jti and nonce yourself after verifying.
  • ignoreExpiration and ignoreNotBefore. There is no way to accept a token outside its validity period beyond leeway.
  • Fetching JWK Sets. polyjose does no network I/O; fetch and cache the set yourself.

polyjose also signs with algorithms jsonwebtoken doesn’t have, such as Ed25519, and supports the JWS JSON serializations: see Sign and verify.

  1. Replace jsonwebtoken with @polyjose/node, and await every call.
  2. Turn secrets into oct JWKs, and replace any secret shorter than the hash output.
  3. Convert PEM keys to JWKs once, with jwkFromPem; decrypt passphrase-protected keys first.
  4. Write exp, iat, nbf, iss, aud, sub and jti as claims when you sign.
  5. Pass allowedAlgs to every verify, and rename issuer, audience and clockTolerance.
  6. Make sure the tokens you accept carry exp, and configure audiences where they carry aud.
  7. Replace key functions with jwtVerifyWithJwks, and jwt.decode with the verified result.
  8. Branch on PolyjoseError.code instead of the error classes, with a default branch.