コンテンツへスキップ
·12分で読めます

2026年のモダン認証:JWT、セッション、OAuth、パスキー

認証の全体像:セッション対JWTの使い分け、OAuth 2.0/OIDCフロー、リフレッシュトークンローテーション、パスキー(WebAuthn)、実際に使っているNext.js認証パターン。

シェア:X / TwitterLinkedIn

認証はWeb開発において「動けばいい」では決して済まない領域だ。日付ピッカーのバグは面倒なだけだが、認証システムのバグはデータ漏洩につながる。

私は認証をゼロから実装し、プロバイダー間の移行を経験し、トークン盗難のインシデントをデバッグし、「セキュリティは後で直す」という判断の後始末にも対処してきた。この記事は、私が始めた頃に欲しかった包括的なガイドだ。理論だけでなく、実際のトレードオフ、現実の脆弱性、そして本番環境のプレッシャーに耐えるパターンを解説する。

セッション、JWT、OAuth 2.0、パスキー、MFA、認可まで全体像をカバーする。読み終わる頃には、各メカニズムが_どう_動くかだけでなく、_いつ_使うべきか、そして_なぜ_他の選択肢が存在するのかが理解できるだろう。

セッション vs JWT:本当のトレードオフ#

最初に直面する判断であり、インターネット上には悪いアドバイスが溢れている。実際に重要なことを整理しよう。

セッションベース認証#

セッションは元祖のアプローチだ。サーバーがセッションレコードを作成し、どこかに保存(データベース、Redis、メモリ)し、クライアントにはCookieで不透明なセッションIDを渡す。

typescript
// シンプルなセッション作成
import { randomBytes } from "crypto";
import { cookies } from "next/headers";
 
interface Session {
  userId: string;
  createdAt: Date;
  expiresAt: Date;
  ipAddress: string;
  userAgent: string;
}
 
async function createSession(userId: string, request: Request): Promise<string> {
  const sessionId = randomBytes(32).toString("hex");
 
  const session: Session = {
    userId,
    createdAt: new Date(),
    expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24時間
    ipAddress: request.headers.get("x-forwarded-for") ?? "unknown",
    userAgent: request.headers.get("user-agent") ?? "unknown",
  };
 
  // データベースまたはRedisに保存
  await redis.set(`session:${sessionId}`, JSON.stringify(session), "EX", 86400);
 
  const cookieStore = await cookies();
  cookieStore.set("session_id", sessionId, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 86400,
    path: "/",
  });
 
  return sessionId;
}

メリットは確かにある:

  • 即座に無効化が可能。 セッションレコードを削除すれば、ユーザーは即ログアウト。有効期限切れを待つ必要がない。不審な活動を検知した時に重要だ。
  • セッションの可視性。 ユーザーにアクティブなセッションを表示でき(「Chrome、Windows 11、イスタンブールでログイン中」)、個別に取り消すことも可能。
  • Cookieサイズが小さい。 セッションIDは通常64文字程度。Cookieは肥大化しない。
  • サーバーサイド制御。 セッションデータを更新でき(ユーザーを管理者に昇格、権限変更)、次のリクエストで即反映される。

デメリットもまた確かだ:

  • リクエストごとにデータベースアクセスが発生。 すべての認証済みリクエストでセッション検索が必要。Redisならサブミリ秒だが、依存関係が増える。
  • 水平スケーリングには共有ストレージが必要。 複数サーバーがある場合、すべて同じセッションストアにアクセスする必要がある。スティッキーセッションは脆弱な回避策だ。
  • CSRFが懸念事項。 Cookieは自動送信されるため、CSRF保護が必要。SameSite Cookieでほぼ解決するが、その理由を理解する必要がある。

JWTベース認証#

JWTはモデルを逆転させる。サーバーにセッション状態を保存する代わりに、署名されたトークンにエンコードしてクライアントに持たせる。

typescript
import { SignJWT, jwtVerify } from "jose";
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
 
async function createAccessToken(userId: string, role: string): Promise<string> {
  return new SignJWT({ sub: userId, role })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .setIssuer("https://akousa.net")
    .setAudience("https://akousa.net")
    .sign(secret);
}
 
async function verifyAccessToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret, {
      issuer: "https://akousa.net",
      audience: "https://akousa.net",
    });
    return payload;
  } catch {
    return null;
  }
}

メリット:

  • サーバーサイドストレージ不要。 トークンは自己完結型。署名を検証してクレームを読むだけ。データベースアクセスは不要。
  • サービス間で機能する。 マイクロサービスアーキテクチャでは、公開鍵を持つ任意のサービスがトークンを検証できる。共有セッションストアは不要。
  • ステートレスなスケーリング。 セッションアフィニティを気にせずサーバーを追加できる。

デメリット — これらは多くの人が軽視するポイントだ:

  • JWTは取り消せない。 一度発行されたら、有効期限まで有効。ユーザーのアカウントが侵害されても、強制ログアウトできない。ブロックリストを構築できるが、それはサーバーサイド状態を再導入し、主なメリットを失うことになる。
  • トークンサイズ。 数個のクレームを持つJWTは通常800バイト以上。ロール、権限、メタデータを追加すれば、リクエストごとにキロバイト単位の転送になる。
  • ペイロードは読み取り可能。 ペイロードはBase64エンコードされており、暗号化されていない。誰でもデコードできる。JWTに機密データは決して入れてはいけない。
  • クロックスキューの問題。 サーバー間で時刻がずれていると(実際に起こる)、有効期限のチェックが信頼できなくなる。

使い分け#

私の経験則:

セッションを使うべき場合: モノリシックなアプリケーション、即座の無効化が必要、アカウントセキュリティが重要な一般消費者向けプロダクトを構築している場合、または認証要件が頻繁に変わる可能性がある場合。

JWTを使うべき場合: サービスが独立してIDを検証する必要があるマイクロサービスアーキテクチャ、API間通信を構築している場合、またはサードパーティ認証システムを実装する場合。

実際には: ほとんどのアプリケーションはセッションを使うべきだ。「JWTの方がスケーラブル」という議論は、セッションストレージでは解決できないスケーリング問題が実際にある場合にのみ当てはまる — Redisは毎秒数百万のセッション検索を処理できる。JWTの方がモダンに聞こえるからという理由で選択し、結局ブロックリストとリフレッシュトークンシステムを構築して、セッションよりも複雑になったプロジェクトを何度も見てきた。

JWT詳細解説#

セッションベース認証を選んだとしても、OAuth、OIDC、サードパーティ統合を通じてJWTに遭遇する。内部構造の理解は必須だ。

JWTの構造#

JWTはドットで区切られた3つの部分で構成される:header.payload.signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcwOTMxMjAwMCwiZXhwIjoxNzA5MzEyOTAwfQ.
kQ8s7nR2xC...

ヘッダー — アルゴリズムとトークンタイプを宣言:

json
{
  "alg": "RS256",
  "typ": "JWT"
}

ペイロード — クレームを含む。標準クレームは短い名前を持つ:

json
{
  "sub": "user_123",       // Subject(これは誰についてか)
  "iss": "https://auth.example.com",  // Issuer(誰が作成したか)
  "aud": "https://api.example.com",   // Audience(誰が受け入れるべきか)
  "iat": 1709312000,       // Issued At(Unix タイムスタンプ)
  "exp": 1709312900,       // Expiration(Unixタイムスタンプ)
  "role": "admin"          // カスタムクレーム
}

署名 — トークンが改ざんされていないことを証明する。エンコードされたヘッダーとペイロードを秘密鍵で署名して作成される。

RS256 vs HS256:これは本当に重要#

HS256(HMAC-SHA256)— 対称鍵。同じシークレットで署名と検証を行う。シンプルだが、トークンを検証する必要があるすべてのサービスがシークレットを持つ必要がある。1つでも侵害されれば、攻撃者はトークンを偽造できる。

RS256(RSA-SHA256)— 非対称鍵。秘密鍵で署名し、公開鍵で検証する。認証サーバーだけが秘密鍵を必要とする。どのサービスも公開鍵で検証できる。検証サービスが侵害されても、攻撃者はトークンを読めるが偽造はできない。

typescript
import { SignJWT, jwtVerify, importPKCS8, importSPKI } from "jose";
 
// RS256 — 複数のサービスがトークンを検証する場合に使用
const privateKeyPem = process.env.JWT_PRIVATE_KEY!;
const publicKeyPem = process.env.JWT_PUBLIC_KEY!;
 
async function signWithRS256(payload: Record<string, unknown>) {
  const privateKey = await importPKCS8(privateKeyPem, "RS256");
 
  return new SignJWT(payload)
    .setProtectedHeader({ alg: "RS256", typ: "JWT" })
    .setIssuedAt()
    .setExpirationTime("15m")
    .sign(privateKey);
}
 
async function verifyWithRS256(token: string) {
  const publicKey = await importSPKI(publicKeyPem, "RS256");
 
  const { payload } = await jwtVerify(token, publicKey, {
    algorithms: ["RS256"], // 重要:常にアルゴリズムを制限する
  });
 
  return payload;
}

ルール:トークンがサービス境界を越える場合は常にRS256を使用する。 HS256は、同じサービスが署名と検証の両方を行う場合にのみ使用する。

alg: none 攻撃#

これはJWTの最も有名な脆弱性であり、恥ずかしいほどシンプルだ。かつて一部のJWTライブラリは以下のように動作していた:

  1. ヘッダーから alg フィールドを読み取る
  2. 指定されたアルゴリズムをそのまま使用する
  3. alg: "none" の場合、署名検証を完全にスキップする

攻撃者は有効なJWTを取得し、ペイロードを変更(例:"role": "admin" に設定)、alg"none" に設定し、署名を削除して送信できた。サーバーはそれを受け入れてしまう。

typescript
// 脆弱 — 絶対にこうしてはいけない
function verifyJwt(token: string) {
  const [headerB64, payloadB64, signature] = token.split(".");
  const header = JSON.parse(atob(headerB64));
 
  if (header.alg === "none") {
    // 「署名不要」— 壊滅的
    return JSON.parse(atob(payloadB64));
  }
 
  // ... 署名検証
}

修正はシンプルだ:期待するアルゴリズムを常に明示的に指定する。 トークンに検証方法を指示させてはいけない。

typescript
// 安全 — アルゴリズムはハードコードされ、トークンから読み取らない
const { payload } = await jwtVerify(token, key, {
  algorithms: ["RS256"], // RS256のみ受け入れ — ヘッダーは無視
});

jose のようなモダンなライブラリはデフォルトでこれを正しく処理するが、多層防御として algorithms オプションを明示的に渡すべきだ。

アルゴリズム混同攻撃#

上記に関連して:サーバーがRS256を受け入れるように設定されている場合、攻撃者は次のことが可能だ:

  1. サーバーの公開鍵を取得する(公開されているのだから)
  2. alg: "HS256" でトークンを作成する
  3. 公開鍵をHMACシークレットとして使用して署名する

サーバーが alg ヘッダーを読んでHS256検証に切り替えると、公開鍵(誰でも知っている)が共有シークレットになる。署名は有効になる。攻撃者はトークンを偽造できてしまう。

ここでも修正は同じだ:トークンヘッダーのアルゴリズムを信頼してはいけない。常にハードコードする。

リフレッシュトークンローテーション#

JWTを使うなら、リフレッシュトークン戦略が必要だ。長寿命のアクセストークンを送信するのは問題を招く — 盗まれた場合、攻撃者は有効期間中ずっとアクセスできる。

パターン:

  • アクセストークン:短寿命(15分)。APIリクエストに使用。
  • リフレッシュトークン:長寿命(30日)。新しいアクセストークン取得にのみ使用。
typescript
import { randomBytes } from "crypto";
 
interface RefreshTokenRecord {
  tokenHash: string;
  userId: string;
  familyId: string;  // 関連するトークンをグループ化
  used: boolean;
  expiresAt: Date;
  createdAt: Date;
}
 
async function issueTokenPair(userId: string) {
  const familyId = randomBytes(16).toString("hex");
 
  const accessToken = await createAccessToken(userId);
  const refreshToken = randomBytes(64).toString("hex");
  const refreshTokenHash = await hashToken(refreshToken);
 
  // リフレッシュトークンレコードを保存
  await db.refreshToken.create({
    data: {
      tokenHash: refreshTokenHash,
      userId,
      familyId,
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  return { accessToken, refreshToken };
}

使用ごとのローテーション#

クライアントがリフレッシュトークンを使って新しいアクセストークンを取得するたびに、新しいリフレッシュトークンを発行し、古いものを無効化する:

typescript
async function rotateTokens(incomingRefreshToken: string) {
  const tokenHash = await hashToken(incomingRefreshToken);
  const record = await db.refreshToken.findUnique({
    where: { tokenHash },
  });
 
  if (!record) {
    // トークンが存在しない — 盗難の可能性
    return null;
  }
 
  if (record.expiresAt < new Date()) {
    // トークンの有効期限切れ
    await db.refreshToken.delete({ where: { tokenHash } });
    return null;
  }
 
  if (record.used) {
    // このトークンは既に使用済み。
    // 正規ユーザーか攻撃者かのどちらかがリプレイしている。
    // いずれにせよ、ファミリー全体を無効化する。
    await db.refreshToken.deleteMany({
      where: { familyId: record.familyId },
    });
 
    console.error(
      `ユーザー ${record.userId}、ファミリー ${record.familyId} でリフレッシュトークンの再利用を検出。ファミリー内の全トークンを無効化。`
    );
 
    return null;
  }
 
  // 現在のトークンを使用済みにマーク(削除しない — 再利用検出に必要)
  await db.refreshToken.update({
    where: { tokenHash },
    data: { used: true },
  });
 
  // 同じファミリーIDで新しいペアを発行
  const newRefreshToken = randomBytes(64).toString("hex");
  const newRefreshTokenHash = await hashToken(newRefreshToken);
 
  await db.refreshToken.create({
    data: {
      tokenHash: newRefreshTokenHash,
      userId: record.userId,
      familyId: record.familyId,  // 同じファミリー
      used: false,
      expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      createdAt: new Date(),
    },
  });
 
  const newAccessToken = await createAccessToken(record.userId);
 
  return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

ファミリー無効化が重要な理由#

以下のシナリオを考えてみよう:

  1. ユーザーがログインし、リフレッシュトークンAを取得
  2. 攻撃者がリフレッシュトークンAを盗む
  3. 攻撃者がAを使って新しいペア(アクセストークン + リフレッシュトークンB)を取得
  4. ユーザーが(まだ持っている)Aを使ってリフレッシュを試みる

再利用検出がなければ、ユーザーはエラーを受け取るだけ。攻撃者はトークンBで継続する。ユーザーは再度ログインし、アカウントが侵害されたことに気づかない。

再利用検出とファミリー無効化があれば:ユーザーが既に使用済みのトークンAを使おうとすると、システムが再利用を検出し、ファミリー内のすべてのトークン(Bを含む)を無効化し、ユーザーと攻撃者の両方に再認証を強制する。ユーザーは「再度ログインしてください」というプロンプトを受け取り、何かがおかしいと気づくかもしれない。

これはAuth0、Okta、Auth.jsで使われているアプローチだ。完璧ではない — 攻撃者が正規ユーザーより先にトークンを使えば、正規ユーザーの方が再利用アラートをトリガーすることになる。しかし、ベアラートークンでできる最善の方法だ。

OAuth 2.0 & OIDC#

OAuth 2.0とOpenID Connectは「Googleでサインイン / GitHubでサインイン / Appleでサインイン」の背後にあるプロトコルだ。ライブラリを使う場合でも理解は必須だ。なぜなら問題が発生した時 — そして必ず発生する — プロトコルレベルで何が起きているかを知る必要があるからだ。

重要な区別#

OAuth 2.0認可 プロトコルだ。「このアプリケーションはこのユーザーのデータにアクセスできるか?」に答える。結果は特定の権限(スコープ)を付与するアクセストークンだ。

OpenID Connect(OIDC) はOAuth 2.0の上に構築された 認証 レイヤーだ。「このユーザーは誰か?」に答える。結果はユーザーのID情報を含むIDトークン(JWT)だ。

「Googleでサインイン」する時、OIDCを使っている。Googleがアプリにユーザーの身元を伝える(認証)。また、OAuthスコープをリクエストしてカレンダーやドライブへのアクセスを要求することもできる(認可)。

PKCE付き認可コードフロー#

これはWebアプリケーションで使うべきフローだ。PKCE(Proof Key for Code Exchange)は元々モバイルアプリ向けに設計されたが、現在はサーバーサイドアプリケーションを含むすべてのクライアントに推奨されている。

typescript
import { randomBytes, createHash } from "crypto";
 
// ステップ1:PKCE値を生成してユーザーをリダイレクト
function initiateOAuthFlow() {
  // コードベリファイア:ランダムな43〜128文字の文字列
  const codeVerifier = randomBytes(32)
    .toString("base64url")
    .slice(0, 43);
 
  // コードチャレンジ:ベリファイアのSHA256ハッシュ、base64urlエンコード
  const codeChallenge = createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");
 
  // State:CSRF保護用のランダム値
  const state = randomBytes(16).toString("hex");
 
  // リダイレクト前に両方をセッション(サーバーサイド!)に保存
  // code_verifierはCookieやURLパラメータに絶対入れない
  session.codeVerifier = codeVerifier;
  session.oauthState = state;
 
  const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth");
  authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID!);
  authUrl.searchParams.set("redirect_uri", "https://example.com/api/auth/callback/google");
  authUrl.searchParams.set("response_type", "code");
  authUrl.searchParams.set("scope", "openid email profile");
  authUrl.searchParams.set("state", state);
  authUrl.searchParams.set("code_challenge", codeChallenge);
  authUrl.searchParams.set("code_challenge_method", "S256");
 
  return authUrl.toString();
}
typescript
// ステップ2:コールバックを処理
async function handleOAuthCallback(request: Request) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");
  const error = url.searchParams.get("error");
 
  // プロバイダーからのエラーをチェック
  if (error) {
    throw new Error(`OAuthエラー: ${error}`);
  }
 
  // Stateの一致を検証(CSRF保護)
  if (state !== session.oauthState) {
    throw new Error("State不一致 — CSRF攻撃の可能性");
  }
 
  // 認可コードをトークンと交換
  const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: code!,
      redirect_uri: "https://example.com/api/auth/callback/google",
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      code_verifier: session.codeVerifier, // PKCE:このフローを開始したことを証明
    }),
  });
 
  const tokens = await tokenResponse.json();
  // tokens.access_token — GoogleへのAPIコール用
  // tokens.id_token — ユーザーIDを含むJWT(OIDC)
  // tokens.refresh_token — 新しいアクセストークン取得用
 
  // ステップ3:IDトークンを検証してユーザー情報を抽出
  const idTokenPayload = await verifyGoogleIdToken(tokens.id_token);
 
  return {
    googleId: idTokenPayload.sub,
    email: idTokenPayload.email,
    name: idTokenPayload.name,
    picture: idTokenPayload.picture,
  };
}

3つのエンドポイント#

すべてのOAuth/OIDCプロバイダーはこれらを公開している:

  1. 認可エンドポイント — ユーザーがログインして権限を付与するためにリダイレクトする場所。認可コードを返す。
  2. トークンエンドポイント — サーバーが認可コードをアクセス/リフレッシュ/IDトークンと交換する場所。これはサーバー間通信だ。
  3. UserInfoエンドポイント — アクセストークンを使って追加のユーザープロファイルデータを取得できる場所。OIDCでは、この情報の多くは既にIDトークンに含まれている。

Stateパラメータ#

state パラメータはOAuthコールバックに対するCSRF攻撃を防止する。これがないと:

  1. 攻撃者が自分のマシンでOAuthフローを開始し、認可コードを取得
  2. 攻撃者がURLを作成:https://yourapp.com/callback?code=ATTACKER_CODE
  3. 攻撃者が被害者にクリックさせる(メールリンク、非表示画像)
  4. アプリが攻撃者のコードを交換し、攻撃者のGoogleアカウントを被害者のセッションにリンク

state があれば:アプリがランダム値を生成し、セッションに保存し、認可URLに含める。コールバックが来た時、state が一致するか検証する。攻撃者は被害者のセッションにアクセスできないため、これを偽造できない。

Auth.js(NextAuth)とNext.js App Router#

Auth.jsはほとんどのNext.jsプロジェクトで最初に手が伸びるライブラリだ。OAuthのダンス、セッション管理、データベース永続化、CSRF保護を処理してくれる。以下は本番環境向けのセットアップだ。

基本設定#

typescript
// src/lib/auth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
import { verifyPassword } from "@/lib/password";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
 
  // より良いセキュリティのためにデータベースセッションを使用(JWTではなく)
  session: {
    strategy: "database",
    maxAge: 30 * 24 * 60 * 60, // 30日
    updateAge: 24 * 60 * 60,   // 24時間ごとにセッションを延長
  },
 
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
      // 特定のスコープをリクエスト
      authorization: {
        params: {
          scope: "openid email profile",
          prompt: "consent",
          access_type: "offline", // リフレッシュトークンを取得
        },
      },
    }),
 
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
 
    // メール/パスワードログイン(慎重に使用)
    Credentials({
      credentials: {
        email: { label: "メール", type: "email" },
        password: { label: "パスワード", type: "password" },
      },
      authorize: async (credentials) => {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }
 
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });
 
        if (!user || !user.passwordHash) {
          return null;
        }
 
        const isValid = await verifyPassword(
          credentials.password as string,
          user.passwordHash
        );
 
        if (!isValid) {
          return null;
        }
 
        return {
          id: user.id,
          email: user.email,
          name: user.name,
          image: user.image,
        };
      },
    }),
  ],
 
  callbacks: {
    // サインインできるユーザーを制御
    async signIn({ user, account }) {
      // BANされたユーザーのサインインをブロック
      if (user.id) {
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { banned: true },
        });
        if (dbUser?.banned) return false;
      }
      return true;
    },
 
    // セッションにカスタムフィールドを追加
    async session({ session, user }) {
      if (session.user) {
        session.user.id = user.id;
        // データベースからロールを取得
        const dbUser = await prisma.user.findUnique({
          where: { id: user.id },
          select: { role: true },
        });
        session.user.role = dbUser?.role ?? "user";
      }
      return session;
    },
  },
 
  pages: {
    signIn: "/login",
    error: "/auth/error",
    verifyRequest: "/auth/verify",
  },
});

ルートハンドラー#

typescript
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/lib/auth";
 
export const { GET, POST } = handlers;

ミドルウェアによる保護#

typescript
// src/middleware.ts
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
 
export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isAuthPage = req.nextUrl.pathname.startsWith("/login")
    || req.nextUrl.pathname.startsWith("/register");
  const isProtectedRoute = req.nextUrl.pathname.startsWith("/dashboard")
    || req.nextUrl.pathname.startsWith("/settings")
    || req.nextUrl.pathname.startsWith("/admin");
  const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
 
  // ログイン済みユーザーを認証ページからリダイレクト
  if (isLoggedIn && isAuthPage) {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  // 未認証ユーザーをログインページへリダイレクト
  if (!isLoggedIn && isProtectedRoute) {
    const callbackUrl = encodeURIComponent(req.nextUrl.pathname);
    return NextResponse.redirect(
      new URL(`/login?callbackUrl=${callbackUrl}`, req.nextUrl)
    );
  }
 
  // 管理者ロールをチェック
  if (isAdminRoute && req.auth?.user?.role !== "admin") {
    return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
  }
 
  return NextResponse.next();
});
 
export const config = {
  matcher: [
    "/dashboard/:path*",
    "/settings/:path*",
    "/admin/:path*",
    "/login",
    "/register",
  ],
};

サーバーコンポーネントでのセッション使用#

typescript
// src/app/dashboard/page.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
 
export default async function DashboardPage() {
  const session = await auth();
 
  if (!session?.user) {
    redirect("/login");
  }
 
  return (
    <div>
      <h1>ようこそ、{session.user.name}さん</h1>
      <p>ロール: {session.user.role}</p>
    </div>
  );
}

クライアントコンポーネントでのセッション使用#

typescript
"use client";
 
import { useSession } from "next-auth/react";
 
export function UserMenu() {
  const { data: session, status } = useSession();
 
  if (status === "loading") {
    return <div>読み込み中...</div>;
  }
 
  if (status === "unauthenticated") {
    return <a href="/login">サインイン</a>;
  }
 
  return (
    <div>
      <img
        src={session?.user?.image ?? "/default-avatar.png"}
        alt={session?.user?.name ?? "ユーザー"}
      />
      <span>{session?.user?.name}</span>
    </div>
  );
}

パスキー(WebAuthn)#

パスキーはここ数年で最も重要な認証の改善だ。フィッシング耐性があり、リプレイ耐性があり、パスワード関連の脆弱性のカテゴリー全体を排除する。2026年に新しいプロジェクトを始めるなら、パスキーをサポートすべきだ。

パスキーの仕組み#

パスキーは公開鍵暗号を使い、生体認証やデバイスPINに裏打ちされている:

  1. 登録:ブラウザが鍵ペアを生成する。秘密鍵はデバイス上に留まる(セキュアエンクレーブ内、生体認証で保護)。公開鍵がサーバーに送信される。
  2. 認証:サーバーがチャレンジ(ランダムバイト列)を送信する。デバイスが(生体認証後に)秘密鍵でチャレンジに署名する。サーバーが保存された公開鍵で署名を検証する。

共有シークレットはネットワークを一切通らない。フィッシングされるものも、漏洩するものも、詰め込まれるものもない。

パスキーがフィッシング耐性を持つ理由#

パスキーが作成されると、オリジン(例:https://example.com)にバインドされる。ブラウザは、パスキーが作成された正確なオリジンでのみそのパスキーを使用する。攻撃者が https://exarnple.com で偽サイトを作っても、パスキーは単に提供されない。これはユーザーの注意力ではなく、ブラウザによって強制される。

これはパスワードとは根本的に異なる。パスワードの場合、ページの見た目が正しいので、ユーザーはフィッシングサイトに日常的に認証情報を入力してしまう。

SimpleWebAuthnでの実装#

SimpleWebAuthnは私が推奨するライブラリだ。WebAuthnプロトコルを正しく処理し、良いTypeScript型定義を持っている。

typescript
// サーバーサイド:登録
import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
} from "@simplewebauthn/server";
import type {
  GenerateRegistrationOptionsOpts,
  VerifiedRegistrationResponse,
} from "@simplewebauthn/server";
 
const rpName = "akousa.net";
const rpID = "akousa.net";
const origin = "https://akousa.net";
 
async function startRegistration(userId: string, userEmail: string) {
  // ユーザーの既存パスキーを取得して除外
  const existingCredentials = await db.credential.findMany({
    where: { userId },
    select: { credentialId: true, transports: true },
  });
 
  const options: GenerateRegistrationOptionsOpts = {
    rpName,
    rpID,
    userID: new TextEncoder().encode(userId),
    userName: userEmail,
    attestationType: "none", // ほとんどのアプリではアテステーションは不要
    excludeCredentials: existingCredentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    })),
    authenticatorSelection: {
      residentKey: "preferred",
      userVerification: "preferred",
    },
  };
 
  const registrationOptions = await generateRegistrationOptions(options);
 
  // チャレンジを一時保存 — 検証に必要
  await redis.set(
    `webauthn:challenge:${userId}`,
    registrationOptions.challenge,
    "EX",
    300 // 5分で期限切れ
  );
 
  return registrationOptions;
}
 
async function finishRegistration(userId: string, response: unknown) {
  const expectedChallenge = await redis.get(`webauthn:challenge:${userId}`);
 
  if (!expectedChallenge) {
    throw new Error("チャレンジが期限切れまたは見つからない");
  }
 
  let verification: VerifiedRegistrationResponse;
  try {
    verification = await verifyRegistrationResponse({
      response: response as any,
      expectedChallenge,
      expectedOrigin: origin,
      expectedRPID: rpID,
    });
  } catch (error) {
    throw new Error(`登録検証に失敗: ${error}`);
  }
 
  if (!verification.verified || !verification.registrationInfo) {
    throw new Error("登録検証に失敗");
  }
 
  const { credential } = verification.registrationInfo;
 
  // クレデンシャルをデータベースに保存
  await db.credential.create({
    data: {
      userId,
      credentialId: credential.id,
      publicKey: Buffer.from(credential.publicKey),
      counter: credential.counter,
      transports: credential.transports ?? [],
    },
  });
 
  // クリーンアップ
  await redis.del(`webauthn:challenge:${userId}`);
 
  return { verified: true };
}
typescript
// サーバーサイド:認証
import {
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from "@simplewebauthn/server";
 
async function startAuthentication(userId?: string) {
  let allowCredentials;
 
  // ユーザーが分かっている場合(メールを入力した等)、そのパスキーに限定
  if (userId) {
    const credentials = await db.credential.findMany({
      where: { userId },
      select: { credentialId: true, transports: true },
    });
    allowCredentials = credentials.map((cred) => ({
      id: cred.credentialId,
      transports: cred.transports,
    }));
  }
 
  const options = await generateAuthenticationOptions({
    rpID,
    allowCredentials,
    userVerification: "preferred",
  });
 
  // 検証用にチャレンジを保存
  const challengeKey = userId
    ? `webauthn:auth:${userId}`
    : `webauthn:auth:${options.challenge}`;
 
  await redis.set(challengeKey, options.challenge, "EX", 300);
 
  return options;
}
 
async function finishAuthentication(
  response: any,
  expectedChallenge: string,
  userId: string
) {
  const credential = await db.credential.findUnique({
    where: { credentialId: response.id },
  });
 
  if (!credential) {
    throw new Error("クレデンシャルが見つからない");
  }
 
  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: credential.credentialId,
      publicKey: credential.publicKey,
      counter: credential.counter,
      transports: credential.transports,
    },
  });
 
  if (!verification.verified) {
    throw new Error("認証検証に失敗");
  }
 
  // 重要:リプレイ攻撃を防ぐためにカウンターを更新
  await db.credential.update({
    where: { credentialId: response.id },
    data: {
      counter: verification.authenticationInfo.newCounter,
    },
  });
 
  return { verified: true, userId: credential.userId };
}
typescript
// クライアントサイド:登録
import { startRegistration as webAuthnRegister } from "@simplewebauthn/browser";
 
async function registerPasskey() {
  // サーバーからオプションを取得
  const optionsResponse = await fetch("/api/auth/webauthn/register", {
    method: "POST",
  });
  const options = await optionsResponse.json();
 
  try {
    // ブラウザのパスキーUI(生体認証プロンプト)をトリガー
    const credential = await webAuthnRegister(options);
 
    // 検証のためにクレデンシャルをサーバーに送信
    const verifyResponse = await fetch("/api/auth/webauthn/register/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(credential),
    });
 
    const result = await verifyResponse.json();
    if (result.verified) {
      console.log("パスキーが正常に登録されました!");
    }
  } catch (error) {
    if ((error as Error).name === "NotAllowedError") {
      console.log("ユーザーがパスキー登録をキャンセルしました");
    }
  }
}

アテステーション vs アサーション#

よく出てくる2つの用語:

  • アテステーション(登録):新しいクレデンシャルを作成するプロセス。認証器がそのIDと機能を「証明」する。ほとんどのアプリケーションでは、アテステーションの検証は不要 — attestationType: "none" に設定する。
  • アサーション(認証):既存のクレデンシャルを使ってチャレンジに署名するプロセス。認証器がユーザーが主張する人物であることを「主張」する。

MFA実装#

パスキーがあっても、TOTP経由のMFAが必要なシナリオに遭遇する — パスワードと組み合わせた第二要素としてのパスキー、またはパスキーをサポートしないデバイスのユーザーのサポートなど。

TOTP(時間ベースのワンタイムパスワード)#

TOTPはGoogle Authenticator、Authy、1Passwordの背後にあるプロトコルだ。以下のように動作する:

  1. サーバーがランダムなシークレットを生成(base32エンコード)
  2. ユーザーがシークレットを含むQRコードをスキャン
  3. サーバーと認証器アプリの両方がシークレットと現在時刻から同じ6桁のコードを計算
  4. コードは30秒ごとに変わる
typescript
import { createHmac, randomBytes } from "crypto";
 
// ユーザー用のTOTPシークレットを生成
function generateTOTPSecret(): string {
  const buffer = randomBytes(20);
  return base32Encode(buffer);
}
 
function base32Encode(buffer: Buffer): string {
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
  let result = "";
  let bits = 0;
  let value = 0;
 
  for (const byte of buffer) {
    value = (value << 8) | byte;
    bits += 8;
    while (bits >= 5) {
      result += alphabet[(value >>> (bits - 5)) & 0x1f];
      bits -= 5;
    }
  }
 
  if (bits > 0) {
    result += alphabet[(value << (5 - bits)) & 0x1f];
  }
 
  return result;
}
 
// QRコード用のTOTP URIを生成
function generateTOTPUri(
  secret: string,
  userEmail: string,
  issuer: string = "akousa.net"
): string {
  const encodedIssuer = encodeURIComponent(issuer);
  const encodedEmail = encodeURIComponent(userEmail);
  return `otpauth://totp/${encodedIssuer}:${encodedEmail}?secret=${secret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}
typescript
// TOTPコードを検証
function verifyTOTP(secret: string, code: string, window: number = 1): boolean {
  const secretBuffer = base32Decode(secret);
  const now = Math.floor(Date.now() / 1000);
 
  // 現在のタイムステップと隣接するものをチェック(クロックドリフト許容)
  for (let i = -window; i <= window; i++) {
    const timeStep = Math.floor(now / 30) + i;
    const expectedCode = generateTOTPCode(secretBuffer, timeStep);
 
    // タイミング攻撃を防ぐための定数時間比較
    if (timingSafeEqual(code, expectedCode)) {
      return true;
    }
  }
 
  return false;
}
 
function generateTOTPCode(secret: Buffer, timeStep: number): string {
  // タイムステップを8バイトのビッグエンディアンバッファに変換
  const timeBuffer = Buffer.alloc(8);
  timeBuffer.writeBigInt64BE(BigInt(timeStep));
 
  // HMAC-SHA1
  const hmac = createHmac("sha1", secret).update(timeBuffer).digest();
 
  // 動的切り捨て
  const offset = hmac[hmac.length - 1] & 0x0f;
  const code =
    ((hmac[offset] & 0x7f) << 24) |
    ((hmac[offset + 1] & 0xff) << 16) |
    ((hmac[offset + 2] & 0xff) << 8) |
    (hmac[offset + 3] & 0xff);
 
  return (code % 1_000_000).toString().padStart(6, "0");
}
 
function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  const bufA = Buffer.from(a);
  const bufB = Buffer.from(b);
  return createHmac("sha256", "key").update(bufA).digest()
    .equals(createHmac("sha256", "key").update(bufB).digest());
}

バックアップコード#

ユーザーはスマートフォンを紛失する。MFAセットアップ時に必ずバックアップコードを生成する:

typescript
import { randomBytes, createHash } from "crypto";
 
function generateBackupCodes(count: number = 10): string[] {
  return Array.from({ length: count }, () =>
    randomBytes(4).toString("hex").toUpperCase() // 8文字の16進数コード
  );
}
 
async function storeBackupCodes(userId: string, codes: string[]) {
  // 保存前にコードをハッシュ化 — パスワードと同様に扱う
  const hashedCodes = codes.map((code) =>
    createHash("sha256").update(code).digest("hex")
  );
 
  await db.backupCode.createMany({
    data: hashedCodes.map((hash) => ({
      userId,
      codeHash: hash,
      used: false,
    })),
  });
 
  // プレーンコードをユーザーが保存するために一度だけ返す
  // これ以降はハッシュのみ保持
  return codes;
}
 
async function verifyBackupCode(userId: string, code: string): Promise<boolean> {
  const codeHash = createHash("sha256")
    .update(code.toUpperCase().replace(/\s/g, ""))
    .digest("hex");
 
  const backupCode = await db.backupCode.findFirst({
    where: {
      userId,
      codeHash,
      used: false,
    },
  });
 
  if (!backupCode) return false;
 
  // 使用済みにマーク — 各バックアップコードは一度だけ使える
  await db.backupCode.update({
    where: { id: backupCode.id },
    data: { used: true, usedAt: new Date() },
  });
 
  return true;
}

リカバリーフロー#

MFAリカバリーは、ほとんどのチュートリアルがスキップし、ほとんどの実際のアプリケーションが失敗する部分だ。私が実装する内容:

  1. プライマリ:認証器アプリからのTOTPコード
  2. セカンダリ:10個のバックアップコードのうちの1つ
  3. 最後の手段:メールベースのリカバリー、24時間の待機期間付きで、ユーザーの他の確認済みチャネルへの通知を含む

待機期間は非常に重要だ。攻撃者がユーザーのメールを侵害している場合、MFAを即座に無効化させたくない。24時間の遅延により、正規のユーザーがメールに気づいて介入する時間が生まれる。

typescript
async function initiateAccountRecovery(email: string) {
  const user = await db.user.findUnique({ where: { email } });
  if (!user) {
    // アカウントの存在を明かさない
    return { message: "そのメールアドレスが存在する場合、リカバリー手順を送信しました。" };
  }
 
  const recoveryToken = randomBytes(32).toString("hex");
  const tokenHash = createHash("sha256").update(recoveryToken).digest("hex");
 
  await db.recoveryRequest.create({
    data: {
      userId: user.id,
      tokenHash,
      expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24時間
      status: "pending",
    },
  });
 
  // リカバリーリンク付きメールを送信
  await sendEmail(email, {
    subject: "アカウントリカバリーリクエスト",
    body: `
      あなたのアカウントのMFAを無効にするリクエストが行われました。
      これがあなた自身の操作であれば、24時間後に以下のリンクをクリックしてください: ...
      これがあなたの操作でなければ、すぐにパスワードを変更してください。
    `,
  });
 
  return { message: "そのメールアドレスが存在する場合、リカバリー手順を送信しました。" };
}

認可パターン#

認証は_誰であるか_を教えてくれる。認可は_何をすることが許可されているか_を教えてくれる。これを間違えると、ニュースに載ることになる。

RBAC vs ABAC#

RBAC(ロールベースアクセス制御):ユーザーにロールがあり、ロールに権限がある。シンプルで理解しやすく、ほとんどのアプリケーションで機能する。

typescript
// RBAC — 直接的なロールチェック
type Role = "user" | "editor" | "admin" | "super_admin";
 
const ROLE_PERMISSIONS: Record<Role, string[]> = {
  user: ["read:own_profile", "update:own_profile", "read:posts"],
  editor: ["read:own_profile", "update:own_profile", "read:posts", "create:posts", "update:posts"],
  admin: [
    "read:own_profile", "update:own_profile",
    "read:posts", "create:posts", "update:posts", "delete:posts",
    "read:users", "update:users",
  ],
  super_admin: ["*"], // ワイルドカードには注意
};
 
function hasPermission(role: Role, permission: string): boolean {
  const permissions = ROLE_PERMISSIONS[role];
  return permissions.includes("*") || permissions.includes(permission);
}
 
// APIルートでの使用例
export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: "未認証" }, { status: 401 });
  }
 
  if (!hasPermission(session.user.role as Role, "delete:posts")) {
    return Response.json({ error: "禁止" }, { status: 403 });
  }
 
  const { id } = await params;
  await db.post.delete({ where: { id } });
  return Response.json({ success: true });
}

ABAC(属性ベースアクセス制御):権限はユーザー、リソース、コンテキストの属性に依存する。より柔軟だが、より複雑。

typescript
// ABAC — RBACでは不十分な場合
interface PolicyContext {
  user: {
    id: string;
    role: string;
    department: string;
    clearanceLevel: number;
  };
  resource: {
    type: string;
    ownerId: string;
    classification: string;
    department: string;
  };
  action: string;
  environment: {
    ipAddress: string;
    time: Date;
    mfaVerified: boolean;
  };
}
 
function evaluatePolicy(context: PolicyContext): boolean {
  const { user, resource, action, environment } = context;
 
  // ユーザーは常に自分のリソースを読み取れる
  if (action === "read" && resource.ownerId === user.id) {
    return true;
  }
 
  // 管理者は自分の部門の任意のリソースを読み取れる
  if (
    action === "read" &&
    user.role === "admin" &&
    user.department === resource.department
  ) {
    return true;
  }
 
  // 機密リソースにはMFAと最低クリアランスが必要
  if (resource.classification === "confidential") {
    if (!environment.mfaVerified) return false;
    if (user.clearanceLevel < 3) return false;
  }
 
  // 営業時間外は破壊的な操作をブロック
  if (action === "delete") {
    const hour = environment.time.getHours();
    if (hour < 9 || hour > 17) return false;
  }
 
  return false; // デフォルトは拒否
}

「境界でチェック」のルール#

最も重要な認可の原則:UIレベルだけでなく、すべての信頼境界で権限をチェックする。

typescript
// 悪い例 — コンポーネントでのみチェック
function DeleteButton({ post }: { post: Post }) {
  const { data: session } = useSession();
 
  // ボタンを非表示にするが、削除を防げない
  if (session?.user?.role !== "admin") return null;
 
  return <button onClick={() => deletePost(post.id)}>削除</button>;
}
 
// これも悪い例 — サーバーアクションでチェックしてもAPIルートでチェックしていない
async function deletePostAction(postId: string) {
  const session = await auth();
  if (session?.user?.role !== "admin") throw new Error("禁止");
  await db.post.delete({ where: { id: postId } });
}
// 攻撃者はPOST /api/posts/123を直接呼び出せる
 
// 良い例 — すべての境界でチェック
// 1. UIでボタンを非表示(UXのため、セキュリティではない)
// 2. サーバーアクションでチェック(多層防御)
// 3. APIルートでチェック(実際のセキュリティ境界)
// 4. オプションで、ミドルウェアでチェック(ルートレベルの保護)

UIチェックはユーザー体験のため。サーバーチェックはセキュリティのため。どちらか一方だけに頼ってはいけない。

Next.jsミドルウェアでの権限チェック#

ミドルウェアはマッチしたすべてのリクエストの前に実行される。粗い粒度のアクセス制御に適している:

typescript
// 「このユーザーはこのセクションにアクセスできるか?」
// 細かい粒度のチェック(「このユーザーはこの記事を編集できるか?」)はルートハンドラーで行う
// ミドルウェアではリクエストボディやルートパラメータに簡単にアクセスできないため
 
export default auth((req) => {
  const path = req.nextUrl.pathname;
  const role = req.auth?.user?.role;
 
  // ルートレベルのアクセス制御
  const routeAccess: Record<string, Role[]> = {
    "/admin": ["admin", "super_admin"],
    "/editor": ["editor", "admin", "super_admin"],
    "/dashboard": ["user", "editor", "admin", "super_admin"],
  };
 
  for (const [route, allowedRoles] of Object.entries(routeAccess)) {
    if (path.startsWith(route)) {
      if (!role || !allowedRoles.includes(role as Role)) {
        return NextResponse.redirect(new URL("/unauthorized", req.nextUrl));
      }
    }
  }
 
  return NextResponse.next();
});

よくある脆弱性#

実際のコードベースで最もよく見かける攻撃だ。理解することが不可欠。

セッション固定攻撃#

攻撃:攻撃者があなたのサイトで有効なセッションを作成し、被害者にそのセッションIDを使わせる(URLパラメータ経由や、サブドメインを通じてCookieを設定するなど)。被害者がログインすると、攻撃者のセッションに認証済みユーザーが紐づく。

修正:認証成功後に必ずセッションIDを再生成する。 認証前のセッションIDを認証後のセッションに引き継がせてはいけない。

typescript
async function login(credentials: { email: string; password: string }, request: Request) {
  const user = await verifyCredentials(credentials);
  if (!user) throw new Error("無効な認証情報");
 
  // 重要:古いセッションを削除して新しいものを作成
  const oldSessionId = getSessionIdFromCookie(request);
  if (oldSessionId) {
    await redis.del(`session:${oldSessionId}`);
  }
 
  // 新しいIDで完全に新しいセッションを作成
  const newSessionId = await createSession(user.id, request);
  return newSessionId;
}

CSRF(クロスサイトリクエストフォージェリ)#

攻撃:ユーザーがあなたのサイトにログインしている。悪意のあるページを訪問し、そのページがあなたのサイトにリクエストを送信する。Cookieは自動送信されるため、リクエストは認証済みとなる。

モダンな修正:SameSite Cookie。 SameSite: Lax(現在ほとんどのブラウザのデフォルト)を設定すると、クロスオリジンのPOSTリクエストでCookieの送信が防止され、ほとんどのCSRFシナリオをカバーする。

typescript
// SameSite=LaxはほとんどのCSRFシナリオをカバー:
// - クロスオリジンのPOST、PUT、DELETEでCookieをブロック
// - クロスオリジンのGET(トップレベルナビゲーション)でCookieを許可
//   GETリクエストには副作用がないべきなので、これで問題ない
 
cookieStore.set("session_id", sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: "lax",  // これがCSRF保護
  maxAge: 86400,
  path: "/",
});

JSONを受け入れるAPIの場合、追加の保護が無料で得られる:Content-Type: application/json ヘッダーはHTMLフォームからは設定できず、CORSが他のオリジンのJavaScriptからのカスタムヘッダー付きリクエストを防ぐ。

より強い保証が必要な場合(例:フォーム送信を受け入れる場合)、ダブルサブミットCookieパターンまたはシンクロナイザートークンを使用する。Auth.jsはこれを自動的に処理してくれる。

OAuthにおけるオープンリダイレクト#

攻撃:攻撃者が認証後に自分のサイトにリダイレクトするOAuthコールバックURLを作成する:https://yourapp.com/callback?redirect_to=https://evil.com/steal-token

コールバックハンドラーが redirect_to パラメータに無条件でリダイレクトすると、ユーザーは攻撃者のサイトに到達し、URLにトークンが含まれている可能性がある。

typescript
// 脆弱
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
  // ... ユーザーを認証 ...
  return Response.redirect(redirectTo); // https://evil.comの可能性!
}
 
// 安全
async function handleCallback(request: Request) {
  const url = new URL(request.url);
  const redirectTo = url.searchParams.get("redirect_to") ?? "/";
 
  // リダイレクトURLを検証
  const safeRedirect = sanitizeRedirectUrl(redirectTo, request.url);
  // ... ユーザーを認証 ...
  return Response.redirect(safeRedirect);
}
 
function sanitizeRedirectUrl(redirect: string, baseUrl: string): string {
  try {
    const url = new URL(redirect, baseUrl);
    const base = new URL(baseUrl);
 
    // 同一オリジンへのリダイレクトのみ許可
    if (url.origin !== base.origin) {
      return "/";
    }
 
    // パスリダイレクトのみ許可(javascript:やdata: URIは不可)
    if (!url.pathname.startsWith("/")) {
      return "/";
    }
 
    return url.pathname + url.search;
  } catch {
    return "/";
  }
}

Referer経由のトークン漏洩#

トークンをURLに入れると(入れてはいけない)、ユーザーがリンクをクリックした時に Referer ヘッダー経由で漏洩する。これはGitHubを含む実際の侵害を引き起こしている。

ルール:

  • 認証用のURLクエリパラメータにトークンを決して入れない
  • Referrer-Policy: strict-origin-when-cross-origin(またはそれ以上に厳格)を設定する
  • URLにトークンを入れる必要がある場合(例:メール確認リンク)、使い捨てかつ短寿命にする
typescript
// Next.jsのミドルウェアまたはレイアウトで
const headers = new Headers();
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");

JWTキーインジェクション#

あまり知られていない攻撃:一部のJWTライブラリは、検証器に公開鍵の場所を指示する jwkjku ヘッダーをサポートしている。攻撃者は次のことができる:

  1. 自分の鍵ペアを生成する
  2. 自分のペイロードでJWTを作成し、自分の秘密鍵で署名する
  3. jwk ヘッダーを自分の公開鍵を指すように設定する

ライブラリが jwk ヘッダーの鍵を無条件に取得して使用すると、署名は検証される。修正:トークンに自身の検証鍵を指定させてはいけない。常に自分の設定の鍵を使用する。

2026年の私の認証スタック#

何年も認証システムを構築してきて、今実際に使っているものはこれだ。

ほとんどのプロジェクト:Auth.js + PostgreSQL + パスキー#

新しいプロジェクトのデフォルトスタック:

  • Auth.js(v5):OAuth プロバイダー、セッション管理、CSRF、データベースアダプターの重い処理を担当
  • PostgreSQL + Prismaアダプター:セッションとアカウントのストレージ
  • パスキー(SimpleWebAuthn経由):新規ユーザーの主要ログイン方法
  • メール/パスワード:パスキーが使えないユーザーのフォールバック
  • TOTP MFA:パスワードベースのログインの第二要素

セッション戦略はデータベースバック(JWTではない)で、即座の無効化とシンプルなセッション管理が可能だ。

typescript
// 新しいプロジェクトでの典型的なauth.ts
import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import GitHub from "next-auth/providers/github";
import Passkey from "next-auth/providers/passkey";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "@/lib/prisma";
 
export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  session: { strategy: "database" },
  providers: [
    Google,
    GitHub,
    Passkey({
      // Auth.js v5にはパスキーサポートが組み込まれている
      // 内部的にSimpleWebAuthnを使用
    }),
  ],
  experimental: {
    enableWebAuthn: true,
  },
});

ClerkやAuth0を代わりに使うべき場合#

マネージド認証プロバイダーに手を伸ばすのは以下の場合だ:

  • プロジェクトにエンタープライズSSO(SAML、SCIM)が必要な場合。SAMLを正しく実装するのは数ヶ月のプロジェクトだ。Clerkならすぐに使える。
  • チームにセキュリティの専門知識がない場合。 チームの誰もPKCEを説明できないなら、認証をゼロから構築すべきではない。
  • コストよりも市場投入速度が重要な場合。 Auth.jsは無料だが、適切なセットアップに数日かかる。Clerkなら午後で済む。
  • コンプライアンス保証(SOC 2、HIPAA)が必要な場合。マネージドプロバイダーがコンプライアンス認証を処理してくれる。

マネージドプロバイダーのトレードオフ:

  • コスト:Clerkは月間アクティブユーザーごとに課金される。スケールすると積み重なる。
  • ベンダーロックイン:ClerkやAuth0からの移行は苦痛だ。ユーザーテーブルが彼らのサーバーにある。
  • カスタマイズの限界:認証フローが特殊な場合、プロバイダーの意見と戦うことになる。
  • レイテンシ:すべての認証チェックがサードパーティAPIに向かう。データベースセッションならローカルクエリだ。

避けていること#

  • 自前の暗号実装。 JWTには jose、パスキーには @simplewebauthn/server、パスワードには bcrypt または argon2 を使用。手作りは絶対にしない。
  • SHA256でのパスワード保存。 bcrypt(コストファクター12以上)またはargon2idを使用する。SHA256は速すぎる — 攻撃者がGPUで毎秒数十億のハッシュを試行できる。
  • 長寿命アクセストークン。 最大15分。長いセッションにはリフレッシュトークンローテーションを使用。
  • サービス間検証のための対称シークレット。 複数のサービスがトークンを検証する必要がある場合、RS256と公開/秘密鍵ペアを使用する。
  • エントロピーが不十分なカスタムセッションID。 最低 crypto.randomBytes(32) を使用する。UUID v4は許容可能だが、ランダムバイト生成よりエントロピーが少ない。

パスワードハッシュ:正しい方法#

せっかくなので — 2026年にパスワードを正しくハッシュする方法:

typescript
import { hash, verify } from "@node-rs/argon2";
 
// Argon2idが推奨アルゴリズム
// これらはWebアプリケーションの妥当なデフォルト値
async function hashPassword(password: string): Promise<string> {
  return hash(password, {
    memoryCost: 65536,  // 64 MB
    timeCost: 3,        // 3回のイテレーション
    parallelism: 4,     // 4スレッド
  });
}
 
async function verifyPassword(
  password: string,
  hashedPassword: string
): Promise<boolean> {
  try {
    return await verify(hashedPassword, password);
  } catch {
    return false;
  }
}

bcryptよりargon2idを選ぶ理由は?Argon2idはメモリハードであり、攻撃にはCPUパワーだけでなく大量のRAMも必要だ。これによりGPUおよびASIC攻撃のコストが大幅に上がる。bcryptも問題ない — 壊れているわけではない — が、新しいプロジェクトにはargon2idの方がより良い選択だ。

セキュリティチェックリスト#

認証システムをリリースする前に確認すること:

  • パスワードはargon2idまたはbcrypt(コスト12以上)でハッシュされている
  • ログイン後にセッションが再生成される(セッション固定攻撃の防止)
  • Cookieが HttpOnlySecureSameSite=Lax または Strict である
  • JWTがアルゴリズムを明示的に指定している(alg ヘッダーを信頼しない)
  • アクセストークンが15分以内に期限切れになる
  • リフレッシュトークンローテーションが再利用検出付きで実装されている
  • OAuthのstateパラメータが検証されている(CSRF保護)
  • リダイレクトURLがホワイトリストに対して検証されている
  • ログイン、登録、パスワードリセットのエンドポイントにレート制限が適用されている
  • ログイン失敗がIPとともに記録されている(パスワードは記録しない)
  • N回の失敗後にアカウントロックアウト(永久ロックではなく、段階的な遅延で)
  • パスワードリセットトークンが使い捨てで1時間で期限切れになる
  • MFAバックアップコードがパスワードと同様にハッシュされている
  • CORSが既知のオリジンのみ許可するように設定されている
  • Referrer-Policy ヘッダーが設定されている
  • JWTペイロードに機密データがない(誰でも読み取れる)
  • WebAuthnカウンターが検証・更新されている(クレデンシャル複製の防止)

このリストは網羅的ではないが、本番システムで最もよく見かける脆弱性をカバーしている。

まとめ#

認証は状況が常に進化するが、基本は変わらない領域だ:IDを検証し、必要最小限のクレデンシャルを発行し、すべての境界で権限をチェックし、侵害を前提とする。

2026年の最大の変化は、パスキーの主流化だ。ブラウザサポートはユニバーサルで、プラットフォームサポート(iCloudキーチェーン、Googleパスワードマネージャー)によりUXはシームレスであり、セキュリティ特性はこれまでの何よりも真に優れている。新しいアプリケーションを構築するなら、パスキーを主要なログイン方法にし、パスワードをフォールバックとして扱うべきだ。

2番目に大きな変化は、自前の認証構築を正当化することが難しくなっていることだ。Auth.js v5、Clerk、および類似のソリューションは、難しい部分を正しく処理する。カスタムにする唯一の理由は、要件が既存のソリューションに本当に合わない場合だ — そしてそれはほとんどの開発者が思っているよりもまれだ。

何を選ぶにしても、攻撃者のように認証をテストすべきだ。トークンのリプレイ、署名の偽造、アクセスすべきでないルートへのアクセス、リダイレクトURLの操作を試みる。リリース前に見つけるバグは、ニュースにならないバグだ。

関連記事