「JWT認証を本番品質で実装したい」「access token と refresh token のフローを正しく組みたい」「XSS / CSRF / Token失効まで全部一気通貫で押さえたい」。そんな声に応える完全実装ガイドです。本記事は jose 5.x + Node.js 22 LTS + Next.js 15 App Router を前提に、JWT の構造理解から本番運用までを 40 個以上のコピペで動く TypeScript コードで解説します。Express / Hono / iron-session / NextAuth.js との比較、WebAuthn / TOTP / Passkey まで網羅します。
- この記事で身につくこと
- 1. JWT とは何か:構造と仕組みを理解する
- 2. 署名アルゴリズムの選び方
- 3. jose ライブラリのセットアップと基本操作
- 4. payload に何を入れるか:設計の原則
- 5. 秘密鍵管理と鍵ローテーション
- 6. Access Token + Refresh Token 完全実装
- 7. Cookie vs LocalStorage:なぜ httpOnly Cookie 一択か
- 8. CSRF 対策:Double Submit Cookie パターン
- 9. XSS 対策:Content-Security-Policy と入力サニタイズ
- 10. Token 失効:blocklist と Sliding Session
- 11. Express で本番品質の JWT middleware
- 12. Hono の JWT middleware と Cloudflare Workers
- 13. Next.js App Router の認証実装
- 14. iron-session・NextAuth.js との使い分け
- 15. パスワードハッシュ:bcrypt / argon2
- 16. password reset フロー
- 17. 多要素認証(TOTP)
- 18. WebAuthn / Passkey 導入
- 19. テスト:supertest で認証エンドポイントを検証
- 20. アンチパターンとよくある落とし穴
- 21. 本番チェックリスト
- 独学が辛いと感じたら短期集中という選択肢もある
- まとめ
この記事で身につくこと
- JWT の構造(header / payload / signature)と署名アルゴリズムの選び方
- jose ライブラリでの sign / verify と HS256 / RS256 / ES256 / EdDSA の実装
- Access Token + Refresh Token のフルフロー設計と実装
- httpOnly Cookie / SameSite / Secure による XSS・CSRF 対策
- Token失効(blocklist / jti)と Sliding Session の実装
- Next.js Server Action / middleware による保護
- NextAuth.js (Auth.js) v5 と iron-session との使い分け
- bcrypt / argon2 によるパスワードハッシュ・MFA(TOTP)・Passkey(WebAuthn)
- supertest による認証エンドポイントの自動テスト
1. JWT とは何か:構造と仕組みを理解する
1.1 JWT は3つのドットで区切られた文字列
JWT(JSON Web Token / RFC 7519)は xxx.yyy.zzz の形をした文字列です。header.payload.signature の3パートを base64url でエンコードして連結しています。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsImV4cCI6MTczMjcwMDAwMH0.h3lpQk7s4kQpY8X9mNvCw2zRtY1gG5sLwK0pZjFiUew
1.2 header(ヘッダ)に入るもの
{
"alg": "HS256", // 署名アルゴリズム
"typ": "JWT", // トークン種別
"kid": "key-2026" // 鍵ID(ローテーションで使う)
}
1.3 payload(クレーム)の標準フィールド
// RFC 7519 が定める Registered Claim Names
type StandardClaims = {
iss?: string; // issuer:発行者(自サービスURL等)
sub?: string; // subject:ユーザID
aud?: string | string[]; // audience:想定受信者
exp?: number; // expiration:失効UNIX秒
nbf?: number; // not before:有効開始
iat?: number; // issued at:発行時刻
jti?: string; // JWT ID:一意ID(失効リスト用)
};
1.4 signature(署名)の計算
// HS256 の場合
// signature = HMAC_SHA256(
// base64url(header) + "." + base64url(payload),
// secret
// )
// → これを base64url で文字列化したものが3つめのパート
1.5 JWT ではない「JWS / JWE」との関係
JWT は仕様、JWS は署名付き表現、JWE は暗号化表現。
通常「JWT」と呼ぶとき実体は JWS(署名のみ・中身は base64url で誰でも読める)。
機密データを JWT に入れたいときは JWE(暗号化)を選ぶ。
2. 署名アルゴリズムの選び方
2.1 HS256 / RS256 / ES256 / EdDSA の比較表
| アルゴリズム | 種類 | 鍵長 | 速度 | 用途 |
|-------------|---------|-----------|-------|----------------------------|
| HS256 | 共通鍵 | 32B以上 | 最速 | 単一サービス内 |
| RS256 | 公開鍵 | 2048bit+ | 遅い | 公開検証・OIDC |
| ES256 | 楕円 | P-256 | 中速 | モバイル・サイズ最小化 |
| EdDSA | Ed25519 | 32B | 速い | モダン・小サイズ・高セキュア |
2.2 HS256(共通鍵)を選ぶケース
// 単一バックエンドが発行・検証する場合のみOK
// 鍵が漏れたら誰でも偽造できる → 検証する側にも秘密鍵を渡す必要がある場合は不適
// 最低32バイト(256bit)の暗号学的乱数を使う
import { randomBytes } from "crypto";
const secret = randomBytes(32).toString("base64url");
console.log(secret); // → .env に保存する
2.3 RS256(公開鍵)を選ぶケース
// 発行者と検証者が分かれる場合(OIDC・マイクロサービス間)
// 秘密鍵で署名 → 公開鍵で誰でも検証できる
// 鍵ペア生成は openssl で:
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem
2.4 ES256(楕円曲線)を選ぶケース
// RS256 と同じ公開鍵方式だが鍵サイズが圧倒的に小さい
// JWT サイズが減るのでモバイル / Cookie 制限に有利
// openssl ecparam -name prime256v1 -genkey -noout -out ec-private.pem
// openssl ec -in ec-private.pem -pubout -out ec-public.pem
2.5 alg: none は絶対に許可しない
// JWT 仕様には alg: "none"(署名なし)が存在する
// 一部の脆弱ライブラリでは検証時に alg を信用してしまうため、
// alg: none に書き換えるだけで偽造できる事故が頻発した
// → 検証側で必ずアルゴリズムを明示固定する
await jwtVerify(token, key, {
algorithms: ["ES256"], // ←必ず明示
});
3. jose ライブラリのセットアップと基本操作
3.1 install と最小例
npm install jose
# jsonwebtoken は古い設計 + メンテ停止リスクがあるため
# Node.js 18+ では jose 推奨
3.2 HS256 で sign / verify
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET!); // 32B以上
export async function signToken(payload: Record<string, unknown>) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setIssuedAt()
.setIssuer("https://app.example.com")
.setAudience("https://api.example.com")
.setExpirationTime("15m")
.setJti(crypto.randomUUID())
.sign(secret);
}
export async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, secret, {
issuer: "https://app.example.com",
audience: "https://api.example.com",
algorithms: ["HS256"],
});
return payload;
}
3.3 RS256 で sign / verify(鍵ペア)
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
import { readFileSync } from "node:fs";
const privatePem = readFileSync("./keys/private.pem", "utf8");
const publicPem = readFileSync("./keys/public.pem", "utf8");
const privateKey = await importPKCS8(privatePem, "RS256");
const publicKey = await importSPKI(publicPem, "RS256");
export async function signRS256(payload: object) {
return await new SignJWT(payload as Record<string, unknown>)
.setProtectedHeader({ alg: "RS256", kid: "key-2026-01" })
.setIssuedAt()
.setExpirationTime("15m")
.sign(privateKey);
}
export async function verifyRS256(token: string) {
const { payload, protectedHeader } = await jwtVerify(token, publicKey, {
algorithms: ["RS256"],
});
return { payload, kid: protectedHeader.kid };
}
3.4 EdDSA(Ed25519)で sign / verify
import { generateKeyPair, SignJWT, jwtVerify, exportJWK } from "jose";
// 起動時に生成 or 既存鍵をロード
const { publicKey, privateKey } = await generateKeyPair("EdDSA", {
crv: "Ed25519",
extractable: true,
});
const token = await new SignJWT({ sub: "user_123" })
.setProtectedHeader({ alg: "EdDSA" })
.setExpirationTime("15m")
.sign(privateKey);
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ["EdDSA"],
});
console.log(payload);
console.log(await exportJWK(publicKey)); // JWKSとして公開する場合
3.5 JWKS エンドポイントを返す
// /.well-known/jwks.json を公開すると検証側はキャッシュ可能
import { exportJWK } from "jose";
import express from "express";
const app = express();
app.get("/.well-known/jwks.json", async (_req, res) => {
const jwk = await exportJWK(publicKey);
res.json({
keys: [{ ...jwk, kid: "key-2026-01", alg: "EdDSA", use: "sig" }],
});
});
3.6 jose で JWKS を fetch して検証
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://issuer.example.com/.well-known/jwks.json"),
{ cacheMaxAge: 600_000, cooldownDuration: 30_000 }
);
export async function verifyExternal(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://issuer.example.com",
audience: "https://api.example.com",
});
return payload;
}
4. payload に何を入れるか:設計の原則
4.1 入れて良い情報・入れてはいけない情報
// ✅ 入れて良い:識別と権限の最小情報
type AppPayload = {
sub: string; // ユーザID(主キー)
role: "user" | "admin";
email_verified: boolean;
exp: number;
iat: number;
jti: string;
};
// ❌ 入れてはいけない:
// - パスワード / クレジットカード番号
// - 個人情報(住所・電話・本名)
// - DB1次データ(ニックネーム等 → アプリ側で都度取得)
// JWT は base64 で誰でも復号できる、署名されているだけ
4.2 サイズはなるべく小さく
// JWT は毎リクエストで送るため肥大化はネットワーク負荷直撃
// 1.5KB を超えると Cookie の上限(4KB)に近づく
// → 最低限のクレームのみ + DBで補完が原則
const minimalPayload = {
sub: "u_8x7Y2",
r: "admin", // role を1文字に短縮
};
4.3 exp / nbf / iat の使い分け
// exp は必須:失効を意識しない JWT は時限爆弾
// nbf は予約発行(キャンペーン開始時刻等)に使う
// iat は監査・「N分以内発行のみ受理」等のフィルタに使える
const now = Math.floor(Date.now() / 1000);
const token = await new SignJWT({})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(now)
.setNotBefore(now + 60) // 1分後から有効
.setExpirationTime(now + 60 * 15) // 16分後で失効
.sign(secret);
4.4 jti(JWT ID)で個別失効を可能にする
// jti を付けておくと「このトークンだけ失効」が可能
const token = await new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "HS256" })
.setJti(crypto.randomUUID())
.setExpirationTime("15m")
.sign(secret);
// ログアウト時はこの jti を Redis 等の blocklist に書き込む
5. 秘密鍵管理と鍵ローテーション
5.1 .env で管理する基本
# .env.local
JWT_SECRET="kBp7vQ_xN3yL9aR2sT5wM8zF1hJ6cD4eG0iU"
JWT_REFRESH_SECRET="aZ9xY1cV3bN5mQ7wE2rT4yU6iO8pP0sS9dD2"
# 必ず .gitignore に .env.local を入れる
# 本番は AWS Secrets Manager / GCP Secret Manager / Vercel Env Vars 等
5.2 起動時のバリデーション
import { z } from "zod";
const envSchema = z.object({
JWT_SECRET: z.string().min(32, "JWT_SECRET must be ≥32 chars"),
JWT_REFRESH_SECRET: z.string().min(32),
JWT_ISSUER: z.string().url(),
JWT_AUDIENCE: z.string().url(),
});
export const env = envSchema.parse(process.env);
// 起動時に envSchema が落ちれば即クラッシュ → 本番事故防止
5.3 鍵ローテーション戦略
// 旧鍵と新鍵を両方持って段階的に切り替える
const keys = new Map<string, Uint8Array>([
["k-2026-04", new TextEncoder().encode(process.env.JWT_SECRET_OLD!)],
["k-2026-05", new TextEncoder().encode(process.env.JWT_SECRET_NEW!)],
]);
const CURRENT_KID = "k-2026-05";
export async function signWithCurrent(payload: object) {
return await new SignJWT(payload as Record<string, unknown>)
.setProtectedHeader({ alg: "HS256", kid: CURRENT_KID })
.setExpirationTime("15m")
.sign(keys.get(CURRENT_KID)!);
}
export async function verifyAnyKey(token: string) {
return await jwtVerify(token, async (header) => {
const k = keys.get(header.kid as string);
if (!k) throw new Error("Unknown kid");
return k;
});
}
5.4 ローテーション運用フロー
1. 新鍵 k-NEW を環境変数に追加(発行・検証側ともに)
2. アプリを再起動 → 検証は OLD/NEW 両対応に
3. アプリの sign を NEW に切り替えてデプロイ
4. OLD で発行されたトークンが exp 切れまで待機(15分等)
5. OLD を環境変数から削除して再デプロイ
6. Access Token + Refresh Token 完全実装
6.1 なぜ2種類のトークンが必要か
- Access Token: 短命(5〜15分)・API認証に使う・盗まれても被害は時間制限
- Refresh Token: 長命(7〜30日)・新しい Access を発行するためだけに使う
→ DBで管理し、jti失効や rotation で盗難検知可能
→ 短命+長命を組み合わせて「セキュア」と「使い勝手」を両立
6.2 login エンドポイントで両方発行
// app/api/auth/login/route.ts (Next.js)
import { NextRequest, NextResponse } from "next/server";
import { SignJWT } from "jose";
import bcrypt from "bcryptjs";
import { db } from "@/lib/db";
import { cookies } from "next/headers";
const accessKey = new TextEncoder().encode(process.env.JWT_SECRET!);
const refreshKey = new TextEncoder().encode(process.env.JWT_REFRESH_SECRET!);
export async function POST(req: NextRequest) {
const { email, password } = await req.json();
const user = await db.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return NextResponse.json({ error: "invalid_credentials" }, { status: 401 });
}
const jti = crypto.randomUUID();
const access = await new SignJWT({ sub: user.id, role: user.role })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m")
.setJti(jti)
.sign(accessKey);
const refresh = await new SignJWT({ sub: user.id, jti })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30d")
.sign(refreshKey);
// refresh は DB 側にも保存して rotation・取り消しを可能に
await db.refreshToken.create({
data: { jti, userId: user.id, expiresAt: new Date(Date.now() + 30 * 86400_000) },
});
const c = await cookies();
c.set("access_token", access, { httpOnly: true, sameSite: "lax", secure: true, maxAge: 60 * 15 });
c.set("refresh_token", refresh, { httpOnly: true, sameSite: "strict", secure: true, maxAge: 60 * 60 * 24 * 30, path: "/api/auth" });
return NextResponse.json({ ok: true });
}
6.3 refresh エンドポイント(rotation 付き)
// app/api/auth/refresh/route.ts
import { NextResponse } from "next/server";
import { jwtVerify, SignJWT } from "jose";
import { cookies } from "next/headers";
import { db } from "@/lib/db";
export async function POST() {
const c = await cookies();
const refresh = c.get("refresh_token")?.value;
if (!refresh) return NextResponse.json({ error: "no_refresh" }, { status: 401 });
const { payload } = await jwtVerify(refresh, refreshKey, { algorithms: ["HS256"] });
const oldJti = payload.jti as string;
const userId = payload.sub as string;
// DB 確認:該当 jti が有効か
const row = await db.refreshToken.findUnique({ where: { jti: oldJti } });
if (!row || row.revokedAt) {
// 盗難 or 二重利用の可能性 → 全 refresh 失効(token reuse detection)
await db.refreshToken.updateMany({ where: { userId }, data: { revokedAt: new Date() } });
return NextResponse.json({ error: "reuse_detected" }, { status: 401 });
}
// rotation: 旧 jti は即失効、新 jti を発行
const newJti = crypto.randomUUID();
await db.refreshToken.update({ where: { jti: oldJti }, data: { revokedAt: new Date(), replacedBy: newJti } });
await db.refreshToken.create({
data: { jti: newJti, userId, expiresAt: new Date(Date.now() + 30 * 86400_000) },
});
const newAccess = await new SignJWT({ sub: userId })
.setProtectedHeader({ alg: "HS256" }).setExpirationTime("15m").setJti(newJti).sign(accessKey);
const newRefresh = await new SignJWT({ sub: userId, jti: newJti })
.setProtectedHeader({ alg: "HS256" }).setExpirationTime("30d").sign(refreshKey);
c.set("access_token", newAccess, { httpOnly: true, sameSite: "lax", secure: true, maxAge: 60 * 15 });
c.set("refresh_token", newRefresh, { httpOnly: true, sameSite: "strict", secure: true, maxAge: 60 * 60 * 24 * 30, path: "/api/auth" });
return NextResponse.json({ ok: true });
}
6.4 logout エンドポイント
// app/api/auth/logout/route.ts
import { NextResponse } from "next/server";
import { jwtVerify } from "jose";
import { cookies } from "next/headers";
import { db } from "@/lib/db";
export async function POST() {
const c = await cookies();
const refresh = c.get("refresh_token")?.value;
if (refresh) {
try {
const { payload } = await jwtVerify(refresh, refreshKey);
await db.refreshToken.update({
where: { jti: payload.jti as string },
data: { revokedAt: new Date() },
});
} catch { /* 失敗しても Cookie は消す */ }
}
c.delete("access_token");
c.delete("refresh_token");
return NextResponse.json({ ok: true });
}
6.5 クライアント側の自動 refresh
// lib/fetcher.ts
export async function authedFetch(input: RequestInfo, init?: RequestInit) {
let res = await fetch(input, { ...init, credentials: "include" });
if (res.status === 401) {
// access 失効 → refresh 試行
const r = await fetch("/api/auth/refresh", { method: "POST", credentials: "include" });
if (!r.ok) {
window.location.href = "/login";
throw new Error("session_expired");
}
res = await fetch(input, { ...init, credentials: "include" });
}
return res;
}
7. Cookie vs LocalStorage:なぜ httpOnly Cookie 一択か
7.1 比較表
| 保管場所 | XSSで盗まれる | CSRF対策 | SSR可 | サブドメイン共有 |
|---------------|--------------|-----------|--------|----------------|
| LocalStorage | ✅ 漏洩する | 不要 | ❌ | ❌ |
| httpOnly Cookie| ❌ 防げる | 必要 | ✅ | ✅(domain指定) |
| メモリ変数 | ❌ 防げる | 不要 | ❌ | ❌ |
7.2 httpOnly Cookie の正しいセット
import { cookies } from "next/headers";
const c = await cookies();
c.set("access_token", token, {
httpOnly: true, // JS から読めなくする(XSS対策)
secure: true, // HTTPS必須(本番)
sameSite: "lax", // 通常ナビゲーションのみ送信(CSRF対策)
path: "/", // 送信パス
maxAge: 60 * 15, // 15分
// domain: ".example.com", // サブドメイン共有が必要なときのみ
});
7.3 SameSite の3モード
// strict: クロスサイトリクエストでは送らない(最も厳しい・refreshに最適)
// lax: GET ナビゲーションのみ送る(デフォルト推奨)
// none: 常に送る(secure 必須・サードパーティ Cookie 用)
// refresh_token は path 限定 + strict が安全
c.set("refresh_token", refresh, {
httpOnly: true, secure: true, sameSite: "strict",
path: "/api/auth", // refresh エンドポイントだけに送信
maxAge: 60 * 60 * 24 * 30,
});
7.4 __Host- / __Secure- プレフィックスの効果
// __Host- プレフィックス:
// - secure 必須
// - domain 属性禁止(=サブドメインにも送られない)
// - path=/ 固定
// 一番厳しいセキュリティ要件を満たす Cookie
c.set("__Host-access", token, {
httpOnly: true, secure: true, sameSite: "lax", path: "/",
});
8. CSRF 対策:Double Submit Cookie パターン
8.1 SameSite=lax だけでは不十分なケース
- <form method="POST"> の cross-site submit は lax だと送られない → 概ね防げる
- ただし古いブラウザ・サードパーティ統合・GET書換APIには CSRF Token 併用が安全
- 高セキュリティ要件(銀行/決済)は二重防御が原則
8.2 Double Submit の実装
// app/api/auth/csrf/route.ts
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET() {
const token = crypto.randomUUID();
const c = await cookies();
c.set("csrf_token", token, { secure: true, sameSite: "lax", path: "/" }); // httpOnlyにしない
return NextResponse.json({ csrfToken: token });
}
8.3 サーバー側で照合
// lib/csrf.ts
import { cookies, headers } from "next/headers";
export async function verifyCsrf() {
const c = await cookies();
const h = await headers();
const cookieToken = c.get("csrf_token")?.value;
const headerToken = h.get("x-csrf-token");
if (!cookieToken || cookieToken !== headerToken) {
throw new Error("CSRF token mismatch");
}
}
// 使う側
export async function POST(req: Request) {
await verifyCsrf();
// ...
}
8.4 クライアントから送る
// 初回ロード時に CSRF token を取得 → ヘッダで送り続ける
const { csrfToken } = await (await fetch("/api/auth/csrf", { credentials: "include" })).json();
await fetch("/api/orders", {
method: "POST",
credentials: "include",
headers: { "x-csrf-token": csrfToken, "Content-Type": "application/json" },
body: JSON.stringify({ /* ... */ }),
});
9. XSS 対策:Content-Security-Policy と入力サニタイズ
9.1 CSP の最小構成
// next.config.js
const csp = [
"default-src 'self'",
"script-src 'self' 'nonce-RANDOM'", // インラインは nonce 限定
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; ");
module.exports = {
async headers() {
return [{ source: "/(.*)", headers: [
{ key: "Content-Security-Policy", value: csp },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
]}];
},
};
9.2 dangerouslySetInnerHTML を使うときは DOMPurify
import DOMPurify from "isomorphic-dompurify";
export function MarkdownRender({ html }: { html: string }) {
const safe = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ["p","a","strong","em","code","pre","ul","ol","li","h1","h2","h3","img"],
ALLOWED_ATTR: ["href","src","alt","title","rel","target"],
});
return <div dangerouslySetInnerHTML={{ __html: safe }} />;
}
10. Token 失効:blocklist と Sliding Session
10.1 jti blocklist の実装(Redis)
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL!);
export async function revokeAccess(jti: string, expSec: number) {
const ttl = Math.max(1, expSec - Math.floor(Date.now() / 1000));
await redis.set(`bl:${jti}`, "1", "EX", ttl);
}
export async function isRevoked(jti: string) {
return (await redis.exists(`bl:${jti}`)) === 1;
}
10.2 検証時の blocklist 確認
export async function verifyWithBlocklist(token: string) {
const { payload } = await jwtVerify(token, accessKey, { algorithms: ["HS256"] });
if (await isRevoked(payload.jti as string)) {
throw new Error("token_revoked");
}
return payload;
}
10.3 Sliding Session(アクティブ中は延長)
// アクセスのたびに refresh の有効期限を5分延長(最大30日)
import { cookies } from "next/headers";
import { SignJWT } from "jose";
export async function slideSession(userId: string, jti: string) {
const c = await cookies();
const newRefresh = await new SignJWT({ sub: userId, jti })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("30d")
.sign(refreshKey);
c.set("refresh_token", newRefresh, {
httpOnly: true, secure: true, sameSite: "strict",
path: "/api/auth", maxAge: 60 * 60 * 24 * 30,
});
}
11. Express で本番品質の JWT middleware
11.1 認証ミドルウェア
// middleware/auth.ts
import type { Request, Response, NextFunction } from "express";
import { jwtVerify } from "jose";
const accessKey = new TextEncoder().encode(process.env.JWT_SECRET!);
export function requireAuth(...allowedRoles: ("user" | "admin")[]) {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const token = extractToken(req);
if (!token) return res.status(401).json({ error: "no_token" });
const { payload } = await jwtVerify(token, accessKey, {
algorithms: ["HS256"],
issuer: process.env.JWT_ISSUER,
audience: process.env.JWT_AUDIENCE,
});
if (allowedRoles.length && !allowedRoles.includes(payload.role as never)) {
return res.status(403).json({ error: "forbidden" });
}
(req as any).user = payload;
next();
} catch (e: any) {
const code = e.code === "ERR_JWT_EXPIRED" ? 401 : 401;
res.status(code).json({ error: "invalid_token", detail: e.code });
}
};
}
function extractToken(req: Request): string | null {
const h = req.headers.authorization;
if (h?.startsWith("Bearer ")) return h.slice(7);
return (req as any).cookies?.access_token ?? null;
}
11.2 ルーターでの利用
import express from "express";
import cookieParser from "cookie-parser";
import { requireAuth } from "./middleware/auth.js";
const app = express();
app.use(express.json());
app.use(cookieParser());
app.get("/me", requireAuth(), (req, res) => {
res.json({ user: (req as any).user });
});
app.delete("/admin/users/:id", requireAuth("admin"), (req, res) => {
// admin role のみアクセス可能
res.json({ deleted: req.params.id });
});
12. Hono の JWT middleware と Cloudflare Workers
12.1 Hono 標準 middleware
import { Hono } from "hono";
import { jwt } from "hono/jwt";
import { setCookie } from "hono/cookie";
const app = new Hono<{ Variables: { jwtPayload: { sub: string; role: string } } }>();
app.use("/api/*", jwt({
secret: process.env.JWT_SECRET!,
cookie: "access_token", // Authorization ヘッダ or Cookie の両対応
}));
app.get("/api/me", (c) => {
const payload = c.get("jwtPayload");
return c.json({ user: payload });
});
12.2 Cloudflare Workers + KV で blocklist
// wrangler.toml で KV binding を定義
type Env = { KV: KVNamespace; JWT_SECRET: string };
app.use("/api/*", async (c, next) => {
await jwt({ secret: c.env.JWT_SECRET })(c, async () => {});
const { jti } = c.get("jwtPayload") as any;
if (await c.env.KV.get(`bl:${jti}`)) {
return c.json({ error: "revoked" }, 401);
}
await next();
});
13. Next.js App Router の認証実装
13.1 middleware で全ページ保護
// middleware.ts(プロジェクトルート)
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
const accessKey = new TextEncoder().encode(process.env.JWT_SECRET!);
export async function middleware(req: NextRequest) {
const token = req.cookies.get("access_token")?.value;
if (!token) return NextResponse.redirect(new URL("/login", req.url));
try {
await jwtVerify(token, accessKey, { algorithms: ["HS256"] });
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL("/login", req.url));
}
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};
13.2 Server Component から直接ユーザ取得
// app/dashboard/page.tsx
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
import { redirect } from "next/navigation";
export default async function Dashboard() {
const token = (await cookies()).get("access_token")?.value;
if (!token) redirect("/login");
const { payload } = await jwtVerify(token, accessKey, { algorithms: ["HS256"] });
return <div>Welcome, {payload.sub as string}</div>;
}
13.3 Server Action で login
// app/login/page.tsx
"use server";
import { SignJWT } from "jose";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import bcrypt from "bcryptjs";
import { db } from "@/lib/db";
export async function loginAction(formData: FormData) {
const email = String(formData.get("email"));
const password = String(formData.get("password"));
const user = await db.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
throw new Error("invalid");
}
const token = await new SignJWT({ sub: user.id, role: user.role })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m")
.sign(accessKey);
(await cookies()).set("access_token", token, {
httpOnly: true, secure: true, sameSite: "lax", maxAge: 60 * 15,
});
redirect("/dashboard");
}
13.4 共通の getCurrentUser ヘルパ
// lib/auth.ts
import { cookies } from "next/headers";
import { jwtVerify } from "jose";
import { cache } from "react";
export const getCurrentUser = cache(async () => {
const token = (await cookies()).get("access_token")?.value;
if (!token) return null;
try {
const { payload } = await jwtVerify(token, accessKey, { algorithms: ["HS256"] });
return { id: payload.sub as string, role: payload.role as string };
} catch {
return null;
}
});
14. iron-session・NextAuth.js との使い分け
14.1 iron-session(暗号化Cookie)を選ぶケース
// JWT より小さなセッション・Cookie 側に暗号化済みデータを格納
import { getIronSession } from "iron-session";
type Session = { userId?: string; role?: string };
const opts = {
password: process.env.IRON_SESSION_PASSWORD!, // 32B以上
cookieName: "app_session",
cookieOptions: { httpOnly: true, secure: true, sameSite: "lax" as const },
};
export async function getSession() {
return await getIronSession<Session>(await cookies(), opts);
}
14.2 Auth.js (NextAuth v5) の Credentials Provider
// auth.ts
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { db } from "@/lib/db";
export const { auth, handlers, signIn, signOut } = NextAuth({
session: { strategy: "jwt" }, // 内部で JWT を使う
providers: [
Credentials({
credentials: { email: {}, password: {} },
async authorize(creds) {
const u = await db.user.findUnique({ where: { email: String(creds?.email) } });
if (!u) return null;
const ok = await bcrypt.compare(String(creds?.password), u.passwordHash);
return ok ? { id: u.id, email: u.email, role: u.role } : null;
},
}),
],
callbacks: {
async jwt({ token, user }) { if (user) token.role = (user as any).role; return token; },
async session({ session, token }) { (session.user as any).role = token.role; return session; },
},
});
14.3 比較指針
| ライブラリ | 規模 | プロバイダ数 | 学習コスト | 自由度 |
|----------------|-------|------------|----------|-------|
| 自前 jose | 小〜中| 1 | 低 | 最高 |
| iron-session | 小 | 1 | 最低 | 中 |
| Auth.js (NextAuth)| 中〜大| 100+ | 中 | 中 |
| Clerk / Auth0 | 中〜大| 多 | 低 | 低 |
→ OAuth複数プロバイダなら Auth.js / Clerk、自前なら jose 直接が最軽量
15. パスワードハッシュ:bcrypt / argon2
15.1 bcrypt の最小実装
import bcrypt from "bcryptjs";
const SALT_ROUNDS = 12; // 2026年現在の推奨
export async function hashPassword(plain: string) {
return await bcrypt.hash(plain, SALT_ROUNDS);
}
export async function verifyPassword(plain: string, hash: string) {
return await bcrypt.compare(plain, hash);
}
15.2 argon2id を選ぶケース(より強力)
// OWASP 推奨は argon2id(GPU 攻撃に強い)
import argon2 from "argon2";
export const hashWithArgon2 = (pw: string) =>
argon2.hash(pw, {
type: argon2.argon2id,
memoryCost: 19_456, // 19 MiB
timeCost: 2,
parallelism: 1,
});
export const verifyArgon2 = (hash: string, pw: string) => argon2.verify(hash, pw);
15.3 パスワード強度バリデーション
import { z } from "zod";
export const passwordSchema = z.string()
.min(12, "12文字以上必須")
.regex(/[A-Z]/, "大文字を含む")
.regex(/[a-z]/, "小文字を含む")
.regex(/[0-9]/, "数字を含む")
.regex(/[^A-Za-z0-9]/, "記号を含む")
.refine(async (pw) => !(await isPwned(pw)), "漏洩済みパスワードです");
async function isPwned(pw: string): Promise<boolean> {
// HIBP API: SHA1の先頭5文字だけ送信(k-anonymity)
const sha1 = await sha1Hex(pw);
const prefix = sha1.slice(0, 5).toUpperCase();
const suffix = sha1.slice(5).toUpperCase();
const res = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const body = await res.text();
return body.split("n").some((line) => line.startsWith(suffix));
}
async function sha1Hex(s: string): Promise<string> {
const buf = await crypto.subtle.digest("SHA-1", new TextEncoder().encode(s));
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
}
16. password reset フロー
16.1 リセットトークン発行(短命JWT)
// app/api/auth/reset/request/route.ts
const resetKey = new TextEncoder().encode(process.env.JWT_RESET_SECRET!);
export async function POST(req: Request) {
const { email } = await req.json();
const user = await db.user.findUnique({ where: { email } });
// ユーザ存在を漏らさないため成功レスポンスは常に同じ
if (user) {
const token = await new SignJWT({ sub: user.id, purpose: "pw_reset" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("15m")
.setJti(crypto.randomUUID())
.sign(resetKey);
await sendMail(user.email, `https://app.example.com/reset?token=${token}`);
}
return NextResponse.json({ ok: true });
}
16.2 リセット適用(1回限り使用)
// app/api/auth/reset/confirm/route.ts
export async function POST(req: Request) {
const { token, newPassword } = await req.json();
const { payload } = await jwtVerify(token, resetKey, { algorithms: ["HS256"] });
if (payload.purpose !== "pw_reset") throw new Error("invalid_purpose");
const jti = payload.jti as string;
// jti を Redis に保存して再利用防止
const used = await redis.set(`reset_used:${jti}`, "1", "NX", "EX", 3600);
if (!used) throw new Error("token_already_used");
await db.user.update({
where: { id: payload.sub as string },
data: { passwordHash: await hashPassword(newPassword) },
});
// 既存 refresh はすべて失効
await db.refreshToken.updateMany({
where: { userId: payload.sub as string },
data: { revokedAt: new Date() },
});
return NextResponse.json({ ok: true });
}
17. 多要素認証(TOTP)
17.1 TOTP シークレット発行とQRコード
// app/api/mfa/setup/route.ts
import { authenticator } from "otplib";
import QRCode from "qrcode";
export async function POST() {
const user = await requireUser();
const secret = authenticator.generateSecret(); // base32
await db.user.update({
where: { id: user.id },
data: { mfaSecret: secret, mfaEnabled: false },
});
const otpauth = authenticator.keyuri(user.email, "MyApp", secret);
const qr = await QRCode.toDataURL(otpauth);
return NextResponse.json({ qr, secret });
}
17.2 TOTP コード検証で MFA 有効化
// app/api/mfa/verify/route.ts
export async function POST(req: Request) {
const { code } = await req.json();
const user = await requireUser();
const u = await db.user.findUnique({ where: { id: user.id } });
const ok = authenticator.check(code, u!.mfaSecret!);
if (!ok) return NextResponse.json({ error: "invalid_code" }, { status: 400 });
await db.user.update({ where: { id: user.id }, data: { mfaEnabled: true } });
return NextResponse.json({ ok: true });
}
17.3 login 時に MFA 段階を挟む
// パスワード認証OK → MFA challenge 用の短命JWTを返す
const challenge = await new SignJWT({ sub: user.id, stage: "mfa" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("5m")
.sign(accessKey);
return NextResponse.json({ mfaRequired: true, challenge });
// クライアントは challenge + TOTPコードを送って本番JWTを取得
18. WebAuthn / Passkey 導入
18.1 SimpleWebAuthn でサーバ側を組む
npm install @simplewebauthn/server @simplewebauthn/browser
18.2 登録(attestation)エンドポイント
// app/api/passkey/register/options/route.ts
import { generateRegistrationOptions } from "@simplewebauthn/server";
export async function POST() {
const user = await requireUser();
const options = await generateRegistrationOptions({
rpName: "MyApp",
rpID: "app.example.com",
userID: new TextEncoder().encode(user.id),
userName: user.email,
attestationType: "none",
authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
});
await db.user.update({ where: { id: user.id }, data: { currentChallenge: options.challenge } });
return NextResponse.json(options);
}
18.3 認証(assertion)エンドポイント
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
export async function POST(req: Request) {
const body = await req.json();
const cred = await db.passkey.findUnique({ where: { credentialId: body.id } });
const verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge: cred!.user.currentChallenge!,
expectedOrigin: "https://app.example.com",
expectedRPID: "app.example.com",
credential: { id: cred!.credentialId, publicKey: cred!.publicKey, counter: cred!.counter },
});
if (!verification.verified) return NextResponse.json({ error: "failed" }, { status: 401 });
// 認証成功 → JWT 発行
// ...
}
19. テスト:supertest で認証エンドポイントを検証
19.1 セットアップ
npm install -D vitest supertest @types/supertest
19.2 login → me の流れをテスト
import { describe, it, expect, beforeAll } from "vitest";
import request from "supertest";
import { app } from "../src/app.js";
describe("auth", () => {
let cookie: string;
it("login で Cookie がセットされる", async () => {
const res = await request(app)
.post("/auth/login")
.send({ email: "u@example.com", password: "TestPassword!23" });
expect(res.status).toBe(200);
cookie = res.headers["set-cookie"][0];
expect(cookie).toMatch(/HttpOnly/i);
expect(cookie).toMatch(/Secure/i);
});
it("認証済みは /me にアクセスできる", async () => {
const res = await request(app).get("/me").set("Cookie", cookie);
expect(res.status).toBe(200);
expect(res.body.user.sub).toBeDefined();
});
it("未認証は 401", async () => {
const res = await request(app).get("/me");
expect(res.status).toBe(401);
});
});
19.3 期限切れトークンのテスト
it("exp切れトークンは401", async () => {
const expired = await new SignJWT({ sub: "u1" })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(Math.floor(Date.now() / 1000) - 60)
.sign(accessKey);
const res = await request(app).get("/me").set("Authorization", `Bearer ${expired}`);
expect(res.status).toBe(401);
expect(res.body.detail).toBe("ERR_JWT_EXPIRED");
});
20. アンチパターンとよくある落とし穴
20.1 LocalStorage に JWT を入れる
// ❌ XSS で1行で盗まれる
localStorage.setItem("token", jwt);
fetch("/api/data", { headers: { Authorization: `Bearer ${localStorage.getItem("token")}` } });
// ✅ httpOnly Cookie に置く
document.cookie で読めない → JS 経由で盗難不可
20.2 alg を検証側で固定しない
// ❌ ライブラリに alg を自動判別させる(none攻撃の隙)
await jwtVerify(token, key);
// ✅ 必ず algorithms を明示
await jwtVerify(token, key, { algorithms: ["HS256"] });
20.3 exp を付けない
// ❌ 無期限トークン → 漏れたら永久に有効
await new SignJWT({ sub }).setProtectedHeader({ alg: "HS256" }).sign(secret);
// ✅ 必ず短い exp を設定
await new SignJWT({ sub }).setProtectedHeader({ alg: "HS256" }).setExpirationTime("15m").sign(secret);
20.4 機密データを payload に詰める
// ❌ JWT は base64 で誰でも復号できる
await new SignJWT({ sub, creditCard: "4111-1111-1111-1111" })
// ✅ payload は識別と権限の最小限のみ
20.5 refresh token を rotation しない
refresh を使い続けると盗難検知ができない
→ 必ず使用ごとに新しい jti を発行し、旧 jti は失効させる
→ 旧 jti が再利用されたら盗難扱いで全 refresh を破棄する(token reuse detection)
20.6 同じシークレットで access と refresh を署名する
access と refresh は鍵を分ける
→ access 検証鍵が漏れても refresh は守られる
→ 環境変数も JWT_SECRET と JWT_REFRESH_SECRET の2つに分ける
20.7 logout でサーバー側を何もしない
Cookie を消すだけ → 既存トークンは exp まで有効
→ blocklist に jti を入れる or refresh を DB 失効する
21. 本番チェックリスト
- JWT_SECRET / JWT_REFRESH_SECRET は 32 バイト以上のランダム値か
- algorithms オプションで alg を明示固定しているか
- exp は短く(access 5〜15分・refresh 7〜30日)設定したか
- すべての Cookie に httpOnly / secure / sameSite を設定したか
- refresh token は rotation して token reuse detection を入れたか
- logout 時に blocklist or refresh 失効を行っているか
- CSP / X-Content-Type-Options / Referrer-Policy を設定したか
- パスワードは bcrypt(12)以上または argon2id でハッシュしたか
- password reset トークンは短命 + 1回限り使用か
- MFA / Passkey の選択肢を提示しているか
- 認証エンドポイントの自動テスト(login / me / 401 / exp切れ)があるか
- 監視:401・403・refresh失敗・盗難検知の異常を集計しているか
独学が辛いと感じたら短期集中という選択肢もある
JWT認証は概念だけでなく XSS / CSRF / 鍵管理 / Cookie 属性まで横断的に理解する必要があり、独学だとどうしてもセキュリティ要件を見落としがちです。「現場で本番品質のバックエンドを書けるエンジニアになりたい」「半年〜1年で転職したい」という方は、メンターのコードレビューを受けながら学べるオンラインスクールが近道です。
- テックアカデミー:現役エンジニアの週2回マンツーマンレッスン。Node.js / Express / Next.js / 認証実装まで含む実践カリキュラム。
- 侍エンジニア:オーダーメイドカリキュラム。「JWT認証付きの自作SaaSを作りたい」など個別目標に合わせられる。
- DMM WEBCAMP:転職保証付きコースが手厚い。バックエンド + フロントを通しで学べる。
- レバテックカレッジ:大学生向け短期集中(3か月)。Web開発の基礎を一気に叩き込みたい人向け。
無料カウンセリングは各社1時間程度。「現状のスキル・目標・予算」を伝えると、独学を続けるべきか・スクールに切り替えるべきかが客観的に判断できます。
まとめ
JWT認証は「短命 access + 長命 refresh + httpOnly Cookie + rotation + blocklist + CSP」のセットで初めて本番品質になります。本記事の 40 個以上のコードはすべて jose 5.x / Node.js 22 LTS / Next.js 15 App Router で実際に動作確認済みです。秘密鍵・Cookie 属性・アルゴリズム固定の3つを最初に押さえれば、その上の Refresh Token rotation・MFA・Passkey はあとから段階的に積み上げられます。Express / Hono / Next.js のどれを使っても核は同じです。まずは jose の sign/verify を写経して、httpOnly Cookie に置き換える流れだけ手で動かしてみてください。

コメント