jose
jose is the JavaScript implementation of the JOSE family of specs: JWT signing and verification, JWS signatures, JWE encryption, JWK keys, and remote JWKS fetching. It is built entirely on the WebCrypto API, ships zero dependencies, and runs unchanged in Node.js, browsers, Cloudflare Workers, Deno, and Bun. That runtime spread is the reason it displaced jsonwebtoken as the default for new code: the same verify call works in a Worker at the edge and in a Node server, and the maintainer also builds openid-client and oauth4webapi on top of it.
The default JWT library for new JavaScript code in 2026: standards-complete, zero-dependency, and at home in every runtime that matters. Only reach for jsonwebtoken out of legacy inertia, and for jwt-decode when you truly just need to peek at claims.
Use it if
- You verify or mint JWTs anywhere outside plain Node, such as Cloudflare Workers, Vercel Edge, Deno, or the browser, where jsonwebtoken simply does not run
- You need to verify tokens against a provider's JWKS endpoint; createRemoteJWKSet handles fetching, caching, and key rotation for you
- You need more than HS256/RS256: EdDSA, ECDH-ES, JWE encrypted tokens, or JWK thumbprints are all in the box
- You want zero dependencies in a security-critical path instead of jsonwebtoken's dependency tree
- You only need to read claims client-side without verifying: jwt-decode is a fraction of the size and does exactly that one job
- You are stuck on CommonJS with older Node: v6 is ESM, and require('jose') only works on Node ^20.19.0, ^22.12.0, or 23+, so legacy toolchains mean staying on jsonwebtoken or an old jose major
- You mint thousands of HS256 tokens per second on one Node box: fast-jwt uses Node's native crypto directly and publishes benchmarks where it comes out ahead of WebCrypto-based signing
- You expect exotic algorithms everywhere: available algorithms vary by runtime, so an alg that works in Node may be missing in a Worker, and the README tracks these gaps per runtime
- You dislike single-maintainer projects: it is effectively one very responsive person (panva) with sponsor funding, and only one major version gets support at a time, so major bumps are not optional for security fixes
Setup reality
npm install jose and there are no peer dependencies, no config, no native builds. The friction points are elsewhere: it is ESM-first, so old CJS setups need Node's require(esm) support or a bundler; every key must be a CryptoKey or Uint8Array, so string secrets from jsonwebtoken code need new TextEncoder().encode() and PEM keys need an explicit importPKCS8/importSPKI call with the algorithm named; and generated keys are non-extractable by default, which surprises people who try to export them.
Patterns
Verify a JWT with a shared secretverify-jwt-hs256
import { jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
const { payload, protectedHeader } = await jwtVerify(token, secret, {
issuer: 'urn:example:issuer',
audience: 'urn:example:audience',
algorithms: ['HS256'],
})Secrets must be Uint8Array, not a string; coming from jsonwebtoken this is the first thing that breaks. Always pin algorithms so a swapped alg header cannot downgrade verification.
Mint a signed JWTsign-jwt
import { SignJWT } from 'jose'
const jwt = await new SignJWT({ sub: 'user_123', role: 'admin' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer('urn:example:issuer')
.setAudience('urn:example:audience')
.setExpirationTime('2h')
.sign(secret)setProtectedHeader is mandatory; forgetting it throws at sign time. Relative times like '2h' are resolved against the current clock.
Verify against a provider's JWKS endpointverify-remote-jwks
import { jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(
new URL('https://auth.example.com/.well-known/jwks.json'),
)
const { payload } = await jwtVerify(token, JWKS, {
issuer: 'https://auth.example.com',
audience: 'my-api',
})Create the JWKS set once at module scope, not per request: it caches keys and refetches on unknown kid with a cooldown, which is how key rotation gets handled for free.
Generate and export an asymmetric key pairgenerate-key-pair
import { generateKeyPair, exportPKCS8, exportSPKI } from 'jose'
const { publicKey, privateKey } = await generateKeyPair('RS256', {
extractable: true,
})
const privatePem = await exportPKCS8(privateKey)
const publicPem = await exportSPKI(publicKey)Keys are non-extractable CryptoKeys by default in v6; without extractable: true the export calls fail. Leave them non-extractable when you never need to persist them.
Import PEM keys for signing or verificationimport-pem-key
import { importPKCS8, importSPKI, jwtVerify } from 'jose'
const privateKey = await importPKCS8(privatePem, 'RS256')
const publicKey = await importSPKI(publicPem, 'RS256')
const { payload } = await jwtVerify(token, publicKey)The algorithm argument is required; jose refuses to guess it from the PEM contents. importX509 exists for certificate PEMs and importJWK for JWK objects.
Decode claims without verifyingdecode-without-verify
import { decodeJwt, decodeProtectedHeader } from 'jose'
const claims = decodeJwt(token)
const header = decodeProtectedHeader(token)
console.log(claims.exp, header.alg)No signature check happens at all. Fine for showing an expiry in the UI or routing by issuer before verification; never make an auth decision from it.
Encrypted JWT (JWE) round tripencrypt-jwt
import { EncryptJWT, jwtDecrypt, generateSecret } from 'jose'
const secret = await generateSecret('A256GCM')
const jwe = await new EncryptJWT({ sub: 'user_123' })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime('10m')
.encrypt(secret)
const { payload } = await jwtDecrypt(jwe, secret)Use JWE when the claims themselves are sensitive; a normal signed JWT is readable by anyone who holds it. dir with A256GCM is the simplest shared-secret setup.
Branch on specific verification failureshandle-verify-errors
import { jwtVerify, errors } from 'jose'
try {
await jwtVerify(token, secret)
} catch (err) {
if (err instanceof errors.JWTExpired) {
// ask the client to refresh
} else if (err instanceof errors.JWSSignatureVerificationFailed) {
// tampered or wrong key
} else {
throw err
}
}Every failure is a typed class under errors with a stable code like ERR_JWT_EXPIRED, so you can also switch on err.code instead of instanceof.
Allow clock skew and cap token ageclock-tolerance
const { payload } = await jwtVerify(token, secret, {
clockTolerance: '30s',
maxTokenAge: '1h',
})clockTolerance absorbs skew between the issuer's clock and yours; maxTokenAge rejects tokens whose iat is too old even if exp has not passed, which is useful against replay of long-lived tokens.
Export a public JWK with a thumbprint kidexport-jwk-thumbprint
import { exportJWK, calculateJwkThumbprint } from 'jose'
const jwk = await exportJWK(publicKey)
jwk.kid = await calculateJwkThumbprint(jwk)
jwk.use = 'sig'
// serve { keys: [jwk] } from your own JWKS endpointThe RFC 7638 thumbprint gives you a deterministic kid, so verifiers can match keys after rotation without you inventing an id scheme.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| jsonwebtoken | npm | A legacy CommonJS Node codebase already using it and only doing plain HS256/RS256 sign and verify. |
| fast-jwt | npm | Node-only services where token throughput actually shows up in profiles and you can skip cross-runtime support. |
| jwt-decode | npm | Browser code that only reads claims for display and lets the server do the real verification. |
| paseto | npm | You control both ends and prefer PASETO's misuse-resistant token design over the JOSE spec family. |