はじめに
「JWTの検証、自前でjsonwebtokenを薄くラップして使っている」というプロジェクト、意外と多いのではないでしょうか。
署名アルゴリズムの指定漏れ、有効期限チェックの実装ミス、鍵の読み込み処理の書き方の揺れ。認証・認可まわりは一度書けば終わりではなく、仕様に忠実であり続けることが地味に難しい領域です。特にNode.js以外のランタイム(Cloudflare WorkersやDenoなど)にアプリを展開しようとした途端、cryptoモジュール依存のライブラリが動かなくなるというトラブルも珍しくありません。
そこで紹介したいのがjoseです。JWT・JWS・JWEといったJOSE(JSON Object Signing and Encryption)関連のRFCをゼロ依存で実装しており、Web標準のCrypto APIをベースにしているため、Node.jsだけでなくブラウザやエッジ環境でも同じコードがそのまま動きます。
joseとは
joseは、panva氏が開発・メンテナンスしているJavaScript/TypeScript向けのJOSE実装ライブラリです。JWT(RFC7519)、JWS(RFC7515)、JWE(RFC7516)、JWK(RFC7517)、JWA(RFC7518)といった関連仕様を幅広くカバーしており、npmでは週間数千万ダウンロードを超える、事実上の標準ライブラリとなっています。
最大の特徴は、Node.jsのネイティブcryptoモジュールではなく、Web標準のSubtleCrypto(Web Crypto API)を利用して実装されている点です。これにより、追加の依存パッケージなしで複数の実行環境に対応できています。
主な特徴
- ゼロ依存 - 依存パッケージが一切なく、サプライチェーンリスクを抑えられます
- マルチランタイム対応 - Node.js、ブラウザ、Cloudflare Workers、Deno、Bun、Electronの6環境で同じAPIが動作します
- 仕様準拠のフルカバレッジ - JWT/JWS/JWEの署名・検証・暗号化・復号に加え、JWK・JWKSの入出力にも対応しています
インストール
npm、yarn、pnpmのいずれからでもインストールできます。
# npm
npm install jose
# yarn
yarn add jose
# pnpm
pnpm add jose
TypeScriptの型定義も同梱されているため、@typesパッケージを別途入れる必要はありません。
基本的な使い方
まずは、共通鍵(HMAC)を使ってJWTを発行し、検証するシンプルな例から見ていきましょう。
JWTの発行
import * as jose from 'jose'
// 本番環境では環境変数などから読み込んだ秘密鍵を使用してください
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
const alg = 'HS256'
const jwt = await new jose.SignJWT({ role: 'member' })
.setProtectedHeader({ alg })
.setIssuedAt()
.setIssuer('urn:example:issuer')
.setAudience('urn:example:audience')
.setExpirationTime('2h')
.sign(secret)
console.log(jwt)
SignJWTはメソッドチェーンでクレーム(発行者・対象者・有効期限など)を組み立てられるのが特徴です。.setExpirationTime('2h')のように相対時間の文字列を渡せるので、有効期限の計算ミスを防げます。
JWTの検証
import * as jose from 'jose'
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
try {
const { payload } = await jose.jwtVerify(jwt, secret, {
issuer: 'urn:example:issuer',
audience: 'urn:example:audience',
})
console.log(payload) // { role: 'member', iat: ..., exp: ..., iss: ..., aud: ... }
} catch (err) {
// 署名不正・期限切れ・issuer/audience不一致などはここで例外になる
console.error('トークンの検証に失敗しました', err)
}
issuerやaudienceをオプションで渡すだけで、署名検証に加えてクレームの照合まで一括で行ってくれます。自前でifを並べてクレームをチェックする必要がありません。
実践的なユースケース
RS256による非対称鍵署名
サーバー間連携や公開鍵での検証が必要な場面では、RSAやECの鍵ペアを使った非対称署名が有効です。
import * as jose from 'jose'
// 鍵ペアを生成(初回のみ。実運用では生成した鍵を安全に保管して再利用する)
const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
const jwt = await new jose.SignJWT({ role: 'admin' })
.setProtectedHeader({ alg: 'RS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(privateKey)
// 公開鍵は他サービスに配布し、検証だけを行わせることができる
const { payload } = await jose.jwtVerify(jwt, publicKey)
console.log(payload)
秘密鍵を持つサーバーだけがトークンを発行でき、公開鍵さえ配れば他のマイクロサービス側は秘密鍵を持たずに検証だけできます。マイクロサービス構成での認証基盤にそのまま応用できる形です。
JWEでペイロード自体を暗号化する
JWTのクレームは署名だけでは暗号化されず、Base64URLでデコードすれば中身が読めてしまいます。個人情報など、第三者に見られたくない値をトークンに含める場合はJWE(JSON Web Encryption)を使います。
import * as jose from 'jose'
const secret = jose.base64url.decode(process.env.JWE_SECRET_B64URL!)
// 暗号化
const jwe = await new jose.EncryptJWT({ email: 'user@example.com' })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt()
.setExpirationTime('30m')
.encrypt(secret)
// 復号
const { payload } = await jose.jwtDecrypt(jwe, secret)
console.log(payload) // { email: 'user@example.com', iat: ..., exp: ... }
パスワードリセット用のワンタイムトークンなど、「改ざん防止」だけでなく「内容の秘匿」も必要なケースで役立ちます。
Cloudflare WorkersやDenoでも同じコードが動く
joseはWeb Crypto APIベースなので、Node.js用に書いたJWT検証ロジックをそのままCloudflare WorkersやDeno Deployに移植できます。エッジでの認証チェックのように、Node.jsのcryptoモジュールが使えない環境でも追加の書き換えが不要です。
まとめ
joseは、JWT・JWE・JWKといったJOSE仕様をゼロ依存かつ仕様準拠で実装した、事実上の標準ライブラリです。HMACによる手軽な署名検証から、RSA/EC鍵ペアを使った非対称署名、ペイロードを秘匿するJWEまで、認証・認可まわりで必要になる処理を一通りカバーしています。
何より、Web Crypto APIをベースにしているためNode.js・ブラウザ・Cloudflare Workers・Deno・Bun・Electronの6環境で同じコードが動くという点は、マルチランタイムを前提とする現在の開発において大きな強みです。自前のJWT実装や古いライブラリからの移行先として、まず検討してみる価値のあるライブラリだと言えるでしょう。
