Skip to main content

Guide

jose vs jsonwebtoken vs fast-jwt: JWT for Node.js 2026

Compare jose, jsonwebtoken, and fast-jwt for JSON Web Tokens in Node.js. RS256 vs HS256, JWK support, edge runtime compatibility, TypeScript, and performance.

·PkgPulse Team·
0

TL;DR

jose is the modern standard for JWT in 2026 — it uses the Web Crypto API, works in every runtime (Node.js, Deno, Bun, Cloudflare Workers, browsers), supports JWK/JWKS for rotating keys, and handles JWE (encrypted tokens) in addition to JWS (signed tokens). jsonwebtoken is the legacy choice — simple, synchronous API, but Node.js-only, no edge runtime support, and slower for RS256. fast-jwt is the performance-focused alternative with worker thread support and async flows. For any new project in 2026, use jose.

Key Takeaways

  • jose: ~5M weekly downloads — Web Crypto API, all runtimes, JWK/JWKS, JWE/JWS/JWK
  • jsonwebtoken: ~12M weekly downloads — legacy, sync API, Node.js-only, widely used
  • fast-jwt: ~700K weekly downloads — fastest Node.js JWT, worker threads, sync + async
  • jose works in Cloudflare Workers, Vercel Edge, Deno, Bun — jsonwebtoken does not
  • JWKS (JSON Web Key Set) for rotating public keys is a jose-native feature
  • For RS256 (asymmetric) tokens, jose is significantly faster than jsonwebtoken

PackageWeekly DownloadsEdge RuntimeJWKSJWE (Encryption)
jose~5M✅ All runtimes
jsonwebtoken~12M❌ Node.js only
fast-jwt~700K❌ Node.js only

jose

jose is the comprehensive JOSE (JSON Object Signing and Encryption) library — covers JWT, JWS, JWE, JWK, JWKS, and more.

Signing and verifying JWTs (HS256 — symmetric)

import { SignJWT, jwtVerify } from "jose"

const secret = new TextEncoder().encode(process.env.JWT_SECRET)  // HMAC-SHA256 key

// Sign:
async function signToken(payload: { userId: string; role: string }) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("1h")
    .setIssuer("pkgpulse.com")
    .setAudience("pkgpulse-api")
    .sign(secret)
}

const token = await signToken({ userId: "user-123", role: "admin" })

// Verify:
async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, secret, {
    issuer: "pkgpulse.com",
    audience: "pkgpulse-api",
  })
  return payload as { userId: string; role: string }
}

const claims = await verifyToken(token)
console.log(claims.userId)   // "user-123"
console.log(claims.role)     // "admin"
import { SignJWT, jwtVerify, generateKeyPair, exportJWK, importJWK } from "jose"

// Generate key pair (do this once, store keys securely):
const { privateKey, publicKey } = await generateKeyPair("RS256")

// Export keys as JWK for storage:
const privateJwk = await exportJWK(privateKey)
const publicJwk = await exportJWK(publicKey)

// Sign with private key:
async function signRSA(payload: object) {
  const privKey = await importJWK(privateJwk, "RS256")

  return new SignJWT(payload as any)
    .setProtectedHeader({ alg: "RS256", kid: "my-key-id" })
    .setIssuedAt()
    .setExpirationTime("15m")  // Short-lived — use refresh tokens
    .sign(privKey)
}

// Verify with public key only:
async function verifyRSA(token: string) {
  const pubKey = await importJWK(publicJwk, "RS256")
  const { payload } = await jwtVerify(token, pubKey)
  return payload
}

JWKS — rotating keys (OAuth2/OIDC pattern)

import { createRemoteJWKSet, jwtVerify } from "jose"

// Fetch public keys from JWKS endpoint (Auth0, Clerk, Google, etc.):
const JWKS = createRemoteJWKSet(
  new URL("https://your-tenant.us.auth0.com/.well-known/jwks.json")
)

// Verify JWT from any identity provider:
async function verifyAuth0Token(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: "https://your-tenant.us.auth0.com/",
    audience: "https://api.pkgpulse.com",
  })
  return payload
}

// Clerk example:
const ClerkJWKS = createRemoteJWKSet(
  new URL("https://api.clerk.com/v1/jwks")
)

async function verifyClerkToken(sessionToken: string) {
  const { payload } = await jwtVerify(sessionToken, ClerkJWKS)
  return payload as {
    sub: string  // User ID
    email: string
    metadata: Record<string, unknown>
  }
}

Edge runtime (Cloudflare Workers, Vercel Edge)

// app/api/auth/middleware.ts — Vercel Edge Runtime
export const runtime = "edge"

import { jwtVerify } from "jose"

const secret = new TextEncoder().encode(process.env.JWT_SECRET)

export async function middleware(request: Request) {
  const authHeader = request.headers.get("authorization")
  if (!authHeader?.startsWith("Bearer ")) {
    return new Response("Unauthorized", { status: 401 })
  }

  const token = authHeader.slice(7)

  try {
    const { payload } = await jwtVerify(token, secret)
    // Add user info to request headers for downstream handlers:
    const headers = new Headers(request.headers)
    headers.set("x-user-id", String(payload.sub))
    headers.set("x-user-role", String(payload.role))

    return fetch(request, { headers })
  } catch {
    return new Response("Invalid token", { status: 401 })
  }
}

jsonwebtoken

jsonwebtoken — the legacy standard, still widely used in Node.js-only apps:

Basic sign/verify

import jwt from "jsonwebtoken"

const SECRET = process.env.JWT_SECRET!

// Synchronous sign (simpler but blocks):
const token = jwt.sign(
  { userId: "user-123", role: "admin" },
  SECRET,
  {
    expiresIn: "1h",
    issuer: "pkgpulse.com",
    algorithm: "HS256",
  }
)

// Synchronous verify:
try {
  const payload = jwt.verify(token, SECRET, {
    issuer: "pkgpulse.com",
  }) as { userId: string; role: string }
  console.log(payload.userId)
} catch (err) {
  if (err instanceof jwt.TokenExpiredError) {
    console.error("Token expired")
  } else if (err instanceof jwt.JsonWebTokenError) {
    console.error("Invalid token")
  }
}

// Async with callback:
jwt.sign({ userId: "123" }, SECRET, { expiresIn: "1h" }, (err, token) => {
  if (err) throw err
  console.log(token)
})

RS256 with files

import jwt from "jsonwebtoken"
import { readFileSync } from "fs"

const privateKey = readFileSync("./keys/private.pem")
const publicKey = readFileSync("./keys/public.pem")

// Sign with private key:
const token = jwt.sign({ userId: "user-123" }, privateKey, {
  algorithm: "RS256",
  expiresIn: "15m",
})

// Verify with public key:
const payload = jwt.verify(token, publicKey, {
  algorithms: ["RS256"],
})

fast-jwt

fast-jwt — fastest Node.js JWT with caching and worker thread support:

import { createSigner, createVerifier } from "fast-jwt"

// Create reusable signer/verifier (compiles once, reuses):
const sign = createSigner({ key: process.env.JWT_SECRET! })
const verify = createVerifier({ key: process.env.JWT_SECRET! })

// Sign (async by default):
const token = await sign({ userId: "user-123", role: "admin" })

// Verify:
const payload = await verify(token)
console.log(payload.userId)

// Synchronous (faster if you're sure about the blocking):
const signSync = createSigner({ key: process.env.JWT_SECRET!, sync: true })
const verifySync = createVerifier({ key: process.env.JWT_SECRET!, sync: true })

const tokenSync = signSync({ userId: "123" })
const payloadSync = verifySync(tokenSync)

// Caching — fastest verification for repeated tokens:
const cachedVerify = createVerifier({
  key: process.env.JWT_SECRET!,
  cache: true,          // Cache decoded tokens
  cacheTTL: 60000,      // Cache for 60 seconds
})

Feature Comparison

Featurejosejsonwebtokenfast-jwt
Edge runtimes✅ All❌ Node.js only❌ Node.js only
JWKS support✅ Built-in
JWE (encryption)
ES256/RS256/PS256✅ All
Synchronous API❌ Async only
Token caching
Worker threads
TypeScript✅ @types
Performance (RS256)⚠️ Slow✅ Fastest
Maintenance✅ Active✅ Active✅ Active

When to Use Each

Choose jose if:

  • You need edge runtime support (Cloudflare Workers, Vercel Edge)
  • You need JWKS for verifying tokens from Auth0, Clerk, Google, or other OIDC providers
  • You need JWE (encrypted tokens) for sensitive payload data
  • You're starting a new project — most modern and feature-complete

Choose jsonwebtoken if:

  • Legacy Node.js app already using it — no reason to migrate what works
  • You want the synchronous jwt.sign() / jwt.verify() API
  • Simple HS256 tokens with no JWKS needs

Choose fast-jwt if:

  • Performance is critical — high-traffic API verifying thousands of tokens/second
  • You want token caching to avoid repeated cryptographic operations
  • Node.js only app where edge runtime isn't needed

Migration Guide

From jsonwebtoken to jose

The primary motivation is edge runtime support. jose uses the Web Crypto API so it works in Cloudflare Workers, Vercel Edge Functions, and Deno without polyfills:

// jsonwebtoken (old — Node.js only)
import jwt from "jsonwebtoken"
const token = jwt.sign({ userId: "123" }, process.env.JWT_SECRET!, { expiresIn: "1h" })
const payload = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string }

// jose (new — works on any runtime)
import { SignJWT, jwtVerify } from "jose"
const secret = new TextEncoder().encode(process.env.JWT_SECRET)

const token = await new SignJWT({ userId: "123" })
  .setProtectedHeader({ alg: "HS256" })
  .setExpirationTime("1h")
  .setIssuedAt()
  .sign(secret)

const { payload } = await jwtVerify(token, secret)

For JWKS-based verification from identity providers (Clerk, Auth0, Google), only jose supports remote key set fetching via createRemoteJWKSet.

Community Adoption in 2026

jose has grown rapidly to approximately 15 million weekly downloads, driven by the ecosystem shift toward edge runtimes and the adoption of OIDC standards. Auth.js (formerly NextAuth), Hono's JWT middleware, and most modern authentication libraries use jose internally. It is the actively recommended choice for new projects.

jsonwebtoken maintains over 20 million weekly downloads despite its Node.js-only limitation, reflecting the enormous installed base of existing applications. Auth0 continues to maintain it actively. For applications that have no edge runtime requirement and are already using jsonwebtoken, migration provides minimal benefit beyond future-proofing.

fast-jwt sits at around 500,000 weekly downloads, used primarily in high-throughput API gateways where the token caching feature provides measurable latency improvements. Its cache stores decoded payloads by token string hash, reducing verification cost to near-zero for frequently repeated tokens in session-heavy APIs.

Token Security and Key Management

JWT security depends on three factors: algorithm choice, key management, and token validation rigor. Each library has different defaults and constraints that affect security posture.

Algorithm selection is the most critical decision. All three libraries support HS256 (HMAC-SHA256 with a shared secret) and RS256 (RSA with public/private keys). The none algorithm — which skips signing entirely — was a source of major vulnerabilities in older JWT libraries. jose explicitly rejects none signatures unless you pass algorithms: ['none'] explicitly, making it safe by default. jsonwebtoken similarly rejects none by default since v9.

Key rotation is an area where jose has a clear advantage. jose's createRemoteJWKSet() function fetches a JWKS (JSON Web Key Set) endpoint, automatically caches it, and rotates keys based on the Cache-Control header. This is the standard pattern for OAuth 2.0 and OIDC — your auth server publishes public keys at a /.well-known/jwks.json endpoint, and your API server validates tokens against those rotating keys without any manual key distribution. jsonwebtoken does not have built-in JWKS support; you need an additional library (jwks-rsa) or manual key fetching.

Token expiration and clock skew require careful handling. By default, jose validates exp (expiration), nbf (not before), and iat (issued at) claims strictly. A configurable clock tolerance (clockTolerance: '10s') handles minor server clock differences in distributed systems. Both jsonwebtoken and fast-jwt expose similar tolerance settings. The important point is that all three libraries validate expiration by default — a common mistake is to use a verify function incorrectly and bypass claim validation.

Refresh token patterns are not directly handled by any of the three libraries — they manage JWT cryptography only, not session lifecycle. Implementing refresh tokens (short-lived access tokens + long-lived refresh tokens stored server-side) is application logic that sits above the JWT library layer. That said, jose's support for opaque token references and nested JWTs (JWE with JWS inside) makes it the appropriate choice for more complex token schemes like those required by financial-grade OIDC profiles (FAPI).

A frequently misunderstood aspect of JWT security is the distinction between signing (proving authenticity) and encryption (providing confidentiality). Standard JWTs (JWS — JSON Web Signatures) are signed but not encrypted: the payload is Base64-encoded and visible to anyone who intercepts the token. Sensitive claims like user email or internal role assignments should not be placed in standard JWT payloads if the token traverses untrusted networks. jose uniquely provides JWE (JSON Web Encryption) support for encrypting the payload, which is required by financial-grade OIDC and healthcare data exchange standards. jsonwebtoken and fast-jwt do not support JWE.

Methodology

Download data from npm registry (weekly average, February 2026). Performance benchmarks from fast-jwt repository. Feature comparison based on jose v5.x, jsonwebtoken v9.x, and fast-jwt v3.x.

Compare security and authentication packages on PkgPulse →

Compare jose and jsonwebtoken package health on PkgPulse.

The download disparity between jsonwebtoken (~20M/week) and jose (~15M/week) reflects the enormous installed base of existing Node.js applications rather than active preference. New projects in 2026 that appear in developer surveys consistently prefer jose — it's the default in Auth.js, Hono's JWT middleware, and most framework authentication guides. The jsonwebtoken downloads are driven by maintenance of existing applications where the upgrade path exists but carries risk: changing JWT libraries means re-testing all authentication flows, and for applications that have been stable for years, the motivation to migrate is low unless edge runtime support becomes a requirement. Teams evaluating both for new projects should default to jose; teams maintaining existing jsonwebtoken deployments should only migrate when they have a concrete need (edge runtime, JWKS, JWE support) that justifies the testing effort.

A subtle jose behavior worth knowing before adopting it: SignJWT requires that you call .setProtectedHeader({ alg: 'HS256' }) explicitly — there is no default algorithm. This is a deliberate security decision: forcing the developer to declare the algorithm prevents the class of attack where a library accepts the algorithm from the token header itself rather than enforcing it server-side. The downside is that forgetting .setProtectedHeader() produces a runtime error rather than a silently insecure token. For teams migrating from jsonwebtoken — where jwt.sign(payload, secret) defaults to HS256 without requiring an explicit algorithm declaration — this is the most common mistake in jose migrations. The verification side mirrors this: jwtVerify(token, key, { algorithms: ['RS256'] }) should always specify the expected algorithm to prevent algorithm-confusion attacks when the key type is ambiguous.

See also: bcrypt vs argon2 vs scrypt: Password Hashing in 2026 and SuperTokens vs Hanko vs Authelia, Node.js Crypto vs @noble/hashes vs crypto-js.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.