jose is a Web Crypto API-based JWT library that works in Node.js, browser, Deno, and Edge Runtimes — new SignJWT({ sub, role }) chains .setProtectedHeader({ alg: "HS256" }), .setIssuedAt(), .setExpirationTime("7d"), .sign(secret) to create a signed token. jwtVerify(token, secret) verifies signature and claims, returning the payload. JWTExpired and JWTClaimValidationFailed errors enable typed error handling. createRemoteJWKSet(jwksUri) verifies tokens against a JWKS endpoint like Auth0 or Clerk. generateSecret("HS256") creates cryptographically secure secrets. exportSPKI/importSPKI handle RSA public keys for asymmetric signing. jose runs on Cloudflare Workers and Vercel Edge Functions without polyfills. Claude Code generates jose JWT utilities, session token helpers, API key verification middleware, and JWKS-based third-party token validation.
CLAUDE.md for jose
## jose Stack
- Version: jose >= 5.3
- Sign: new SignJWT({ sub: userId }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime("7d").sign(secret)
- Verify: const { payload } = await jwtVerify(token, secret) — throws JWTExpired, JWTClaimValidationFailed
- Secret: const secret = new TextEncoder().encode(process.env.JWT_SECRET)
- RS256: const { privateKey } = await generateKeyPair("RS256"); .sign(privateKey) + jwtVerify(token, publicKey)
- JWKS: const JWKS = createRemoteJWKSet(new URL("https://login.example.com/.well-known/jwks.json"))
- Payload: payload.sub, payload.exp, payload.iat — standard JWT claims
- Error: import { errors } from "jose" — errors.JWTExpired, errors.JWTClaimValidationFailed
JWT Utilities
// lib/auth/jwt.ts — JWT creation and verification utilities
import {
SignJWT,
jwtVerify,
generateSecret,
JWTPayload,
errors as joseErrors,
} from "jose"
// Secret from environment — use JOSE_SECRET=base64url encoded 256-bit random key
function getSecret() {
const secret = process.env.JWT_SECRET
if (!secret) throw new Error("JWT_SECRET not set")
return new TextEncoder().encode(secret)
}
// Access token — short-lived, contains minimal claims
type AccessTokenPayload = JWTPayload & {
userId: string
role: string
sessionId: string
}
export async function signAccessToken(params: {
userId: string
role: string
sessionId: string
}): Promise<string> {
return new SignJWT({
userId: params.userId,
role: params.role,
sessionId: params.sessionId,
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("15m") // Short-lived
.setSubject(params.userId)
.setAudience("api")
.setIssuer(process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000")
.sign(getSecret())
}
export async function verifyAccessToken(token: string): Promise<AccessTokenPayload> {
const { payload } = await jwtVerify(token, getSecret(), {
audience: "api",
issuer: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
algorithms: ["HS256"],
})
return payload as AccessTokenPayload
}
// Refresh token — longer-lived, used to generate new access tokens
export async function signRefreshToken(userId: string, jti: string): Promise<string> {
return new SignJWT({ type: "refresh" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("30d")
.setSubject(userId)
.setJti(jti) // Unique JWT ID for revocation
.sign(getSecret())
}
export async function verifyRefreshToken(token: string) {
const { payload } = await jwtVerify(token, getSecret(), {
algorithms: ["HS256"],
})
if (payload.type !== "refresh") throw new Error("Not a refresh token")
return { userId: payload.sub!, jti: payload.jti! }
}
// Email verification / password reset tokens (one-time use)
export async function signOneTimeToken(params: {
type: "email_verify" | "password_reset"
userId: string
email: string
}): Promise<string> {
return new SignJWT({
type: params.type,
email: params.email,
jti: crypto.randomUUID(),
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.setSubject(params.userId)
.sign(getSecret())
}
// Typed error handling
type VerifyResult<T> =
| { ok: true; payload: T }
| { ok: false; error: "expired" | "invalid" | "unknown" }
export async function safeVerify<T extends JWTPayload>(token: string): Promise<VerifyResult<T>> {
try {
const { payload } = await jwtVerify(token, getSecret())
return { ok: true, payload: payload as T }
} catch (err) {
if (err instanceof joseErrors.JWTExpired) {
return { ok: false, error: "expired" }
}
if (
err instanceof joseErrors.JWTClaimValidationFailed ||
err instanceof joseErrors.JWSSignatureVerificationFailed ||
err instanceof joseErrors.JWTInvalid
) {
return { ok: false, error: "invalid" }
}
return { ok: false, error: "unknown" }
}
}
Middleware JWT Verification
// middleware.ts — verify JWT in Next.js Edge Middleware
import { NextRequest, NextResponse } from "next/server"
import { jwtVerify, createRemoteJWKSet } from "jose"
// jose works in Edge Runtime — no Node.js API polyfills needed
const secret = new TextEncoder().encode(process.env.JWT_SECRET!)
const PUBLIC_PATHS = ["/", "/sign-in", "/sign-up", "/api/auth", "/api/webhooks"]
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
if (PUBLIC_PATHS.some(p => pathname.startsWith(p))) {
return NextResponse.next()
}
const authHeader = request.headers.get("authorization")
const cookieToken = request.cookies.get("access_token")?.value
const token = authHeader?.replace("Bearer ", "") ?? cookieToken
if (!token) {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
return NextResponse.redirect(new URL("/sign-in", request.url))
}
try {
const { payload } = await jwtVerify(token, secret, {
audience: "api",
algorithms: ["HS256"],
})
// Forward user info to handlers via headers
const response = NextResponse.next()
response.headers.set("x-user-id", payload.sub ?? "")
response.headers.set("x-user-role", (payload.role as string) ?? "user")
return response
} catch {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Invalid token" }, { status: 401 })
}
return NextResponse.redirect(new URL("/sign-in?error=session_expired", request.url))
}
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}
JWKS Verification for Third-Party Tokens
// lib/auth/jwks.ts — verify Clerk/Auth0/Okta tokens
import { createRemoteJWKSet, jwtVerify } from "jose"
// Cache JWKS — auto-refreshes periodically
const ClerkJWKS = createRemoteJWKSet(
new URL(`https://${process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY?.split("_")[1]}.clerk.accounts.dev/.well-known/jwks.json`),
{ cacheMaxAge: 600_000 }, // Cache 10 minutes
)
export async function verifyClerkToken(sessionToken: string) {
const { payload } = await jwtVerify(sessionToken, ClerkJWKS, {
issuer: `https://clerk.${process.env.NEXT_PUBLIC_APP_URL}`,
algorithms: ["RS256"],
})
return {
userId: payload.sub!,
sessionId: payload.sid as string,
role: (payload as any).metadata?.role as string | undefined,
}
}
// Generate secure secret for scripts
export async function generateJWTSecret(): Promise<string> {
const key = crypto.getRandomValues(new Uint8Array(32))
return Buffer.from(key).toString("base64url")
}
For the jsonwebtoken alternative when a simpler, synchronous Node.js-only JWT library without Web Crypto API is preferred — jsonwebtoken.sign/verify have a callback/synchronous API without async/await, but don’t work in Edge Runtimes or browsers, making jose preferable for modern full-stack apps, see the jsonwebtoken guide. For the iron-session alternative when encrypted, opaque session cookies are preferred over signed JWTs — iron-session uses Iron symmetric encryption to store session data in a cookie without a separate token store, suitable when you don’t need stateless token verification across services, see the iron-session guide. The Claude Skills 360 bundle includes jose skill sets covering JWT signing, verification, and JWKS. Start with the free tier to try token authentication generation.