はじめに
前回の記事では、RustでOry HydraのLogin/Consent Providerを実装した。5つのエンドポイント(GET/POST /login、GET/POST /consent、GET /logout)とHydra Admin APIの連携。Argon2idによるパスワードハッシュ、ユーザー列挙攻撃を防ぐテスト設計の話をした。
前提知識: この記事は前回の記事の続編です。OAuth2認可コードフローの基礎知識と、Ory HydraのLogin/Consent Providerの役割を理解している前提で進めます。
今回は、そのバックエンドと連携するフロントエンドをNext.js 15で実装する。なぜフロントエンドも自分で書くのか。認証フローを端から端まで把握しておきたいからだ。ちなみにフロントエンドは専門外なのである程度は許してほしいです。NextAuth.jsやAuth0のSDKを使えば楽だが、ブラックボックスのまま本番に出すのは怖い。何かが壊れたとき、「ライブラリの中で何が起きているかわからない」では障害対応で詰むことがある。もちろん、最終的なゴールは「理解した上でライブラリを使う」ことだ。車輪の再発明を推奨しているわけではない。
OAuth2/OIDCフローをブラウザ側でどう扱うか。Cookie管理の罠。マルチテナント環境での認証の複雑さ。実際に動かして気づいたことを記録する。
OAuth2認可コードフロー:フロントエンドから見た流れ
まず全体像を把握しておく。
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Browser │ │ Next.js │ │ Rust Backend│ │ Ory Hydra │
│ (User) │ │ Frontend │ │ (Provider) │ │ (OAuth2) │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │ │
│ 1. Login Click │ │ │
│──────────────────>│ │ │
│ │ │ │
│ 2. Redirect to │ │ │
│ /oauth2/auth │ │ │
│<──────────────────│ │ │
│ │ │ │
│ 3. GET /oauth2/auth?client_id=... │ │
│──────────────────────────────────────────────────────────>│
│ │ │ │
│ 4. Redirect to /login?login_challenge=xxx │
│<──────────────────────────────────────────────────────────│
│ │ │ │
│ 5. GET /login │ │
│──────────────────────────────────────>│ │
│ │ │ │
│ 6. Login Form │ │ │
│<──────────────────────────────────────│ │
│ │ │ │
│ 7. POST /login (credentials) │ │
│──────────────────────────────────────>│ │
│ │ │ │
│ │ │ 8. Accept Login │
│ │ │──────────────────>│
│ │ │ │
│ 9. Redirect to /consent │ │
│<──────────────────────────────────────│ │
│ │ │ │
│ ... Consent Flow ... │ │
│ │ │ │
│ 10. Redirect to /callback?code=xxx │ │
│<──────────────────────────────────────────────────────────│
│ │ │ │
│ 11. GET /callback │ │ │
│──────────────────>│ │ │
│ │ │ │
│ │ 12. Exchange code for tokens │
│ │──────────────────────────────────────>│
│ │ │ │
│ │ 13. Tokens (access, id, refresh) │
│ │<──────────────────────────────────────│
│ │ │ │
│ 14. Set Cookie & │ │ │
│ Redirect │ │ │
│<──────────────────│ │ │
このフローで重要なのは、フロントエンドは認証ロジックを持たないということだ。なぜか。フロントエンドのコードはユーザーのブラウザで動く。攻撃者は自由に改変できる。DevToolsを開けばJavaScriptは丸見えだし、リクエストも書き換えられる。認証ロジックをそこに置くということは、攻撃者に「好きに改ざんしていいですよ」と言っているようなものだ。
認証情報の検証はすべてRustバックエンド(Login Provider)で行う。フロントエンドの役割は:
Next.js App Routerでの実装
ディレクトリ構成
frontend/src/ ├── app/ │ ├── layout.tsx │ ├── page.tsx # ランディング │ ├── dashboard/page.tsx # 認証後のダッシュボード │ ├── callback/page.tsx # OAuth2コールバック │ └── api/auth/ │ ├── login/route.ts # ログイン開始 │ ├── callback/route.ts # コールバック処理 │ └── logout/route.ts # ログアウト ├── components/ │ └── shared/ │ └── Header.tsx ├── lib/ │ └── api.ts # APIクライアント └── middleware.ts # 認証チェック
ログイン開始:認可エンドポイントへのリダイレクト
// app/api/auth/login/route.ts import { NextResponse } from "next/server"; import crypto from "crypto"; export async function GET(request: Request) { const { searchParams } = new URL(request.url); const returnTo = searchParams.get("returnTo") || "/dashboard"; // CSRF対策用のstate生成 const state = crypto.randomBytes(16).toString("hex"); // stateにリダイレクト先を含める(Base64エンコード) const stateWithReturn = `${state}:${Buffer.from(returnTo).toString("base64")}`; // Hydra認可エンドポイントへのURL構築 const params = new URLSearchParams({ client_id: process.env.OAUTH_CLIENT_ID!, response_type: "code", scope: "openid profile email", redirect_uri: `${process.env.NEXT_PUBLIC_URL}/callback`, state: stateWithReturn, }); const authUrl = `${process.env.HYDRA_PUBLIC_URL}/oauth2/auth?${params}`; return NextResponse.redirect(authUrl); }
stateパラメータは2つの役割を持つ:
RFC 9700 (OAuth 2.0 Security Best Current Practice)では、stateパラメータによるCSRF対策が明記されている。認可サーバーがPKCEをサポートしていることを確認できるなら、PKCEでCSRF対策を兼ねることも可能だが、stateを使う方法が最も広くサポートされている。
コールバック処理:トークン取得とCookie設定
ここが最も複雑な部分だ。
// app/api/auth/callback/route.ts import { NextResponse } from "next/server"; export async function POST(request: Request) { const body = await request.json(); const { code, state } = body; // stateからリダイレクト先を取り出す const [, returnToBase64] = state.split(":"); const returnTo = Buffer.from(returnToBase64, "base64").toString(); // 認可コードをトークンに交換 const tokenResponse = await fetch( `${process.env.HYDRA_PUBLIC_URL}/oauth2/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from( `${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}` ).toString("base64")}`, }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: `${process.env.NEXT_PUBLIC_URL}/callback`, }), } ); if (!tokenResponse.ok) { const error = await tokenResponse.text(); console.error("Token exchange failed:", error); return NextResponse.json( { error: "Token exchange failed" }, { status: 401 } ); } const tokens = await tokenResponse.json(); // IDトークンをデコードしてユーザー情報を取得 const idTokenPayload = JSON.parse( Buffer.from(tokens.id_token.split(".")[1], "base64").toString() ); console.log("ID token decoded:", idTokenPayload); // レスポンスにCookieを設定 const response = NextResponse.json({ success: true, returnTo }); response.cookies.set("auth_token", tokens.access_token, { httpOnly: false, // クライアントJSからアクセス可能に secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: tokens.expires_in, path: "/", }); if (tokens.refresh_token) { response.cookies.set("refresh_token", tokens.refresh_token, { httpOnly: true, // リフレッシュトークンはhttpOnlyで保護 secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 30 * 24 * 60 * 60, // 30日 path: "/", }); } return response; }
Cookie設定で学んだこと
最初、httpOnly: trueでアクセストークンを設定していた。OWASPのセッション管理チートシートによれば、これがセキュリティのベストプラクティスだ。しかし、クライアントサイドでAPIを呼び出す必要があった。
// クライアントコンポーネントでAPIを呼び出す useEffect(() => { const token = document.cookie .split("; ") .find((row) => row.startsWith("auth_token=")) ?.split("=")[1]; if (token) { api.setToken(token); } }, []);
httpOnly: trueだとdocument.cookieからアクセスできない。選択肢は2つ:
- アクセストークンを
httpOnly: falseにする - クライアントJSからアクセス可能 - Server Componentからのみ API を呼ぶ - httpOnlyのまま、サーバーサイドで処理
今回は1を選んだ。
「httpOnlyをfalseにするなんて、セキュリティの教科書に反している」——そう思う人がいるかもしれない。私もそう思った。OWASPのチートシートにも「httpOnly: trueにしろ」と書いてある。でも、教科書に書いてあることと、目の前のシステムで最善の選択は、必ずしも一致しない。
この判断には明確な理由がある。
まず、脅威モデルを整理する。httpOnlyの目的は「XSSでトークンを盗まれること」を防ぐことだ。では、XSSが成功した場合に何が起きるか。攻撃者はユーザーのブラウザ上で任意のJavaScriptを実行できる。httpOnlyでトークンを保護しても、攻撃者はfetch('/api/user/delete', {credentials: 'include'})を実行できる。トークンを「盗む」ことはできなくても、「使う」ことはできる。
しかし、httpOnly: falseにすることで追加のリスクが生じる。トークンを読み取って攻撃者のサーバーに送信できるため、攻撃者は別のマシンからトークンを使用できる。httpOnly: trueなら被害はそのブラウザセッション内に限定されるが、falseなら攻撃者が任意の場所からAPIを叩ける。
つまり、httpOnlyは「トークンの窃取」を防ぐことで、XSS被害の範囲を限定する。しかし、XSS対策の本質は、そもそもXSSを発生させないことだ。CSP(Content Security Policy)、入力のサニタイズ、Reactの自動エスケープ——これらがXSS対策の本丸であり、httpOnlyは最後の砦にすぎない。
その上で、今回の判断基準は以下だ。
- アクセストークンは短命(15分): 仮に窃取されても、15分で無効化される
- リフレッシュトークンは
httpOnly: trueで保護: 長期間有効なトークンは絶対に保護する - クライアントサイドでのAPI呼び出しが必須: Server Componentだけでは実現できないリアルタイム機能がある
しかし、これはトレードオフだ。Auth0のToken Storageガイドでは、SPAの場合、インメモリストレージが最も安全とされている。将来的にはBFF(Backend for Frontend)パターンに移行し、トークンをサーバーサイドで完全に管理する構成を検討している。
Curity社のベストプラクティス記事では、JWTの安全な取り扱いについて詳しく解説されている。
ID Tokenの署名検証
なぜ署名検証が必要か
最初の実装では、ID Tokenを単純にBase64デコードしていた:
// ❌ 危険:署名検証なしのデコード const payload = JSON.parse( Buffer.from(tokens.id_token.split(".")[1], "base64").toString() );
これは動く。中身も読める。でも、これでは改ざんを検出できない。
「tokenエンドポイントから直接取得しているから、改ざんされることはないのでは?」と思うかもしれない。確かに、バックエンドでtokenエンドポイントを呼び出し、その結果をそのまま使うなら、経路上で改ざんされるリスクは低い。しかし、問題は別のところにある。フロントエンドにトークンを渡す設計だと、ブラウザ側で別のトークンに差し替えられる可能性がある。また、マイクロサービス間でトークンを渡す際、悪意あるサービスが偽トークンを送る可能性もある。署名検証は「このトークンは本当にHydraが発行したものか」を確認する仕組みだ。
具体的に何が起きるか。攻撃者は以下のようなトークンを作成できる。
// 攻撃者が作成した偽のトークン const fakePayload = { sub: "admin-user-id", // 管理者のユーザーID email: "admin@example.com", role: "platform_admin", // 権限昇格 tenant_id: "target-tenant", // 他テナントへのアクセス exp: 9999999999 // 無期限 }; const fakeToken = `eyJhbGciOiJub25lIn0.${btoa(JSON.stringify(fakePayload))}.`;
署名検証をしていなければ、このトークンは「有効」として受け入れられる。攻撃者は任意のユーザーになりすまし、任意の権限を持ち、任意のテナントにアクセスできる。認証システムが完全に無意味になる。
JWTは3つのパートで構成される:ヘッダー.ペイロード.署名。署名を検証しないということは、攻撃者が作った偽のトークンも受け入れてしまうということだ。これは「鍵のかかっていない金庫」と同じだ。中身は入っているが、誰でも開けられる。
OpenID Connect Core 1.0のID Token検証仕様では、以下の検証が必須とされている:
joseライブラリによる実装
joseライブラリを使うと、これらの検証を簡潔に実装できる。
npm install jose
// lib/auth.ts import * as jose from "jose"; export interface IdTokenClaims { sub: string; aud: string | string[]; iss: string; exp: number; iat: number; email?: string; role?: string; tenant_id?: string; } /** * ID Tokenの署名を検証し、クレームを返す * @see https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation */ export async function verifyIdToken(idToken: string): Promise<IdTokenClaims> { const hydraUrl = process.env.HYDRA_PUBLIC_URL || "http://localhost:4444"; const clientId = process.env.NEXT_PUBLIC_CLIENT_ID || "demo-client"; // JWKSエンドポイントから公開鍵を取得 // @see https://www.ory.sh/docs/hydra/reference/api#tag/jwk/operation/discoverJsonWebKeys const JWKS = jose.createRemoteJWKSet( new URL(`${hydraUrl}/.well-known/jwks.json`) ); // 署名検証 + issuer/audience検証 const { payload } = await jose.jwtVerify(idToken, JWKS, { issuer: hydraUrl, audience: clientId, }); return payload as IdTokenClaims; }
コールバックでの使用
// app/api/auth/callback/route.ts import { verifyIdToken } from "@/lib/auth"; export async function POST(request: Request) { const { code } = await request.json(); // トークン交換... const tokens = await exchangeCodeForTokens(code); // ✅ 署名検証付きでID Tokenをデコード try { const claims = await verifyIdToken(tokens.id_token); console.log("ID token verified:", { sub: claims.sub, email: claims.email, role: claims.role, iss: claims.iss, }); // ユーザー情報をセッションに保存 const user = { id: claims.sub, email: claims.email || "unknown", role: claims.role || "customer", tenant_id: claims.tenant_id, }; // Cookie設定... } catch (error) { console.error("ID token verification failed:", error); return NextResponse.json( { error: "Token verification failed" }, { status: 401 } ); } }
E2Eテストでの確認
実際にログインフローを実行して、署名検証が機能していることを確認した。verifyIdToken()の内部ログと、コールバックハンドラーのログが出力される:
ID token verified successfully: {
sub: 'c128f3e7-5013-46b8-add2-fbe0e78bfec7',
email: 'demo@example.com',
role: 'platform_admin',
iss: 'http://localhost:4444'
}
ID token verified and decoded: {
sub: 'c128f3e7-5013-46b8-add2-fbe0e78bfec7',
email: 'demo@example.com',
role: 'platform_admin',
tenant_id: undefined,
iss: 'http://localhost:4444',
aud: [ 'demo-client' ]
}
POST /api/auth/callback 200 in 609ms
verified successfullyと出力されれば、以下が確認できている:
- JWKSエンドポイント(
/.well-known/jwks.json)から公開鍵を取得できた - 署名が正しく検証された(RS256)
issがHydra URL(http://localhost:4444)と一致したaudにクライアントID(demo-client)が含まれていた- トークンが有効期限内だった
tenant_id: undefinedは、Platform Adminユーザーがテナントに所属していないため。通常のテナントユーザーでログインすると、ここにテナントIDが表示される。
開発環境でのフォールバック
開発環境ではJWKSエンドポイントにアクセスできない場合がある。その時は警告を出しつつ、署名なしデコードにフォールバックする:
try { const claims = await verifyIdToken(tokens.id_token); // 検証成功 } catch (verifyError) { console.warn("ID token verification failed, falling back to unsafe decode"); console.warn("WARNING: Using unverified ID token claims. This is insecure!"); // 開発環境のみ許容 const unsafeClaims = decodeIdTokenUnsafe(tokens.id_token); // ... }
本番環境では、このフォールバックを無効化すべきだ。
マルチテナント認証
JWTにテナント情報を含める
Ory HydraのConsent画面で、ユーザーのテナント情報をIDトークンに含める。ベストプラクティスとして、Login時にcontextに保存したユーザー情報をConsent時に取得する(DBルックアップを回避):
// Rustバックエンド側(Consent Provider) // Best Practice: contextからユーザー情報を取得(DBルックアップ不要) // Login時にUserContextとして保存した情報をここで復元 let user_context: Option<UserContext> = consent_request .context .as_ref() .and_then(|ctx| serde_json::from_value(ctx.clone()).ok()); let (user_email, user_role, user_tenant_id) = user_context .map(|ctx| (ctx.email, ctx.role, ctx.tenant_id)) .unwrap_or_default(); // IDトークンにカスタムクレームを追加 let session = ConsentSession { id_token: serde_json::json!({ "email": user_email, "role": user_role, "tenant_id": user_tenant_id, // テナントIDを含める }), }; hydra.accept_consent(&challenge, grant_scope, grant_audience, Some(session)).await?;
ここで重要なのは、user_emailやuser_roleをDBから取得するのではなく、Login時にHydraのcontextに保存したUserContextから取得している点だ。これにより:
- Consent時のDBアクセスが不要になる
- Login時点のユーザー状態が保持される(整合性)
- パフォーマンスが向上する
フロントエンドでトークンをデコードすると、テナント情報が取得できる:
// IDトークンのペイロード例 { "aud": ["demo-client"], "email": "manager@example.com", "role": "manager", "tenant_id": "aa8d56f1-a083-439b-996a-4a7b73698dfb", "sub": "e5555555-5555-5555-5555-555555555555" }
APIリクエストでのテナント分離
バックエンドAPIは/api/v1/tenant/というプレフィックスでテナント固有のエンドポイントを提供:
/api/v1/tenant/incidents # テナント内のインシデント /api/v1/tenant/projects # テナント内のプロジェクト /api/v1/tenant/engineers # テナント内のエンジニア
テナントIDはJWTから取得するため、URLにテナントIDを含める必要はない。これにより:
- URLの推測による他テナントへのアクセス試行を防ぐ
- テナントIDの改ざんを防ぐ(JWTは署名で保護されている)
なぜURLパスにテナントIDを含める方式が危険なのか、具体例で説明する。
# URLパス方式(危険) GET /api/v1/tenants/tenant-123/incidents GET /api/v1/tenants/tenant-456/incidents ← tenant-123のユーザーがアクセスを試みる
この方式では、バックエンドで「リクエストしたユーザーがtenant-456に所属しているか」を毎回検証する必要がある。検証を忘れると、他テナントのデータが漏洩する。実際、この種のバグは「IDOR(Insecure Direct Object Reference)」として知られ、OWASPのトップ10に常に入る脆弱性だ。
# JWTクレーム方式(安全)
GET /api/v1/tenant/incidents
# JWTの中身: {"tenant_id": "tenant-123", ...}
この方式では、バックエンドはJWTからテナントIDを取得する。JWTは署名で保護されているため、ユーザーが改ざんできない。「どのテナントのデータを返すか」はJWTが決定し、URLは関与しない。URLパラメータとユーザー権限を照合する追加の検証が不要になるため、バグの入り込む余地が減る。
このアプローチはMicrosoft Azure Architecture Centerでも推奨されている。
ログアウト処理
OAuth2のログアウトは複雑だ。以下を考慮する必要がある:
- フロントエンドのCookie削除
- HydraのOAuth2セッション無効化
- バックエンドのセッション無効化(該当する場合)
// app/api/auth/logout/route.ts export async function GET(request: Request) { const accessToken = request.cookies.get("auth_token")?.value; if (accessToken) { // 1. Hydraでトークンを無効化 await fetch(`${process.env.HYDRA_PUBLIC_URL}/oauth2/revoke`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Authorization: `Basic ${Buffer.from( `${process.env.OAUTH_CLIENT_ID}:${process.env.OAUTH_CLIENT_SECRET}` ).toString("base64")}`, }, body: new URLSearchParams({ token: accessToken, }), }); // 2. Hydraのログインセッションも削除 // (IDトークンからsubjectを取得して削除) } // 3. Cookieを削除してリダイレクト const response = NextResponse.redirect(new URL("/", request.url)); response.cookies.delete("auth_token"); response.cookies.delete("refresh_token"); return response; }
RP-Initiated Logout
OpenID ConnectにはRP-Initiated Logout 1.0という仕様がある。この仕様では、Relying Party(クライアントアプリケーション)からOpenID Providerに対してログアウトを要求する方法が定義されている。Hydraはこれをサポートしている。
// Hydraのログアウトエンドポイントを使う方法 const logoutUrl = new URL(`${process.env.HYDRA_PUBLIC_URL}/oauth2/sessions/logout`); logoutUrl.searchParams.set("id_token_hint", idToken); logoutUrl.searchParams.set("post_logout_redirect_uri", `${process.env.NEXT_PUBLIC_URL}/`); return NextResponse.redirect(logoutUrl);
この方法だと、Hydraがログアウト処理を統括し、Login Providerの/logoutエンドポイントにリダイレクトしてくれる。
トラブルシューティング:実際に遭遇した問題
問題1:Cookie名の不一致
症状:ログイン後、ダッシュボードでAPIデータが取得できない
原因:コールバックで設定するCookie名と、各ページで読み取るCookie名が異なっていた
// コールバック response.cookies.set("auth_token", ...); // ダッシュボード(間違い) .find((row) => row.startsWith("access_token=")) // 正しくは .find((row) => row.startsWith("auth_token="))
教訓:Cookie名は定数として一箇所で定義し、全体で共有する。
なぜこのミスが起きるのか。認証コードはコールバック処理から書き始め、ダッシュボードは後から書く。時間が空くと、最初に使った名前を忘れる。「書いた順番」と「読まれる順番」が異なるコードでは、定数化を最初に行うべきだ。
// lib/constants.ts export const AUTH_COOKIE_NAME = "auth_token"; export const REFRESH_COOKIE_NAME = "refresh_token";
問題2:APIパスの構造
症状:APIリクエストが404を返す
// 間違い fetch("/api/v1/incidents") // 404 // 正しい fetch("/api/v1/tenant/incidents") // 200
教訓:APIのベースパスはAPIクライアントクラスで管理する
class ApiClient { private baseUrl = process.env.NEXT_PUBLIC_API_URL; private tenantPath = "/api/v1/tenant"; async getIncidents() { return this.request(`${this.tenantPath}/incidents`); } }
問題3:トークン期限切れ
症状:しばらく操作しないとAPI呼び出しが失敗する
原因:アクセストークンの有効期限(15分)が切れていた
対策:リフレッシュトークンを使った自動更新
async request<T>(path: string, options?: RequestInit): Promise<T> { const response = await fetch(`${this.baseUrl}${path}`, { ...options, headers: { ...options?.headers, Authorization: `Bearer ${this.token}`, }, }); if (response.status === 401) { // トークンをリフレッシュして再試行 await this.refreshToken(); return this.request(path, options); } return response.json(); }
問題4:HydraのセッションとProviderのセッション
症状:ログアウト後、再度ログインしようとすると認証画面をスキップしてしまう
原因:Hydraのログインセッションが残っていた
Ory Hydraのドキュメントによると、HydraはLogin Providerでの認証成功を記憶している。skipフラグが立っている場合、ログイン画面をスキップする。これはSSO(シングルサインオン)の正しい動作だが、完全なログアウトを実装する際には注意が必要だ。
// Login Provider側 if login_request.skip { // 既にセッションがあるのでスキップ // Note: skip時はcontextが既に設定されているためNoneで良い let completed = hydra.accept_login(&challenge, &login_request.subject, false, None).await?; return Ok(Redirect::to(&completed.redirect_to)); }
完全なログアウトには、Hydraのセッションも削除する必要がある:
// ログアウト時にHydraのセッションも削除 await fetch( `${process.env.HYDRA_ADMIN_URL}/admin/oauth2/auth/sessions/login?subject=${userId}`, { method: "DELETE" } );
エラーハンドリングのパターン
バックエンドから返されるエラーは統一された形式になっている:
{ "error": "invalid_credentials", "error_description": "The provided credentials are invalid", "error_code": "AUTH_002" }
フロントエンドではこれを適切に処理する:
async request<T>(path: string, options?: RequestInit): Promise<T> { const response = await fetch(`${this.baseUrl}${path}`, options); if (!response.ok) { const error = await response.json().catch(() => ({ error: "unknown_error", error_description: "An unexpected error occurred", })); throw new ApiError(response.status, error); } return response.json(); } class ApiError extends Error { constructor( public status: number, public body: { error: string; error_description: string; error_code?: string } ) { super(body.error_description); } }
セキュリティチェックリスト
実装後に確認すべき項目。これは完璧なリストではない——セキュリティに完璧はない——が、最低限チェックすべきポイントをまとめた。
認証に関わるCookieの属性
- [ ] HttpOnly属性: XSSの緩和策。クライアントJSからアクセス不要なCookieには必ず設定
- [ ] SameSite属性:
LaxもしくはStrictに設定。CSRF対策の基本。Laxの場合、GETリクエストで更新処理を行っていないか確認 - [ ] Secure属性: HTTPS通信でのみCookieが送られるように。本番環境では必須
- [ ] Domain属性: サブドメインへのCookie送信範囲を理解しているか。
example.comのCookieがjobs.example.comにも送られる設定だと、他サブドメインの脆弱性がリスクになる - [ ] Cookie Prefix: Cookie名を
__Host-で始めると、Domain属性が空でないCookieの指定を無視してくれる(参考: Cookie Prefixのバイパス)
レスポンスヘッダ
- [ ] Strict-Transport-Security(HSTS): ブラウザにHTTPS接続を強制。
max-age=31536000; includeSubDomains; preload - [ ] X-Frame-Options:
DENYもしくはSAMEORIGINでクリックジャッキング対策。CSPのframe-ancestorsも検討 - [ ] X-Content-Type-Options:
nosniffを指定。MIMEタイプスニッフィング攻撃を防ぐ
認証フロー
- [ ] stateパラメータでCSRF対策している
- [ ] リフレッシュトークンはhttpOnlyで保護している
- [ ] アクセストークンの有効期限は短く設定している(15分推奨)
- [ ] ログアウト時にトークンを無効化している
- [ ] メールアドレスの列挙ができないこと: ログイン画面やパスワード再設定画面で「このメールアドレスは登録されていません」のようなエラーを出さない
- [ ] JWTの署名を検証している(バックエンド側)
- [ ] テナント分離がJWTベースで行われている
- [ ] 退会/メールアドレス変更などの重要操作で直前のログインを必須にしている: XSSやセッションハイジャック発生時の緩和策
その他
- [ ] サードパーティCookieに依存していないこと(Chrome廃止予定)
- [ ] iOS SafariのITPによりローカルストレージやJSから保存したCookieは7日で消える可能性がある(未使用時)
まとめ
Next.jsでOry Hydra認証を実装する際の要点:
- OAuth2フローの理解:認可コードフローの各ステップでフロントエンドが何をすべきか把握する
- ID Token署名検証:JWKSを使って署名を検証し、issuer/audienceを確認する
- Cookie管理:httpOnly, Secure, SameSiteの設定を用途に応じて選択する
- マルチテナント:JWTにテナント情報を含め、APIはトークンからテナントを識別する
- エラーハンドリング:OAuth2仕様に沿ったエラー形式を統一的に処理する
- ログアウト:Hydraのセッションとフロントエンドのセッション両方を考慮する
認証は「動いた」で終わりではない。Cookie名の不一致のような単純なミスから、セッション管理の複雑さまで、実際に動かして初めて見つかる問題が多い。
結局のところ、OAuth2は「誰かが決めた仕様に従う」ゲームだ。RFCを読み、OWASPを読み、Hydraのドキュメントを読む。自分で発明する余地は少ない。でも、それでいい。認証のような重要な仕組みを自己流で作るのは、傲慢だと思う。セキュリティの歴史は「賢い人が作ったものを、もっと賢い攻撃者が破る」の繰り返しだ。OAuth 1.0のセッション固定攻撃、JWTのalg=none脆弱性——仕様を作った人たちでさえ、穴を見落とす。自分がその歴史に新たな失敗を加える必要はない。先人の知恵に乗っかり、その上で自分のシステムに合った判断をする。それが現実的なアプローチだ。
前回のバックエンド実装でユーザー列挙攻撃を防ぐテストを書いたように、フロントエンドでも手動でのE2Eテストが重要だ。ログイン→操作→ログアウト→再ログイン。このサイクルを何度も試して、エッジケースを潰していく。
次回は、Playwright MCPを使ったE2Eテストの自動化と、テストで発見したバグについて解説する。
このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。
おわりに
今日は社内で学生向けワークショップを担当した。終わった後、若い参加者が話しかけてきた。「ブログ読んでます」と言われた。
嬉しかった。嬉しかったが、すぐに釘を刺した。「あまり憧れないでくださいね」と。
憧れられるのがあまり得意ではない。偶像として崇拝されるのが苦手だし、偶像として振る舞って相手に応えるのも苦手だ。
それに、ブログで良いこと言っている人に若いうちから憧れすぎるのは良くない。自分がそうだったのでよく分かる。10代の頃、文章が上手くて考え方が明快な技術ブロガーを見つけて、「この人みたいになりたい」と思った。記事を読み漁った。でも、その人が実際にどんなコードを書いているかは知らなかった。ブログは編集された「ハイライト」にすぎない。裏側の泥臭い試行錯誤、失敗、妥協は見えない。数年後にそれを知ったとき、ちょっとがっかりした。がっかりした自分にもがっかりした。
若い技術者なら、現場に居る良い技術者に憧れてほしい。ブログを書く人ではなく。GitHubのコミット履歴を見てほしい。PRのレビューコメントを見てほしい。本番障害のポストモーテムを読んでほしい。そこに本当の技術者がいる。ブログの「正解」ではなく、コードの「試行錯誤」に学んでほしい。
正直に言えば、フロントエンドでの認証実装は想像以上に複雑だった。3年前の自分に言いたい。「Next.jsで認証?OAuth2知ってるし、すぐできるでしょ」と思っていた過去の自分に。そうじゃない。Cookieの属性一つでセキュリティモデルが変わる。ID Tokenの署名検証を省略した瞬間、認証システムの意味がなくなる。
OAuth2のフローは理解していたつもりだった。RFCも読んだ。でも、実際にNext.jsでCookieを扱い、ID Tokenの署名を検証し、マルチテナントのテナント分離を実装すると、「知っている」と「動かせる」の間には大きな溝があることを思い知らされた。RFCには「stateパラメータでCSRF対策」と書いてある。でも、実際にコードを書くと「stateはどこに保存する?」「検証はいつやる?」「不一致の場合のエラーメッセージは?」という判断が次々と必要になる。仕様書は「何をすべきか」は教えてくれるが、「どう実装すべきか」は教えてくれない。その溝を埋めるのは、結局、自分で書いて動かす経験しかない。
特にhttpOnlyの判断には時間を使った。OWASPのベストプラクティスを読み、Auth0のガイドを読み、それでも「これで正しいのか」という不安は消えない。セキュリティに100%の正解はない。トレードオフを理解し、判断し、記録する。それしかできることはない。
この記事を書いている人間も、悩みながら書いている。ブログに書かれている「正解」は、試行錯誤の結果を事後的に整理したものにすぎない。過程で何度も間違えている。それを知った上で、参考にしてもらえれば。
なんか総じてとても疲れた。でも、まあ、悪くない一日だった。
参考資料
Ory Hydra
OAuth2/OIDC仕様
- RFC 6749 - OAuth 2.0
- RFC 9700 - OAuth 2.0 Security Best Current Practice
- OpenID Connect Core 1.0
- RP-Initiated Logout 1.0
セキュリティガイドライン
- OWASP OAuth2 Cheat Sheet
- OWASP Session Management Cheat Sheet
- Auth0 Token Storage
- Curity JWT Best Practices
Cookie属性
- CookieのDomain属性は指定しないが一番安全 - 徳丸氏によるCookie Domain属性の解説
- Cookie Prefixのバイパス -
__Host-プレフィックスの重要性 - MDN: Set-Cookie - Cookie属性の公式リファレンス
- サードパーティCookieの廃止に向けた準備 - Chrome対応ガイド