CSRF対策の実装ガイド|SameSite・Synchronizer Token・Laravel/Expressコード

セキュリティ CSRF Cookie Web開発 バックエンド
結論
  • 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を想定しています。

1

被害者が bank.example にログインし、セッションCookie(SESSION=abc123)がブラウザに保存される

2

被害者がメールやSNSで受け取った「懸賞当選!」リンクをクリックし、attacker.example を開く

3

attacker.example のHTMLに隠しフォーム <form action="https://bank.example/transfer" method="POST"> が仕込まれている

4

ページ読み込みと同時に JavaScript が form.submit() を実行する(または <img onerror> 相当のトリガー)

5

ブラウザは bank.example へのPOSTに SESSION Cookie を自動付与する(SameSite=Lax 以前のブラウザ、または対策未実装の場合)

6

サーバーが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メソッド設計
GETに副作用を載せない

「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.combank.example.com
  • トップレベルGET遷移で副作用がある設計ミス

SameSiteは補助線と捉え、必ずサーバー側トークン検証を重ねてください。

Synchronizer Token Pattern:HTMLフォームでの実装

Synchronizer Token(同期トークン)は、サーバーがセッションごとに暗号学的乱数のCSRFトークンを発行し、HTMLフォームのhiddenフィールドやmetaタグでフロントに渡す方式です。状態変更リクエストでは、セッションに保存したトークンとリクエスト内のトークンが一致するかをサーバーが検証します。

トークン配布から検証までの流れ

1

ユーザーがログインすると、サーバーがセッションIDとCSRFトークン(32バイト以上の乱数)を生成する

2

HTMLレンダリング時に hidden フィールド <input type="hidden" name="_token" value="..."> にトークンを埋め込む

3

状態変更リクエスト(POST等)で _token パラメータまたは X-CSRF-TOKEN ヘッダーを受け取る

4

セッション保存値と一致するか検証し、不一致なら 403 Forbidden を返す

5

ログアウト・セッション再生成時にトークンもローテーションする

送金フォームの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 TokenDouble Submit Cookie
トークン保管サーバー・セッションCookieのみ
HttpOnlyセッションCookieはHttpOnly可CSRF用CookieはHttpOnly不可
向いている構成SSR・Laravel/RailsSPA + APIゲートウェイ

方式の詳細比較・サブドメインリスク・XSSとの関係は、CSRFトークン防御|Synchronizer TokenとDouble Submit Cookieの比較で詳しく解説しています。本記事では実装の主役をSynchronizer Tokenとし、Double Submitはステートレス要件がある場合の代替として位置づけます。

return path(リダイレクト先)の検証

ログイン後やフォーム送信後に?return_path=/settingsのように元のページへ戻すパターンはUX向上に効きます。しかし検証を怠るとオープンリダイレクト脆弱性になり、フィッシング攻撃の踏み台にもなります。return pathはCSRFトークンとセットで、サーバー側で厳格に制御してください。

1

return pathは相対パス(/dashboard)に限定するか、許可ドメインのホワイトリストと照合する

2

hiddenフィールドのreturn pathをそのまま信じず、トークン発行時にセッションへ保存してPOST時に照合する

3

外部URL(https://evil.example)やプロトコル相対URL(//evil.example)へのリダイレクトは拒否する

4

リダイレクト前に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とは別軸だがフォーム改ざんとセットで実装。

実装チェックリストとテスト方法

1

状態変更エンドポイント(POST/PUT/PATCH/DELETE)すべてにCSRF検証ミドルウェアを適用する

2

Cookieに HttpOnly・Secure・SameSite=Lax(必要に応じStrict)を設定する

3

HTMLフォームに _token hiddenフィールド、SPAには meta タグまたは /api/csrf-token を用意する

4

fetch/axiosの共通インターセプタで X-CSRF-TOKEN ヘッダー付与を徹底する

5

return pathを許可リスト検証し、セッション紐づけで改ざんを防ぐ

6

ログイン・ログアウト・権限変更時にセッションIDとCSRFトークンをローテーションする

7

クロスオリジンの <form>fetch(credentials:'include') で意図的に攻撃を再現し、403になることを確認する

手動テストの例

  1. 正規の送金フォームからPOST → 200/302で成功すること
  2. _tokenを削除してPOST → 403になること
  3. 別オリジンのHTMLから<form action="https://bank.example/transfer">をPOST → 403(またはSameSiteによりCookie未送信で401)になること
  4. return_path=https://evil.exampleを仕込んでPOST → デフォルトパス(/dashboard)にリダイレクトされること

E2Eテスト(Playwright/Cypress)では、CSRFトークン欠落・不一致・クロスオリジンPOSTをCIに組み込むと、リグレッションを防ぎやすくなります。

Webhookは除外を慎重に

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層をセットにしてください。

  1. SameSite属性 — ブラウザがクロスサイトPOSTへのCookie送信を抑える第一防御
  2. Synchronizer Token — LaravelのVerifyCsrfTokenやExpressのcsrf-csrfで状態変更リクエストを検証
  3. return path許可リスト — リダイレクト先改ざんとオープンリダイレクトを防ぐ

Double Submit Cookieが必要な構成(ステートレスAPI、水平スケール優先)はCSRFトークン防御|Synchronizer TokenとDouble Submit Cookieの比較を参照し、XSS対策(CSP・出力エスケープ)と合わせた多層防御を徹底してください。CSRFトークンは「Cookie付きPOSTを止める」ものであり、XSSが既にある環境ではトークン自体が窃取される——その点も忘れずに設計を組み立てましょう。