Skip to main content

@oslojs vs jose vs jsonwebtoken: Modern JWT Auth in JavaScript 2026

·PkgPulse Team

@oslojs vs jose vs jsonwebtoken: Modern JWT Auth in JavaScript 2026

TL;DR

For JWT authentication in JavaScript, jose is the universal standard (10M+ weekly downloads, works in all runtimes including Cloudflare Workers), jsonwebtoken is still dominant in pure Node.js environments but can't run in edge runtimes, and @oslojs/jwt is the new modular option designed for self-hosted auth systems that need lean, audited primitives. For most new projects, jose is the right default.

Key Takeaways

  • jose supports Web Crypto API (WHATWG) and works in Node.js, Deno, Bun, Cloudflare Workers, and browsers — the only library with true universal runtime support
  • jsonwebtoken has 30M+ weekly downloads but relies on Node.js crypto module, making it incompatible with edge runtimes
  • @oslojs/jwt is part of the Oslo v2 refactor — a family of small, dependency-free auth primitives for teams building authentication from scratch
  • JWT verification without key rotation handling is insecure; all three libraries support it differently
  • RS256 (asymmetric) is safer for multi-service architectures than HS256 (symmetric) — service B shouldn't have your signing secret
  • The jsonwebtoken security advisory from 2022 (CVE-2022-23529) prompted many migrations to jose

Why This Comparison Matters in 2026

The JavaScript authentication library landscape split in two directions over the past few years:

Direction 1: Edge-first — Cloudflare Workers, Vercel Edge Functions, and Deno require the Web Crypto API (globalThis.crypto). The Node.js crypto module doesn't exist. Libraries written in 2015 (jsonwebtoken, passport-jwt) can't run there.

Direction 2: Modular primitives — The Oslo project (from Lucia Auth's creator) refactored into individual @oslojs/* packages. Instead of one auth library, you compose @oslojs/jwt, @oslojs/oauth2, @oslojs/encoding, and @oslojs/crypto to build exactly what you need.

Understanding these two trends explains why three libraries coexist in 2026 despite solving the same problem.


jsonwebtoken: The Legacy Standard

npm: jsonwebtoken | weekly downloads: 30M+ | bundle size: ~50KB | runtime: Node.js only

jsonwebtoken has been the JWT standard for Node.js since 2013. Nearly every Express.js tutorial uses it, and it's in millions of production applications.

npm install jsonwebtoken
npm install @types/jsonwebtoken
import jwt from 'jsonwebtoken'

const SECRET = process.env.JWT_SECRET!

// Sign a JWT
const token = jwt.sign(
  { userId: user.id, role: user.role },
  SECRET,
  {
    expiresIn: '7d',
    algorithm: 'HS256',
    issuer: 'api.example.com',
    audience: 'app.example.com',
  }
)

// Verify a JWT
try {
  const payload = jwt.verify(token, SECRET, {
    algorithms: ['HS256'], // Always specify — prevents algorithm confusion attacks
    issuer: 'api.example.com',
    audience: 'app.example.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')
  }
}

RS256 (asymmetric keys) — better for microservices:

import fs from 'fs'
import jwt from 'jsonwebtoken'

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

// API gateway signs with private key
const token = jwt.sign({ userId: '123' }, privateKey, {
  algorithm: 'RS256',
  expiresIn: '1h',
})

// Downstream services verify with public key (never need private key)
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] })

The edge runtime problem: jsonwebtoken uses crypto.createSign() and related Node.js APIs. On Cloudflare Workers, Vercel Edge, or in the browser, this throws:

ReferenceError: crypto is not defined

For pure Node.js backends (Express, Fastify, NestJS), jsonwebtoken remains perfectly serviceable. For anything edge-facing, migrate to jose.


jose: The Universal Standard

npm: jose | weekly downloads: 10M+ | bundle size: ~30KB | runtime: Universal

jose (JavaScript Object Signing and Encryption) implements the full JOSE standard (JWK, JWS, JWE, JWT, JWKS) using the Web Crypto API. It works everywhere globalThis.crypto is available: Node.js 18+, Deno, Bun, Cloudflare Workers, and browsers.

npm install jose

Signing and verifying (symmetric HS256):

import { SignJWT, jwtVerify, createSecretKey } from 'jose'

const secret = createSecretKey(
  Buffer.from(process.env.JWT_SECRET!),
  'utf-8'
)

// Sign
const token = await new SignJWT({ userId: user.id, role: user.role })
  .setProtectedHeader({ alg: 'HS256' })
  .setIssuedAt()
  .setIssuer('api.example.com')
  .setAudience('app.example.com')
  .setExpirationTime('7d')
  .sign(secret)

// Verify
const { payload } = await jwtVerify(token, secret, {
  issuer: 'api.example.com',
  audience: 'app.example.com',
})

console.log(payload.userId) // TypeScript knows this exists

RS256 with generated key pairs (no files needed):

import { generateKeyPair, SignJWT, jwtVerify, exportJWK } from 'jose'

// Generate key pair at startup (or load from secret store)
const { privateKey, publicKey } = await generateKeyPair('RS256')

// Export public key as JWK for distribution to other services
const publicJWK = await exportJWK(publicKey)
console.log(JSON.stringify(publicJWK)) // Share this with downstream services

// Sign
const token = await new SignJWT({ userId: '123' })
  .setProtectedHeader({ alg: 'RS256' })
  .setExpirationTime('1h')
  .sign(privateKey)

// Verify (downstream service only needs publicKey)
const { payload } = await jwtVerify(token, publicKey)

JWKS (JSON Web Key Sets) — the professional approach for key rotation:

import { createRemoteJWKSet, jwtVerify } from 'jose'

// Fetch public keys from a JWKS endpoint (e.g., Auth0, Cognito, your own)
const JWKS = createRemoteJWKSet(
  new URL('https://api.example.com/.well-known/jwks.json')
)

// Automatically fetches, caches, and rotates keys
const { payload } = await jwtVerify(token, JWKS, {
  issuer: 'https://api.example.com',
  audience: 'my-api',
})

createRemoteJWKSet caches keys, handles key rotation gracefully, and automatically refreshes when it encounters an unknown kid (key ID). This is the pattern auth providers like Auth0 and Cognito use, and it's the right approach for production systems.

Cloudflare Workers example:

// workers/auth.ts — runs on Cloudflare edge
import { jwtVerify, createRemoteJWKSet } from 'jose'

const JWKS = createRemoteJWKSet(
  new URL('https://api.example.com/.well-known/jwks.json')
)

export async function verifyToken(request: Request): Promise<{ userId: string } | null> {
  const authorization = request.headers.get('Authorization')
  if (!authorization?.startsWith('Bearer ')) return null

  const token = authorization.slice(7)

  try {
    const { payload } = await jwtVerify(token, JWKS)
    return { userId: payload.sub! }
  } catch {
    return null
  }
}

This runs natively on Cloudflare Workers with no polyfills — jose uses crypto.subtle which is available on all modern runtimes.


@oslojs/jwt: Modular Auth Primitives

npm: @oslojs/jwt | weekly downloads: ~15K | bundle size: ~5KB | runtime: Universal

The Oslo project from Pilcrow (Lucia Auth's creator) was refactored in 2024 from a monolithic oslo package into individual @oslojs/* packages. @oslojs/jwt is the JWT primitive — deliberately minimal, with no opinion about key management or session storage.

npm install @oslojs/jwt @oslojs/crypto
import { createJWT, parseJWT, validateJWT } from '@oslojs/jwt'
import { HMAC } from '@oslojs/crypto/sha2'

const hmac = new HMAC('SHA-256')
const secretBytes = new TextEncoder().encode(process.env.JWT_SECRET)

// Create JWT
const claims = {
  sub: userId,
  iss: 'example.com',
  aud: 'api.example.com',
  exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, // 24 hours
  iat: Math.floor(Date.now() / 1000),
}

const token = await createJWT(
  'HS256',
  secretBytes,
  claims,
  hmac
)

// Parse JWT (without verification)
const [header, payload] = parseJWT(token)

// Validate JWT (signature + claims)
const validatedPayload = await validateJWT('HS256', secretBytes, token, hmac)

The Oslo approach is explicit over implicit: you see exactly what algorithms and primitives are being used, and you compose only what you need. The modular ecosystem:

PackagePurposeSize
@oslojs/jwtJWT create/parse/validate~5KB
@oslojs/oauth2OAuth 2.0 code flow~8KB
@oslojs/cryptoHMAC, SHA, timing-safe compare~3KB
@oslojs/encodingBase64, hex encoding~2KB
@oslojs/cookieCookie parsing/serialization~4KB

When to use @oslojs/jwt: You're building a custom auth system (like Lucia Auth's patterns) and want to stay in control of every primitive. The package is intentionally small — it doesn't solve JWKS, key rotation, or session management. You build those yourself.

For auth systems based on Oslo's patterns, the typical stack is:

  • @oslojs/jwt for token creation/validation
  • @oslojs/oauth2 for third-party OAuth flows
  • PostgreSQL or SQLite for session storage (no Redis required)
  • @oslojs/crypto for PKCE, TOTP, and timing-safe operations

Security Considerations

All three libraries support the same algorithms, but configuration matters:

1. Always specify allowed algorithms:

// ❌ Vulnerable — algorithm confusion attack
jwt.verify(token, secret) // attackers can set alg: "none"

// ✅ Safe — explicit algorithm list
jwt.verify(token, secret, { algorithms: ['HS256'] })
await jwtVerify(token, secret, { algorithms: ['HS256'] })

2. Validate claims explicitly:

// ✅ Always validate issuer, audience, and expiry
const { payload } = await jwtVerify(token, secret, {
  issuer: 'https://api.example.com',
  audience: 'my-app',
  // expirationTime is checked automatically
  // clockTolerance: '5s' — optional, for clock skew
})

3. Use asymmetric keys for multi-service architectures:

Gateway (signs with RS256 private key)
  ↓ token
Service A (verifies with public key only) — never needs private key
Service B (verifies with public key only) — never needs private key

If Service A or B is compromised, the attacker can't issue new tokens — they only have the public key.

4. Implement refresh token rotation:

// Short-lived access tokens (15 minutes)
// Long-lived refresh tokens (30 days, single-use)
const accessToken = await new SignJWT({ userId, type: 'access' })
  .setExpirationTime('15m')
  .sign(privateKey)

const refreshToken = await new SignJWT({ userId, type: 'refresh', jti: randomId() })
  .setExpirationTime('30d')
  .sign(privateKey)

// On refresh: validate old refresh token, invalidate it, issue both new tokens

Choosing the Right Library

RequirementRecommendation
Express/Fastify Node.js APIjsonwebtoken or jose
Cloudflare Workersjose (only option)
Vercel Edge Functionsjose
Custom auth system (self-hosted)@oslojs/jwt
Auth0/Cognito JWT validationjose (createRemoteJWKSet)
JWKS key rotationjose
Maximum downloads/communityjsonwebtoken (30M/week)
Universal runtime compatibilityjose
Smallest bundle@oslojs/jwt (~5KB)

The practical rule for 2026: If you're starting a new project, use jose. If you have an existing Node.js-only project using jsonwebtoken, there's no urgent reason to migrate unless you're adding edge runtime support. If you're building a custom auth system following Lucia Auth patterns, use @oslojs/jwt.


npm Package Health Data

PackageWeekly DownloadsGitHub StarsLast Security AuditNode.js Only
jsonwebtoken30M+17K2022 (CVE-2022-23529)✅ Yes
jose10M+6.5KRegular (panva)❌ No
@oslojs/jwt~15K~2KNew (2024)❌ No

jsonwebtoken's 2022 security advisory (CVE-2022-23529) required a major version update and was the primary catalyst for many teams migrating to jose. The vulnerability required the attacker to control the secretOrPublicKey argument — a configuration error more than a library flaw — but it accelerated the ecosystem's move to more actively maintained alternatives.


Methodology

  • npm download data from npmjs.com and Socket.dev (March 2026)
  • Security data from the National Vulnerability Database and GitHub Security Advisories
  • Runtime compatibility verified against Cloudflare Workers, Vercel Edge, and Deno documentation
  • @oslojs package documentation from oslojs.dev
  • Algorithm security recommendations from IETF RFC 7519 (JWT standard)

Evaluating authentication packages? See PkgPulse's jose health scores and jsonwebtoken analysis for live download trends and maintenance data.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.