CSRF対策の実装ガイド|SameSite・Synchronizer Token・Laravel/Expressコード
- CSRF対策の実務定石は、SameSite属性でブラウザの自動Cookie送信を抑え、状態変更リクエストにはSynchronizer Tokenでサーバー側検証を重ね、return pathまで許可リスト管理することです。
- Laravelは標準ミドルウェア、Expressは
csrf-csrf等で同等の防御を構築できます。 - Double Submit Cookieの比較と選び方はCSRFトークン防御|Synchronizer TokenとDouble Submit Cookieの比較を参照してください。
CSRF(クロスサイトリクエストフォージェリ)とは
CSRF(Cross-Site Request Forgery)は、ログイン済みユーザーのブラウザを操作し、本人の意図しないリクエストを送らせる攻撃です。攻撃者は被害者のCookieの中身を読む必要はありません。ブラウザが同一サイトへのリクエストに自動でCookieを付与する仕組みそのものを悪用します。
典型例は次のとおりです。
- ネット銀行にログインした状態で、別タブの悪意あるページが送金フォームを自動POSTする
- SNSの「いいね」やメールアドレス変更APIを、攻撃者サイトの
<form>から送らせる - 管理画面のユーザー削除エンドポイントを、画像タグやiframe経由のリクエストで叩かせる
Same-Origin Policyにより攻撃者サイトのJavaScriptは銀行のCookieを読めません。しかしCookie付きのPOSTリクエスト自体は、HTMLフォームや特定条件下のfetchで成立します。だから「Cookieだけで認証している状態変更API」は、CSRFの標的になりやすいのです。
攻撃シナリオ:銀行送金を悪用する手口
ここでは、最も理解しやすいネットバンキングの送金を題材に、攻撃がどう進むかを段階的に追います。架空のbank.exampleを想定しています。
被害者が bank.example にログインし、セッションCookie(SESSION=abc123)がブラウザに保存される
被害者がメールやSNSで受け取った「懸賞当選!」リンクをクリックし、attacker.example を開く
attacker.example のHTMLに隠しフォーム <form action="https://bank.example/transfer" method="POST"> が仕込まれている
ページ読み込みと同時に JavaScript が form.submit() を実行する(または <img onerror> 相当のトリガー)
ブラウザは bank.example へのPOSTに SESSION Cookie を自動付与する(SameSite=Lax 以前のブラウザ、または対策未実装の場合)
サーバーがCSRFトークンを検証していなければ、被害者名義で attacker の口座へ送金が実行される
攻撃者が仕込むHTMLのイメージは次のとおりです。
<!-- attacker.example のページ内(攻撃者が制御) -->
<html>
<body>
<p>読み込み中...</p>
<form id="evil" method="POST" action="https://bank.example/transfer">
<input type="hidden" name="to_account" value="attacker-999" />
<input type="hidden" name="amount" value="500000" />
<input type="hidden" name="memo" value="gift" />
</form>
<script>document.getElementById('evil').submit();</script>
</body>
</html>
被害者には送金画面すら見えないまま、バックグラウンドでPOSTが飛びます。防御側が取るべき対策は明確で、攻撃者サイトからは再現できない秘密(CSRFトークン)をフォームに含め、サーバーで検証することです。
CSRFが成立する3つの条件
攻撃が成功するには、次の条件が揃います。設計レビューではこの3点を必ず確認してください。
| 条件 | 説明 | 対策の方向性 |
|---|---|---|
| Cookie認証 | セッションがCookieで維持され、ブラウザが自動送信する | SameSite属性、トークン検証 |
| サーバー側検証の欠如 | Origin/Referer/CSRFトークンを見ていない | ミドルウェアで統一検証 |
| 状態変更の受け入れ | POST以外(GET等)でも副作用を起こす | 安全なHTTPメソッド設計 |
「URLを踏むだけで削除・送金」はCSRF以前の設計欠陥です。状態変更はPOST/PUT/PATCH/DELETEに限定し、GETは読み取り専用に徹底してください。RESTful APIでもGET /users/1/deleteのようなエンドポイントは禁止です。
Bearer Token(Authorizationヘッダー)だけで認証するAPIは、素朴なHTMLフォームからはヘッダーを付けられないためCSRFのリスクは低くなります。ただしCookie認証とBearer Tokenを混在させる構成(同一オリジンのSPA + Cookie API)では、再びCSRFの対象になります。
SameSite属性:ブラウザが担う第一防御線
CookieのSameSite属性は、クロスサイトリクエストへのCookie付与をブラウザが制御します。サーバー側の改修なしに効くため、CSRF対策の第一層として広く使われます。
| 値 | 挙動 | 向いているケース |
|---|---|---|
| Strict | クロスサイトからのリクエストにCookieを付けない | 管理画面・高セキュリティ領域 |
| Lax | トップレベルGET遷移には付くが、クロスサイトPOSTには付かない | 一般Webアプリのデフォルト候補 |
| None | クロスサイトでも送信(Secure必須) | iframe埋め込み・OAuth・決済連携 |
Set-Cookie: SESSION=abc123; Path=/; HttpOnly; Secure; SameSite=Lax
Set-Cookie: remember_token=xyz; Path=/; HttpOnly; Secure; SameSite=Strict
Set-Cookie: embed_session=def; Path=/; Secure; SameSite=None
SameSite=Laxは2020年以降の主要ブラウザでデフォルト相当の挙動になり、クロスサイトPOST型CSRFの多くを防ぎます。ただし次のケースでは単独では不十分です。
- 古いUser-AgentやレガシーWebView
- 意図的に
SameSite=Noneにした第三者Cookie連携 - 同一サイト内のサブドメイン攻撃(
evil.example.com→bank.example.com) - トップレベルGET遷移で副作用がある設計ミス
SameSiteは補助線と捉え、必ずサーバー側トークン検証を重ねてください。
Synchronizer Token Pattern:HTMLフォームでの実装
Synchronizer Token(同期トークン)は、サーバーがセッションごとに暗号学的乱数のCSRFトークンを発行し、HTMLフォームのhiddenフィールドやmetaタグでフロントに渡す方式です。状態変更リクエストでは、セッションに保存したトークンとリクエスト内のトークンが一致するかをサーバーが検証します。
トークン配布から検証までの流れ
ユーザーがログインすると、サーバーがセッションIDとCSRFトークン(32バイト以上の乱数)を生成する
HTMLレンダリング時に hidden フィールド <input type="hidden" name="_token" value="..."> にトークンを埋め込む
状態変更リクエスト(POST等)で _token パラメータまたは X-CSRF-TOKEN ヘッダーを受け取る
セッション保存値と一致するか検証し、不一致なら 403 Forbidden を返す
ログアウト・セッション再生成時にトークンもローテーションする
送金フォームのHTML例
<!-- bank.example/transfer (サーバーがレンダリング) -->
<form method="POST" action="/transfer" id="transfer-form">
<input type="hidden" name="_token" value="f9a8b7c6d5e4..." />
<input type="hidden" name="return_path" value="/dashboard" />
<label>振込先口座
<input type="text" name="to_account" required />
</label>
<label>金額(円)
<input type="number" name="amount" min="1" required />
</label>
<label>メモ
<input type="text" name="memo" maxlength="100" />
</label>
<button type="submit">送金する</button>
</form>
攻撃者サイトはbank.exampleのHTMLを読めないため、正しい_token値をhiddenフィールドにセットできません。これがSynchronizer Tokenの核心です。
SPA・fetch向け:metaタグとヘッダー
<meta name="csrf-token" content="f9a8b7c6d5e4..." />
const token = document.querySelector('meta[name="csrf-token"]').content;
await fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': token,
},
credentials: 'include',
body: JSON.stringify({
to_account: '1234567',
amount: 10000,
}),
});
X-CSRF-TOKENのようなカスタムヘッダーは、素朴なHTMLフォームPOSTでは送れません(CORSプリフライトが絡む)。API/SPA構成ではヘッダー送信を必須にすると、防御が一段強くなります。
LaravelとExpressでのCSRFミドルウェア
フレームワークの標準機能を使うと、検証漏れを防ぎやすくなります。ここではSynchronizer Token方式の実装例を示します。
Laravel:VerifyCsrfToken
LaravelはwebミドルウェアグループにCSRF検証が組み込まれています。Bladeテンプレートでは@csrfディレクティブがhiddenフィールドを自動生成します。
{{-- resources/views/transfer.blade.php --}}
<form method="POST" action="{{ route('transfer.store') }}">
@csrf
<input type="hidden" name="return_path" value="/dashboard" />
<input type="text" name="to_account" required />
<input type="number" name="amount" required />
<button type="submit">送金</button>
</form>
<?php
// app/Http/Middleware/VerifyCsrfToken.php
namespace App\Http\Middleware;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
class VerifyCsrfToken extends Middleware
{
/**
* CSRF検証を除外するURI(Webhook等は慎重に)
*/
protected $except = [
// 'stripe/webhook',
];
}
<?php
// routes/web.php
use App\Http\Controllers\TransferController;
Route::middleware(['web', 'auth'])->group(function () {
Route::get('/transfer', [TransferController::class, 'create']);
Route::post('/transfer', [TransferController::class, 'store']);
});
<?php
// app/Http/Controllers/TransferController.php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class TransferController extends Controller
{
public function store(Request $request)
{
// VerifyCsrfToken ミドルウェアが POST 前に _token を検証済み
$validated = $request->validate([
'to_account' => 'required|string',
'amount' => 'required|integer|min:1',
]);
// 送金処理...
return redirect($this->sanitizeReturnPath($request->input('return_path')));
}
private function sanitizeReturnPath(?string $raw): string
{
$allowed = ['/dashboard', '/accounts', '/history'];
if (!$raw || !str_starts_with($raw, '/') || str_starts_with($raw, '//')) {
return '/dashboard';
}
$path = explode('?', $raw, 2)[0];
return in_array($path, $allowed, true) ? $path : '/dashboard';
}
}
Axiosを使うSPAでは、Laravelが返すXSRF-TOKEN Cookieを読み取り、X-XSRF-TOKENヘッダーに載せる設定が一般的です(Double Submit Cookieの詳細は別記事で解説)。
Express:express-session + csrf-csrf
Expressの旧csurfパッケージは非推奨です。csrf-csrfライブラリでSynchronizer Token方式を構築する例を示します。
// server.js
import express from 'express';
import session from 'express-session';
import cookieParser from 'cookie-parser';
import { doubleCsrf } from 'csrf-csrf';
const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(session({
name: 'SESSION',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 1000, // 1時間
},
}));
const {
generateToken,
doubleCsrfProtection,
} = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET,
cookieName: '__Host-csrf',
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
},
getSessionIdentifier: (req) => req.session.id,
});
// トークン配布用エンドポイント(SPA向け)
app.get('/api/csrf-token', (req, res) => {
const token = generateToken(req, res);
res.json({ csrfToken: token });
});
// 状態変更ルートにCSRFミドルウェアを適用
app.post('/transfer', doubleCsrfProtection, (req, res) => {
const { to_account, amount, return_path } = req.body;
if (!to_account || !amount) {
return res.status(400).json({ error: 'Invalid input' });
}
// 送金処理...
const safePath = sanitizeReturnPath(return_path);
return res.redirect(302, safePath);
});
function sanitizeReturnPath(raw) {
const ALLOWED = new Set(['/dashboard', '/accounts', '/history']);
if (typeof raw !== 'string' || !raw.startsWith('/') || raw.startsWith('//')) {
return '/dashboard';
}
const path = raw.split('?')[0];
return ALLOWED.has(path) ? path : '/dashboard';
}
const PORT = process.env.PORT || 3000;
app.listen(PORT);
// フロント(fetch)— カスタムヘッダーでトークン送信
async function postTransfer(payload) {
const { csrfToken } = await fetch('/api/csrf-token', {
credentials: 'include',
}).then((r) => r.json());
return fetch('/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
credentials: 'include',
body: JSON.stringify(payload),
});
}
検証失敗時は403を返し、副作用のある処理(送金・削除・権限変更)を実行しないことが重要です。エラーメッセージにトークン値そのものを含めないよう注意してください。
Double Submit Cookieとの使い分け
Synchronizer Tokenはセッションストアにトークンを保存するため、サーバー側の状態管理が必要です。対照的にDouble Submit Cookieは、Cookieとリクエストヘッダーの値を照合するだけでステートレスに運用できます。
| 観点 | Synchronizer Token | Double Submit Cookie |
|---|---|---|
| トークン保管 | サーバー・セッション | Cookieのみ |
| HttpOnly | セッションCookieはHttpOnly可 | CSRF用CookieはHttpOnly不可 |
| 向いている構成 | SSR・Laravel/Rails | SPA + APIゲートウェイ |
方式の詳細比較・サブドメインリスク・XSSとの関係は、CSRFトークン防御|Synchronizer TokenとDouble Submit Cookieの比較で詳しく解説しています。本記事では実装の主役をSynchronizer Tokenとし、Double Submitはステートレス要件がある場合の代替として位置づけます。
return path(リダイレクト先)の検証
ログイン後やフォーム送信後に?return_path=/settingsのように元のページへ戻すパターンはUX向上に効きます。しかし検証を怠るとオープンリダイレクト脆弱性になり、フィッシング攻撃の踏み台にもなります。return pathはCSRFトークンとセットで、サーバー側で厳格に制御してください。
return pathは相対パス(/dashboard)に限定するか、許可ドメインのホワイトリストと照合する
hiddenフィールドのreturn pathをそのまま信じず、トークン発行時にセッションへ保存してPOST時に照合する
外部URL(https://evil.example)やプロトコル相対URL(//evil.example)へのリダイレクトは拒否する
リダイレクト前にCSRF検証を完了させ、PRG(Post-Redirect-Get)で二重送信も防ぐ
許可リスト方式(Python / 汎用)
# return_path.py
from urllib.parse import urlparse
ALLOWED_RETURN_PATHS = frozenset({
"/dashboard",
"/settings",
"/accounts",
"/transfer/history",
})
DEFAULT_RETURN_PATH = "/dashboard"
def sanitize_return_path(raw: str | None) -> str:
if not raw:
return DEFAULT_RETURN_PATH
# 相対パスのみ許可(// で始まるプロトコル相対URLを拒否)
if not raw.startswith("/") or raw.startswith("//"):
return DEFAULT_RETURN_PATH
# クエリ・フラグメントを除去してパス部分だけ比較
path = raw.split("?")[0].split("#")[0]
return path if path in ALLOWED_RETURN_PATHS else DEFAULT_RETURN_PATH
def sanitize_return_path_with_origin(raw: str | None, allowed_origin: str) -> str:
"""絶対URLを受け取る場合は同一オリジンのみ許可"""
if not raw:
return DEFAULT_RETURN_PATH
if raw.startswith("/") and not raw.startswith("//"):
return sanitize_return_path(raw)
parsed = urlparse(raw)
allowed = urlparse(allowed_origin)
if parsed.scheme in ("http", "https") and parsed.netloc == allowed.netloc:
internal = parsed.path or "/"
return sanitize_return_path(internal)
return DEFAULT_RETURN_PATH
セッション紐づけ方式(トークンとreturn pathをセットで検証)
// Express — フォーム表示時にセッションへ保存
app.get('/transfer', requireAuth, (req, res) => {
const returnPath = sanitizeReturnPath(req.query.return_path ?? '/dashboard');
req.session.pendingReturnPath = returnPath;
req.session.save(() => {
res.render('transfer', { returnPath, csrfToken: generateToken(req, res) });
});
});
// POST時 — hiddenフィールドではなくセッション値を信頼
app.post('/transfer', doubleCsrfProtection, requireAuth, (req, res) => {
const returnPath = sanitizeReturnPath(req.session.pendingReturnPath);
delete req.session.pendingReturnPath;
// 送金処理...
res.redirect(302, returnPath);
});
hiddenフィールドのreturn_pathはユーザーがDevToolsで書き換え可能です。サーバーが発行した値をセッションに保持し、POST時にセッション値を参照する設計のほうが改ざんに強いです。
Origin / Referer検証と多層防御
CSRFトークンに加え、OriginまたはRefererヘッダーの検証を併用すると防御層が増えます。ただしRefererはプライバシー設定やHTTPS→HTTP遷移で欠落することがあるため、トークン検証の代替にはなりません。
// Express — Origin検証ミドルウェア(補助的)
function verifyOrigin(req, res, next) {
const origin = req.headers.origin;
const allowed = process.env.APP_ORIGIN; // 例: https://bank.example
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
return next();
}
if (!origin || origin !== allowed) {
return res.status(403).json({ error: 'Invalid origin' });
}
next();
}
app.use(verifyOrigin);
| 防御層 | 役割と限界 |
|---|---|
| SameSite=Lax/Strict | クロスサイトPOSTへのCookie送信を抑制。ブラウザ依存の挙動差あり。単独では不十分な場面がある。 |
| Synchronizer Token | 攻撃者サイトから再現不能な秘密を要求。MVCフレームワークの定番。セッションストアが必要。 |
| Double Submit Cookie | ステートレス。CookieをJS-readableにする必要がありXSS耐性が課題。詳細は別記事参照。 |
| Origin/Referer検証 | 補助線。ヘッダー欠落・偽装の可能性があるためトークンの代わりにはならない。 |
| return path許可リスト | オープンリダイレクト防止。CSRFとは別軸だがフォーム改ざんとセットで実装。 |
実装チェックリストとテスト方法
状態変更エンドポイント(POST/PUT/PATCH/DELETE)すべてにCSRF検証ミドルウェアを適用する
Cookieに HttpOnly・Secure・SameSite=Lax(必要に応じStrict)を設定する
HTMLフォームに _token hiddenフィールド、SPAには meta タグまたは /api/csrf-token を用意する
fetch/axiosの共通インターセプタで X-CSRF-TOKEN ヘッダー付与を徹底する
return pathを許可リスト検証し、セッション紐づけで改ざんを防ぐ
ログイン・ログアウト・権限変更時にセッションIDとCSRFトークンをローテーションする
クロスオリジンの <form> と fetch(credentials:'include') で意図的に攻撃を再現し、403になることを確認する
手動テストの例
- 正規の送金フォームからPOST → 200/302で成功すること
_tokenを削除してPOST → 403になること- 別オリジンのHTMLから
<form action="https://bank.example/transfer">をPOST → 403(またはSameSiteによりCookie未送信で401)になること return_path=https://evil.exampleを仕込んでPOST → デフォルトパス(/dashboard)にリダイレクトされること
E2Eテスト(Playwright/Cypress)では、CSRFトークン欠落・不一致・クロスオリジンPOSTをCIに組み込むと、リグレッションを防ぎやすくなります。
LaravelのやExpressのルート除外でWebhookをCSRF検証から外す場合、署名検証(Stripe-Signature等)で代替認証を必ず行ってください。除外URIの放置は実務でよくある事故原因です。
関連記事
この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| XSS攻撃と防御の完全ガイド | XSS攻撃と防御の完全ガイド|反射型・蓄積型・DOM型の対策 |
| Content Security Policy (CSP) 設定ガイド | Content Security Policy (CSP) 設定ガイド|XSS・クリックジャッキング対策 |
| CSP Report-Only 段階導入ガイド | CSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する |
| セキュリティヘッダー一覧と設定ガイド | セキュリティヘッダー一覧と設定ガイド|CSP・HSTS・Referrer-Policy・Permissions-Policy |
| セキュアなCookie設定ガイド | セキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策 |
| CORSエラーの対処法 | CORSエラーの対処法|「Access-Control-Allow-Origin」で解決する |
| CORSプリフライトリクエスト | CORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答 |
| ローカル開発でCORSエラーが出る原因と3つの回避策 | ローカル開発でCORSエラーが出る原因と3つの回避策 |
| OAuth2 PKCE SPA実装ガイド | OAuth2 PKCE SPA実装ガイド|Authorization Code + S256 + BFFパターン |
| Passkeys・WebAuthn実装の基礎 | Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server |
| JWTのデバッグ方法 | JWTのデバッグ方法|トークンの中身を安全に確認する |
| JWTの中身を確認する方法 | JWTの中身を確認する方法|改ざんが検知される仕組み |
まとめ
CSRFは「ブラウザがCookieを自動送信する」ことが根本原因です。銀行送金のシナリオが示すとおり、対策なしではユーザーが攻撃ページを開いただけで被害が起き得ます。
実務では次の3層をセットにしてください。
- SameSite属性 — ブラウザがクロスサイトPOSTへのCookie送信を抑える第一防御
- Synchronizer Token — Laravelの
VerifyCsrfTokenやExpressのcsrf-csrfで状態変更リクエストを検証 - return path許可リスト — リダイレクト先改ざんとオープンリダイレクトを防ぐ
Double Submit Cookieが必要な構成(ステートレスAPI、水平スケール優先)はCSRFトークン防御|Synchronizer TokenとDouble Submit Cookieの比較を参照し、XSS対策(CSP・出力エスケープ)と合わせた多層防御を徹底してください。CSRFトークンは「Cookie付きPOSTを止める」ものであり、XSSが既にある環境ではトークン自体が窃取される——その点も忘れずに設計を組み立てましょう。