JWT認証完全実装ガイド〜jose・refresh token・XSS/CSRF対策・Next.js Server Actions【2026年版】〜

「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. この記事で身につくこと
  2. 1. JWT とは何か:構造と仕組みを理解する
    1. 1.1 JWT は3つのドットで区切られた文字列
    2. 1.2 header(ヘッダ)に入るもの
    3. 1.3 payload(クレーム)の標準フィールド
    4. 1.4 signature(署名)の計算
    5. 1.5 JWT ではない「JWS / JWE」との関係
  3. 2. 署名アルゴリズムの選び方
    1. 2.1 HS256 / RS256 / ES256 / EdDSA の比較表
    2. 2.2 HS256(共通鍵)を選ぶケース
    3. 2.3 RS256(公開鍵)を選ぶケース
    4. 2.4 ES256(楕円曲線)を選ぶケース
    5. 2.5 alg: none は絶対に許可しない
  4. 3. jose ライブラリのセットアップと基本操作
    1. 3.1 install と最小例
    2. 3.2 HS256 で sign / verify
    3. 3.3 RS256 で sign / verify(鍵ペア)
    4. 3.4 EdDSA(Ed25519)で sign / verify
    5. 3.5 JWKS エンドポイントを返す
    6. 3.6 jose で JWKS を fetch して検証
  5. 4. payload に何を入れるか:設計の原則
    1. 4.1 入れて良い情報・入れてはいけない情報
    2. 4.2 サイズはなるべく小さく
    3. 4.3 exp / nbf / iat の使い分け
    4. 4.4 jti(JWT ID)で個別失効を可能にする
  6. 5. 秘密鍵管理と鍵ローテーション
    1. 5.1 .env で管理する基本
    2. 5.2 起動時のバリデーション
    3. 5.3 鍵ローテーション戦略
    4. 5.4 ローテーション運用フロー
  7. 6. Access Token + Refresh Token 完全実装
    1. 6.1 なぜ2種類のトークンが必要か
    2. 6.2 login エンドポイントで両方発行
    3. 6.3 refresh エンドポイント(rotation 付き)
    4. 6.4 logout エンドポイント
    5. 6.5 クライアント側の自動 refresh
  8. 7. Cookie vs LocalStorage:なぜ httpOnly Cookie 一択か
    1. 7.1 比較表
    2. 7.2 httpOnly Cookie の正しいセット
    3. 7.3 SameSite の3モード
    4. 7.4 __Host- / __Secure- プレフィックスの効果
  9. 8. CSRF 対策:Double Submit Cookie パターン
    1. 8.1 SameSite=lax だけでは不十分なケース
    2. 8.2 Double Submit の実装
    3. 8.3 サーバー側で照合
    4. 8.4 クライアントから送る
  10. 9. XSS 対策:Content-Security-Policy と入力サニタイズ
    1. 9.1 CSP の最小構成
    2. 9.2 dangerouslySetInnerHTML を使うときは DOMPurify
  11. 10. Token 失効:blocklist と Sliding Session
    1. 10.1 jti blocklist の実装(Redis)
    2. 10.2 検証時の blocklist 確認
    3. 10.3 Sliding Session(アクティブ中は延長)
  12. 11. Express で本番品質の JWT middleware
    1. 11.1 認証ミドルウェア
    2. 11.2 ルーターでの利用
  13. 12. Hono の JWT middleware と Cloudflare Workers
    1. 12.1 Hono 標準 middleware
    2. 12.2 Cloudflare Workers + KV で blocklist
  14. 13. Next.js App Router の認証実装
    1. 13.1 middleware で全ページ保護
    2. 13.2 Server Component から直接ユーザ取得
    3. 13.3 Server Action で login
    4. 13.4 共通の getCurrentUser ヘルパ
  15. 14. iron-session・NextAuth.js との使い分け
    1. 14.1 iron-session(暗号化Cookie)を選ぶケース
    2. 14.2 Auth.js (NextAuth v5) の Credentials Provider
    3. 14.3 比較指針
  16. 15. パスワードハッシュ:bcrypt / argon2
    1. 15.1 bcrypt の最小実装
    2. 15.2 argon2id を選ぶケース(より強力)
    3. 15.3 パスワード強度バリデーション
  17. 16. password reset フロー
    1. 16.1 リセットトークン発行(短命JWT)
    2. 16.2 リセット適用(1回限り使用)
  18. 17. 多要素認証(TOTP)
    1. 17.1 TOTP シークレット発行とQRコード
    2. 17.2 TOTP コード検証で MFA 有効化
    3. 17.3 login 時に MFA 段階を挟む
  19. 18. WebAuthn / Passkey 導入
    1. 18.1 SimpleWebAuthn でサーバ側を組む
    2. 18.2 登録(attestation)エンドポイント
    3. 18.3 認証(assertion)エンドポイント
  20. 19. テスト:supertest で認証エンドポイントを検証
    1. 19.1 セットアップ
    2. 19.2 login → me の流れをテスト
    3. 19.3 期限切れトークンのテスト
  21. 20. アンチパターンとよくある落とし穴
    1. 20.1 LocalStorage に JWT を入れる
    2. 20.2 alg を検証側で固定しない
    3. 20.3 exp を付けない
    4. 20.4 機密データを payload に詰める
    5. 20.5 refresh token を rotation しない
    6. 20.6 同じシークレットで access と refresh を署名する
    7. 20.7 logout でサーバー側を何もしない
  22. 21. 本番チェックリスト
  23. 独学が辛いと感じたら短期集中という選択肢もある
  24. まとめ

この記事で身につくこと

  • 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 に置き換える流れだけ手で動かしてみてください。

コメント

タイトルとURLをコピーしました