Skip to main content

oslo vs arctic vs jose: JWT Auth Libraries 2026

·PkgPulse Team

oslo vs arctic vs jose: JWT and OAuth Libraries for Node.js in 2026

TL;DR

The auth utility library space has fragmented productively. jose (by Panva) is the gold standard for JWT operations — verifying, signing, and parsing JWTs with full Web Crypto API support across all JavaScript runtimes. oslo (by Pilcrow, the Lucia Auth author) provides a broader collection of auth-adjacent utilities: TOTP, password hashing, cookie handling, OAuth PKCE, and session token generation — the primitives for building auth systems from scratch. arctic (also by Pilcrow) is specifically for OAuth 2.0 provider integrations — GitHub, Google, Discord, and 50+ other providers with a type-safe, runtime-agnostic API. Use jose for JWT, arctic for OAuth, and oslo for the connective tissue between them.

Key Takeaways

  • jose is the correct choice for JWT operations — 5.5M npm downloads/week, Web Crypto API (no native bindings), works in Node.js, Deno, Bun, Cloudflare Workers, and browsers
  • arctic covers 50+ OAuth providers with a consistent API — no provider-specific SDK required; one import, one pattern
  • oslo fills the "auth primitives" gap — TOTP/2FA, argon2/bcrypt wrappers, PKCE generation, session token crypto, timing-safe comparison
  • Lucia Auth v3 uses all threearctic for OAuth, oslo for core primitives, jose for optional JWT sessions
  • jsonwebtoken is legacy — it uses Node.js crypto module and doesn't work in edge runtimes; migrate to jose
  • node-oauth2-server is effectively unmaintained — arctic is the modern replacement for OAuth server-side flows

The Auth Library Landscape in 2026

Building auth from scratch in 2026 involves assembling from three distinct layers:

  1. JWT layer — sign, verify, and parse JSON Web Tokens
  2. OAuth layer — handle the OAuth 2.0 authorization code flow for third-party providers
  3. Utilities layer — password hashing, TOTP, session management, secure token generation

Historically, these were either bundled into heavy frameworks (Passport.js, Auth0 SDK) or implemented from scratch with crypto module calls. The modern approach uses lightweight, focused libraries: jose, arctic, and oslo.


jose — The JWT Standard

jose by Filip Skokan (Panva) is the definitive JWT library for the JavaScript ecosystem. At 5.5M weekly downloads and used by Next.js, Hono, Remix, and virtually every major framework's auth integration, it has earned its position as the default.

Why jose Replaced jsonwebtoken

The classic jsonwebtoken package works only in Node.js — it uses the crypto module, which doesn't exist in Cloudflare Workers, Deno, Bun (partially), or browser environments. As serverless and edge runtimes became production targets, jsonwebtoken became a blocker.

jose uses the Web Crypto API (SubtleCrypto) — the standard cryptography interface available in all modern JavaScript environments:

// jsonwebtoken — Node.js only
import jwt from 'jsonwebtoken'
const token = jwt.sign({ userId }, process.env.SECRET)

// jose — any runtime
import { SignJWT } from 'jose'
const secret = new TextEncoder().encode(process.env.SECRET)
const token = await new SignJWT({ userId })
  .setProtectedHeader({ alg: 'HS256' })
  .setIssuedAt()
  .setExpirationTime('2h')
  .sign(secret)

Core jose APIs

Signing a JWT:

import { SignJWT } from 'jose'

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

async function signToken(payload: { userId: string; role: string }) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('24h')
    .setJwtId(crypto.randomUUID())
    .sign(secret)
}

Verifying a JWT:

import { jwtVerify } from 'jose'

async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, secret, {
    requiredClaims: ['userId', 'iat', 'exp'],
  })

  return payload as { userId: string; role: string; iat: number; exp: number }
}

jwtVerify throws if the token is expired, has an invalid signature, or is missing required claims. No manual expiry checking required.

RS256 / Asymmetric keys:

import { importPKCS8, importSPKI, SignJWT, jwtVerify } from 'jose'

// Sign with private key
const privateKey = await importPKCS8(process.env.JWT_PRIVATE_KEY, 'RS256')
const token = await new SignJWT(payload)
  .setProtectedHeader({ alg: 'RS256' })
  .sign(privateKey)

// Verify with public key (can be deployed to edge without secret)
const publicKey = await importSPKI(process.env.JWT_PUBLIC_KEY, 'RS256')
const { payload } = await jwtVerify(token, publicKey)

RS256 asymmetric signing is important for architectures where verification happens in multiple services — the private key stays in the auth service, the public key can be distributed everywhere.

JWKS (JSON Web Key Sets):

import { createRemoteJWKSet, jwtVerify } from 'jose'

// Verify tokens using a remote JWKS endpoint (e.g., Auth0, Clerk, Supabase)
const JWKS = createRemoteJWKSet(
  new URL('https://your-auth-provider.com/.well-known/jwks.json')
)

const { payload } = await jwtVerify(token, JWKS, {
  audience: 'your-api',
  issuer: 'https://your-auth-provider.com/',
})

JWKS verification is how you integrate with managed auth providers (Auth0, Clerk, WorkOS) without managing keys yourself — jose fetches and caches the provider's public keys, then verifies your app's JWTs against them.


arctic — OAuth Provider Integrations

Arctic by Pilcrow (the Lucia Auth author) is a collection of OAuth 2.0 provider integrations. Instead of installing @octokit/auth-oauth-app for GitHub, google-auth-library for Google, and a different package for each provider, Arctic provides a consistent interface for 50+ providers.

The Arctic API Pattern

Every Arctic provider follows the same pattern:

import { GitHub } from 'arctic'

const github = new GitHub(
  process.env.GITHUB_CLIENT_ID,
  process.env.GITHUB_CLIENT_SECRET
)

// 1. Generate authorization URL with state and PKCE
const state = generateState()
const codeVerifier = generateCodeVerifier()  // PKCE
const authUrl = github.createAuthorizationURL(state, scopes)

// 2. Store state in cookie (for CSRF validation)
// 3. Redirect user to authUrl

// 4. Handle callback — exchange code for tokens
const tokens = await github.validateAuthorizationCode(code, codeVerifier)
// tokens.accessToken, tokens.refreshToken (if applicable)

The same pattern works for Google, Discord, Apple, Twitter, LinkedIn, Spotify — everything. No provider-specific SDK knowledge required.

Supported Providers (Selected)

ProviderImportNotes
GitHubGitHubPersonal + App OAuth
GoogleGooglePKCE support, OpenID Connect
DiscordDiscordBot + user OAuth
AppleAppleSign in with Apple, JWT assertions
Twitter/XTwitterOAuth 2.0 (PKCE)
LinkedInLinkedInOIDC profile data
SpotifySpotifyScoped music data
TwitchTwitchStream API access
MicrosoftMicrosoftEntraIdAzure AD / Microsoft 365
FacebookFacebookMeta OAuth 2.0

50+ total — check the Arctic docs for the complete list.

PKCE Support

Arctic handles Proof Key for Code Exchange (PKCE) — required for OAuth flows in SPAs and mobile apps where you can't safely store a client secret:

import { generateCodeVerifier, generateState } from 'arctic'

const codeVerifier = generateCodeVerifier()  // Random 43-128 char string
const codeChallenge = await computeCodeChallenge(codeVerifier)  // SHA-256 hash

// Send codeChallenge in authorization URL
// Store codeVerifier in session/cookie
// Send codeVerifier when exchanging the code

Arctic's Google and other PKCE-capable providers have this built into createAuthorizationURL().

Runtime Agnostic

Like jose, Arctic uses only Web Platform APIs — no Node.js-specific dependencies. It works in Cloudflare Workers, Deno, Bun, and Node.js without polyfills.


oslo — Auth Utility Primitives

Oslo is Pilcrow's collection of auth-adjacent utilities — the primitives that don't fit neatly into jose (JWT) or arctic (OAuth) but are needed in any real auth system.

Core Oslo Modules

TOTP (Time-based One-Time Password for 2FA):

import { TOTPController, createTOTPKeyURI } from '@oslo/otp'

// Generate a TOTP secret for a user
const secret = new Uint8Array(20)
crypto.getRandomValues(secret)

// Generate the QR code URI for authenticator apps
const uri = createTOTPKeyURI('MyApp', user.email, secret)
// → otpauth://totp/MyApp:user@example.com?secret=BASE32&issuer=MyApp

// Verify a TOTP code
const totp = new TOTPController()
const valid = totp.verify(otpCode, secret)
// Handles clock drift (±30 seconds window)

Session token generation:

import { generateRandomString, alphabet } from '@oslo/crypto'

// Generate a cryptographically random session token
const sessionToken = generateRandomString(40, alphabet('a-z', 'A-Z', '0-9'))
// → "xK9mPqR2vLwN8jT5uY3cE7hD1bF4sA6g"

// Hash the token before storing in database (don't store raw tokens)
const hashedToken = await sha256(new TextEncoder().encode(sessionToken))

Timing-safe comparison:

import { constantTimeEqual } from '@oslo/crypto'

// Prevent timing attacks when comparing tokens
const valid = constantTimeEqual(
  new TextEncoder().encode(submittedToken),
  new TextEncoder().encode(storedToken)
)

Cookie handling:

import { Cookie, CookieController } from '@oslo/cookie'

const cookieController = new CookieController('session', {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
  maxAge: 60 * 60 * 24 * 30,  // 30 days
})

// Parse cookies
const sessionToken = cookieController.parse(request.headers.get('Cookie'))

// Create a Set-Cookie header value
const cookieValue = cookieController.createCookie(sessionToken)

Password hashing (wrapper around @node-rs/argon2):

import { hashPassword, verifyPassword } from '@oslo/password'

// Hash on registration
const hash = await hashPassword(password)

// Verify on login
const valid = await verifyPassword(hash, password)

Oslo wraps argon2id from @node-rs/argon2 — the OWASP-recommended password hashing algorithm.


How They Work Together

In practice, jose, arctic, and oslo complement each other for a complete auth implementation:

User wants to sign in with GitHub
        ↓
arctic.GitHub.createAuthorizationURL()  ← Arctic generates OAuth URL
        ↓
User authenticates at GitHub
        ↓
arctic.GitHub.validateAuthorizationCode()  ← Arctic exchanges code for tokens
        ↓
oslo.generateRandomString()  ← Oslo creates a session token
        ↓
Store hashed session in database
        ↓
jose.SignJWT()  ← Optionally: mint a JWT with the session info

This is exactly the pattern that Lucia Auth v3 implements — arctic for OAuth provider flows, oslo for session token management, and optional jose for JWT-based sessions.


When to Use Each (Decision Guide)

Use jose when:

  • You need to sign or verify JWTs — this is the primary use case
  • You're integrating with a managed auth provider (Auth0, Clerk, Supabase) that issues JWTs you need to verify
  • You're running on edge runtimes where jsonwebtoken doesn't work
  • You need RS256/ES256 asymmetric JWT signing for distributed systems

Use arctic when:

  • You're implementing "Sign in with GitHub/Google/Discord" — any third-party OAuth
  • You want a unified API across providers instead of provider-specific SDKs
  • You're using Lucia Auth or building a similar custom auth system
  • You need PKCE-compliant OAuth for SPAs or mobile apps

Use oslo when:

  • You're building auth primitives from scratch (session management, TOTP/2FA)
  • You need cryptographically secure token generation without rolling your own
  • You want a thin, well-audited wrapper around argon2 for password hashing
  • You're implementing timing-safe comparisons to prevent side-channel attacks

Use all three together when:

  • You're building a custom auth system (not using NextAuth/Better Auth)
  • Your requirements don't fit an off-the-shelf auth library
  • You need Lucia-compatible primitives but want to manage the session layer yourself

Migrating from Legacy Libraries

From jsonwebtoken to jose:

// Before (jsonwebtoken — Node.js only)
import jwt from 'jsonwebtoken'
jwt.sign({ userId }, secret, { expiresIn: '2h' })
jwt.verify(token, secret)

// After (jose — any runtime)
import { SignJWT, jwtVerify } from 'jose'
const key = new TextEncoder().encode(secret)
await new SignJWT({ userId }).setProtectedHeader({ alg: 'HS256' })
  .setExpirationTime('2h').sign(key)
await jwtVerify(token, key)

From passport-github2 to arctic:

// Before (passport.js)
passport.use(new GitHubStrategy({
  clientID, clientSecret, callbackURL,
}, (accessToken, refreshToken, profile, done) => {
  done(null, profile)
}))

// After (arctic)
const github = new GitHub(clientID, clientSecret)
const url = github.createAuthorizationURL(state, ['user:email'])
// ... handle callback:
const tokens = await github.validateAuthorizationCode(code, codeVerifier)

Arctic's API is more explicit — you see exactly what happens at each step rather than delegating to Passport middleware magic.


Comparison Table

Factorjosearcticoslo
PurposeJWT sign/verifyOAuth provider flowsAuth utility primitives
npm downloads/week~5.5M~180K~90K
Runtime supportAll (Web Crypto)All (Web Crypto)All (Web Crypto)
Replacesjsonwebtokenpassport-oauth2 strategiesCustom crypto utilities
TypeScript
Maintained bypanva (Filip Skokan)pilcrow (Pilcrow)pilcrow (Pilcrow)

Methodology

  • npm download data from npmjs.com API, March 2026 weekly averages
  • Package versions: jose v5.x, arctic v2.x, oslo v1.x
  • Sources: jose documentation (panva.github.io/jose), Arctic docs (arcticjs.dev), Oslo docs (oslojs.dev)

Compare JWT and auth libraries on PkgPulse — download trends, security advisories, and dependency health.

Related: Better Auth vs Lucia vs NextAuth 2026 · Lucia Auth v3 vs Better Auth vs Stack Auth 2026

Comments

Stay Updated

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