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.
Install and import
Section titled “Install and import”npm install @polyjose/node@0.2.0Registry: npm
polyjose is ESM only. CommonJS code can still load it with require("@polyjose/node") on Node.js 22.12 or later.
import jwt from 'jsonwebtoken';import { base64urlEncode, jwkFromPem, jwtSign, jwtVerify, jwtVerifyWithJwks, PolyjoseError } from '@polyjose/node';The same code runs in browsers and Workers with @polyjose/wasm: see Web (WASM).
Concept map
Section titled “Concept map”| jsonwebtoken | polyjose |
|---|---|
jwt.sign(payload, key, options) | await jwtSign(claims, key, { alg }) |
jwt.verify(token, key, options), with or without a callback | await jwtVerify(token, key, { allowedAlgs, … }), which returns { claims, header, alg } |
jwt.verify(token, getKey, …) with a key function | await jwtVerifyWithJwks(token, jwks, options) |
jwt.decode(token) | None: polyjose reads a token only by verifying it |
A secret string or Buffer | An 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 KeyObject | A JWK: convert PEM with jwkFromPem |
TokenExpiredError, NotBeforeError, JsonWebTokenError | PolyjoseError, with a code (Handle errors) |
Sign a JWT
Section titled “Sign a JWT”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.
// `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',});// `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 option | With 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, jwtid | The claims aud, iss, sub, jti |
The iat it adds, unless noTimestamp | The claim iat: now, if you want one |
keyid | A kid member in the key’s JWK, which is copied into the header |
header | header in the options, for extra header parameters |
now is Math.floor(Date.now() / 1000).
Secrets and keys
Section titled “Secrets and keys”HMAC secrets
Section titled “HMAC secrets”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.
// `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'] });// 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').
PEM keys
Section titled “PEM keys”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.
// 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.
Verify a JWT
Section titled “Verify a JWT”algorithms becomes allowedAlgs, and it is required. issuer and audience become the arrays issuers and audiences. clockTolerance becomes leeway.
const claims = jwt.verify(token, publicPem, { algorithms: ['ES256'], issuer: 'https://issuer.example', audience: 'https://api.example', clockTolerance: 30,});// claims.sub === "user-123"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 option | polyjose |
|---|---|
algorithms | allowedAlgs, required |
issuer, audience, subject | issuers, audiences (strings, matched exactly, no regular expressions), subject |
clockTolerance (default 0) | leeway (default 60, at most 600 seconds) |
maxAge | maxAge, in seconds; it also makes iat required |
clockTimestamp | now, in seconds |
complete: true | Always: the result has header and claims |
jwtid, nonce | No option. Require the claim with requiredClaims: [‘jti’], then compare its value in your code. |
ignoreExpiration, ignoreNotBefore | None. 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.
Callbacks
Section titled “Callbacks”There is no callback form and no synchronous form: every polyjose call returns a Promise. Replace the callback with await, or with .then:
jwt.verify(token, publicPem, { algorithms: ['ES256'], audience: 'https://api.example' }, (err, claims) => { if (err) return reply(401); reply(200, claims.sub);});// 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.
Choosing the key by kid
Section titled “Choosing the key by kid”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.
// 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);});// `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.
Reading a token without verifying it
Section titled “Reading a token without verifying it”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.
// Not verified: anyone could have written these claims.const { header, payload } = jwt.decode(token, { complete: true });// 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.
Handle errors
Section titled “Handle errors”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.
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;}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 | polyjose code |
|---|---|
TokenExpiredError | TokenExpired |
NotBeforeError | TokenNotYetValid |
JsonWebTokenError: invalid signature | InvalidSignature |
JsonWebTokenError: invalid algorithm | AlgorithmNotAllowed |
JsonWebTokenError: jwt issuer invalid, jwt audience invalid | InvalidClaim |
JsonWebTokenError: jwt malformed | InvalidFormat; 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.
Behaviour differences
Section titled “Behaviour differences”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,jsonwebtokenpicks default algorithms from the type of key. polyjose has no default: a verify withoutallowedAlgsfails withAlgorithmNotAllowed. Name only the algorithms your issuer uses, and never mixHS*with public-key algorithms. noneis never accepted.jsonwebtokencan sign and verify unsecured tokens (algorithm: 'none'). polyjose refuses them withAlgorithmNotAllowed, 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
octkey only HMAC, and a JWK with analgmember only that algorithm. Anything else fails withKeyAlgorithmMismatch. There is no counterpart toallowInvalidAsymmetricKeyTypes. expis required.jwt.signwrites noexpwithoutexpiresIn, andjwt.verifyaccepts such tokens. polyjose rejects them withInvalidClaim, unless you passallowMissingExp: true. Check what your issuers send before you switch.- An
audis always checked.jwt.verifyignoresaudwhen you pass noaudience. polyjose rejects a token that has anaudwithInvalidClaimunless you configureaudiences, or passignoreAudience: true. - 60 seconds of leeway by default, where
jsonwebtokenhas none. A token that expired 30 seconds ago fails injsonwebtokenand passes in polyjose. Passleeway: 0to 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’sallowInsecureKeySizeshas no counterpart. - Errors are codes, not classes: see Handle errors.
Not supported, or different
Section titled “Not supported, or different”- JWE. Not part of this migration, because
jsonwebtokenhas none. polyjose supports it: see Encrypt and decrypt. jwt.decode. No unverified decoding; see above.- Encrypted PEM (
{ key, passphrase }). Decrypt the key withnode:cryptoand export a JWK, as in PEM keys. - Regular expressions in
audience, and thejwtidandnoncechecks. Audiences are exact strings; comparejtiandnonceyourself after verifying. ignoreExpirationandignoreNotBefore. There is no way to accept a token outside its validity period beyondleeway.- 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.
Checklist
Section titled “Checklist”- Replace
jsonwebtokenwith@polyjose/node, andawaitevery call. - Turn secrets into
octJWKs, and replace any secret shorter than the hash output. - Convert PEM keys to JWKs once, with
jwkFromPem; decrypt passphrase-protected keys first. - Write
exp,iat,nbf,iss,aud,subandjtias claims when you sign. - Pass
allowedAlgsto every verify, and renameissuer,audienceandclockTolerance. - Make sure the tokens you accept carry
exp, and configureaudienceswhere they carryaud. - Replace key functions with
jwtVerifyWithJwks, andjwt.decodewith the verified result. - Branch on
PolyjoseError.codeinstead of the error classes, with a default branch.