<!-- PkgPulse AI-readable guide source -->
<!-- Canonical: https://www.pkgpulse.com/guides/oslo-vs-arctic-vs-jose-jwt-auth-libraries-nodejs-2026 -->
<!-- Raw Markdown: https://www.pkgpulse.com/guides/oslo-vs-arctic-vs-jose-jwt-auth-libraries-nodejs-2026/raw.md -->
<!-- Source path: content/guides/oslo-vs-arctic-vs-jose-jwt-auth-libraries-nodejs-2026.mdx -->

---
og_image: "/images/guides/oslo-vs-arctic-vs-jose-jwt-auth-libraries-nodejs-2026.webp"
title: "oslo vs arctic vs jose: JWT Auth for Node.js 2026"
description: "oslo vs arctic vs jose: which JWT and OAuth utility libraries should Node.js developers use in 2026? Full comparison of API, runtime support, and use cases."
date: "2026-03-09"
authors: ["team"]
tier: 2
tags: ["authentication", "jwt", "oauth", "nodejs", "security"]
---

## 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 three** — `arctic` 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](https://github.com/panva/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:

```typescript
// 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:**

```typescript
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:**

```typescript
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:**

```typescript
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):**

```typescript
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](https://arcticjs.dev) 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:

```typescript
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)

| Provider | Import | Notes |
|----------|--------|-------|
| GitHub | `GitHub` | Personal + App OAuth |
| Google | `Google` | PKCE support, OpenID Connect |
| Discord | `Discord` | Bot + user OAuth |
| Apple | `Apple` | Sign in with Apple, JWT assertions |
| Twitter/X | `Twitter` | OAuth 2.0 (PKCE) |
| LinkedIn | `LinkedIn` | OIDC profile data |
| Spotify | `Spotify` | Scoped music data |
| Twitch | `Twitch` | Stream API access |
| Microsoft | `MicrosoftEntraId` | Azure AD / Microsoft 365 |
| Facebook | `Facebook` | Meta 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:

```typescript
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](https://oslojs.dev) 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):**

```typescript
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:**

```typescript
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:**

```typescript
import { constantTimeEqual } from '@oslo/crypto'

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

**Cookie handling:**

```typescript
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`):**

```typescript
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`:**

```typescript
// 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`:**

```typescript
// 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

| Factor | jose | arctic | oslo |
|--------|------|--------|------|
| Purpose | JWT sign/verify | OAuth provider flows | Auth utility primitives |
| npm downloads/week | ~5.5M | ~180K | ~90K |
| Runtime support | All (Web Crypto) | All (Web Crypto) | All (Web Crypto) |
| Replaces | jsonwebtoken | passport-oauth2 strategies | Custom crypto utilities |
| TypeScript | ✅ | ✅ | ✅ |
| Maintained by | panva (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)

---

The emergence of `jose`, `arctic`, and `oslo` as a coordinated toolkit reflects a broader shift in the JavaScript authentication ecosystem away from monolithic frameworks. Passport.js, which dominated Node.js authentication for a decade, bundles strategy selection, session management, and serialization into a single middleware chain — convenient for Express-based apps but opaque when debugging and incompatible with modern edge runtimes. The newer libraries are deliberately minimal and composable: `jose` does exactly one thing (JWT cryptography) with no side effects, `arctic` does exactly one thing (OAuth code exchange) with no session state, and `oslo` provides stateless utility functions. This composability means the same libraries work in Next.js API routes, Hono on Cloudflare Workers, Fastify on a VPS, and Deno Deploy — the libraries have no runtime-specific dependencies to negotiate.

One area where `oslo` fills a gap not covered by either `jose` or `arctic` is the cookie attribute management for session tokens. Setting a session cookie correctly — `HttpOnly`, `Secure`, `SameSite=Lax`, with the correct `Max-Age` — is simple in theory but has subtle implementation differences across frameworks. `oslo`'s `CookieController` encodes the correct attribute string for you and, critically, handles the cookie parsing side with `cookieController.parse(cookieHeader)`, which avoids the off-by-one and edge case bugs common in manual cookie header parsing. For teams building their own auth system without a framework like NextAuth or Better Auth, this kind of low-level correctness matters: using `SameSite=Strict` instead of `SameSite=Lax` silently breaks OAuth flows because the callback redirect from the OAuth provider arrives as a cross-site navigation and the cookie is not sent. `oslo`'s defaults are deliberately set to `Lax`, which is the correct default for session cookies in OAuth flows.

*Compare JWT and auth libraries on [PkgPulse](/compare/jose-vs-jsonwebtoken) — download trends, security advisories, and dependency health.*

*Related: [Better Auth vs Lucia vs NextAuth 2026](/guides/better-auth-vs-lucia-vs-nextauth-2026) · [Lucia Auth v3 vs Better Auth vs Stack Auth 2026](/guides/lucia-auth-v3-vs-better-auth-vs-stack-auth-self-hosted-2026)*
