CSRF(クロスサイトリクエストフォージェリ)対策とは、ログイン中の利用者のブラウザが、別のサイトから送らされたリクエストを「本人の操作」として処理しないようにすることです。 いまの基本形は、SameSite属性でCookieが送られる場面を絞り、サーバー側で Sec-Fetch-Site や Origin ヘッダーを見てリクエストの出どころを確かめ、それらが使えない場合にCSRFトークンで補うという多層防御です。

この記事では、Node.jsで2つのオリジンを立て、Playwright(Chromium 141)で「SameSite=Strict/Lax/None/指定なし」のCookieが、フォームPOST・リンク・fetch・画像読み込みのそれぞれで送られるかを実測しました。そのうえで、Sec-Fetch-Site → Origin → CSRFトークンの順に判定するミドルウェアを実装し、ブラウザとcurlから試したログを掲載します。最後に、Spring Security・Rails・Django・Laravel・Next.js・Goの標準機能を公式ドキュメントに沿って表にまとめました。

CSRF対策の早見表

対策 何を防ぐか 注意点
SameSite=Lax / Strict クロスサイトからのPOSTなどでCookieが送られない 同じサイトの別オリジン(別ポート・別サブドメイン)からは防げない
Sec-Fetch-Site の検証 ブラウザが付ける出どころ情報で拒否 送らない古い環境・非ブラウザ向けに次の手段が必要
Origin の検証 許可リストにないオリジンからの書き込みを拒否 GETには付かないことが多い
CSRFトークン ページに埋め込んだ秘密値がないリクエストを拒否 フォームやJSで確実に送る仕組みが必要
状態を変える処理はPOST等に限定 リンクや画像からの書き込みを防ぐ GETで削除・更新しない設計が前提

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CSRF対策とSameSite|Cookie送信をChromeで実測し多層防御を実装」

CSRFの仕組みを30秒で

sequenceDiagram
    participant U as 利用者のブラウザ
    participant A as 本来のサイト app
    participant E as 別サイトのページ
    U->>A: ログイン(セッションCookieを受け取る)
    U->>E: 別サイトを開く
    E-->>U: 自動送信フォームを返す
    U->>A: POST /email(Cookieが自動で付く)
    A->>A: Cookieが正しいので本人の操作と判断

Cookieはブラウザが自動で付けるため、サーバーは「本人が押したのか、別サイトに送らされたのか」をCookieだけでは区別できません。 そこで、①Cookieを付けない(SameSite)、②出どころを確かめる(Sec-Fetch-Site・Origin)、③本人のページにしかない値を確かめる(トークン)、の3方向から対策します。

最初に押さえる:「site」と「origin」は違う

SameSiteの「サイト」と、CORSなどの「オリジン」は範囲が違います。ここを取り違えると、SameSiteで守れていると思い込んでしまいます。

用語 何で決まるか 例
origin(オリジン) スキーム+ホスト+ポート http://localhost:7050 と http://localhost:7051 は別オリジン
site(サイト) スキーム+登録可能ドメイン(ポートは見ない) https://a.example.com と https://b.example.com は同じサイト

今回の検証環境では、次の関係になります(リクエスト時の Sec-Fetch-Site の実測値つき)。

送信元ページ app(http://localhost:7050)から見た関係 実測した Sec-Fetch-Site
http://localhost:7050 同じオリジン same-origin
http://localhost:7051 別オリジンだが同じサイト(ポート違い) same-site
http://127.0.0.1:7051 別サイト(ホスト名が違う) cross-site
http://evil.test:7051(127.0.0.1に向けた架空の名前) 別サイト cross-site

localhost と 127.0.0.1 は同じマシンを指していても、ブラウザにとっては別のサイトです。 ローカルで検証するときは、どちらのホスト名で開いたかで結果が変わる点に注意してください。

実測:SameSiteごとにCookieは送られるか

検証の条件

項目 内容
ブラウザ Chromium 141.0.7390.37(Playwright 1.56.1、ヘッドレス、既定設定)
app Node.js 22 の http サーバー、http://localhost:7050
Cookie /login で4つ発行(下記)。すべて Path=/; HttpOnly
送信方法 フォームPOST(form.submit())、リンクGET(クリック)、fetch POST/GET(mode: 'no-cors', credentials: 'include')、<img> のGET
Set-Cookie: c_strict=1; Path=/; HttpOnly; SameSite=Strict
Set-Cookie: c_lax=1; Path=/; HttpOnly; SameSite=Lax
Set-Cookie: c_none=1; Path=/; HttpOnly; SameSite=None; Secure
Set-Cookie: c_default=1; Path=/; HttpOnly

c_none は Secure 付きですが、Chromiumは http://localhost を安全な場所として扱うため、HTTPでも保存されました(MDNにも「localhostが設定する場合はhttpsの要件が無視される」と書かれています)。

結果(○=Cookieが届いた、×=届かなかった)

同じオリジン(localhost:7050)と、同じサイトの別ポート(localhost:7051)からは、すべての方法で4つのCookieがすべて届きました。

送信元 送信方法 Strict Lax None 指定なし
同じオリジン 5種類すべて ○ ○ ○ ○
同じサイト・別ポート 5種類すべて ○ ○ ○ ○

別サイト(127.0.0.1:7051 と evil.test:7051)からは、次のとおりでした。 2つのホスト名で結果は同じでした。

送信方法 Strict Lax None 指定なし Origin ヘッダー
フォームPOST × × ○ ○(発行から2分以内) 付く
フォームPOST(発行から130秒後) × × ○ × 付く
リンクGET(トップレベル遷移) × ○ ○ ○ 付かない
fetch POST × × ○ × 付く
fetch GET × × ○ × 付かない
<img> GET × × ○ × 付かない

実測からわかること

  • SameSite=Laxは、別サイトからのフォームPOSTとfetchではCookieを送りませんでした。 リンクで画面が切り替わるGETだけは送ります。だから「GETで状態を変えない」ことが前提になります。
  • SameSite属性を書かないCookieは、ChromiumではLax扱いになりますが、発行から2分以内はPOSTにも付きました。 130秒待つと付かなくなりました。MDNでも、既定のLaxには「設定から2分以内ならPOSTにも付ける」緩い版が使われると説明されています。属性は必ず明示するのが確実です。
  • Strictでも、同じサイトの別オリジンからのPOSTには付きました。 別ポートや別サブドメインに、利用者が書き込める領域や別チームのアプリがある場合は、SameSiteだけでは守れません。
  • ブラウザごとに既定値は異なります。Firefox・Safariでは属性なしをLaxとして扱わない場合があるため、MDNの互換表で最新の状況を確認してください。

保存されないCookieの実測

Set-Cookie http://localhost http://app.test(HTTPの別ホスト)
SameSite=None(Secureなし) 保存されない 保存されない
Secure(SameSite指定なし) 保存された 保存されない

SameSite=None には Secure が必須です。 HTTPSでない通常のホストでは Secure 付きCookieも保存されないため、Noneを試すときはHTTPSか localhost を使います。

実装:Sec-Fetch-Site → Origin → トークンの順に確かめる

SameSiteだけでは同じサイトの別オリジンを防げないことがわかったので、サーバー側で出どころを確かめます。Sec-Fetch-Site と Origin はブラウザが付けるヘッダーで、ページのJavaScriptからは書きかえられません。

flowchart TD
    R[リクエスト] --> M{GET / HEAD / OPTIONS?}
    M -- はい --> OK[通す]
    M -- いいえ --> S{Sec-Fetch-Site がある?}
    S -- same-origin / none --> OK
    S -- same-site / cross-site --> NG[403]
    S -- ない --> O{Origin がある?}
    O -- 許可リストにある --> OK
    O -- 許可リストにない --> NG
    O -- ない --> T{CSRFトークンが一致?}
    T -- はい --> OK
    T -- いいえ --> NG

Express 5 で書いたミドルウェアです(検証で使った設定画面・攻撃役ページ用のルートは省略しています)。

import express from 'express';
import crypto from 'node:crypto';

const app = express();
app.use(express.urlencoded({ extended: false }));

const ALLOWED_ORIGINS = new Set(['http://localhost:7053']);
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const sessions = new Map(); // sid -> { user, csrfToken }

function getSession(req) {
  const sid = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie ?? '')?.[1];
  return sid ? sessions.get(sid) : undefined;
}

function csrfGuard(req, res, next) {
  if (SAFE_METHODS.has(req.method)) return next();

  const sfs = req.headers['sec-fetch-site'];
  const origin = req.headers.origin;
  const tag = `${req.method} ${req.path} sfs=${sfs ?? '-'} origin=${origin ?? '-'}`;

  // 1) Fetch Metadata
  if (sfs) {
    if (sfs === 'same-origin' || sfs === 'none') {
      console.log(`[csrf] ALLOW (fetch-metadata)   ${tag}`);
      return next();
    }
    console.log(`[csrf] BLOCK (fetch-metadata)   ${tag}`);
    return res.status(403).send('cross-origin request blocked');
  }
  // 2) Origin ヘッダー
  if (origin) {
    if (ALLOWED_ORIGINS.has(origin)) {
      console.log(`[csrf] ALLOW (origin)           ${tag}`);
      return next();
    }
    console.log(`[csrf] BLOCK (origin)           ${tag}`);
    return res.status(403).send('origin not allowed');
  }
  // 3) どちらもない → CSRFトークン
  const sent = req.body?._csrf ?? req.headers['x-csrf-token'];
  const expected = getSession(req)?.csrfToken;
  const ok = typeof sent === 'string' && expected &&
    sent.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected));
  console.log(`[csrf] ${ok ? 'ALLOW' : 'BLOCK'} (token)            ${tag} token=${sent ? 'present' : 'missing'}`);
  return ok ? next() : res.status(403).send('invalid csrf token');
}

app.get('/login', (req, res) => {
  const sid = crypto.randomBytes(16).toString('hex');
  sessions.set(sid, { user: 'alice', csrfToken: crypto.randomBytes(32).toString('base64url') });
  res.setHeader('Set-Cookie', `sid=${sid}; Path=/; HttpOnly; SameSite=Lax`);
  res.send('ok');
});

app.use(csrfGuard);

app.post('/email', (req, res) => {
  const s = getSession(req);
  if (!s) return res.status(401).send('login required');
  res.send(`email changed to ${req.body.email}`);
});

app.listen(7053);

ポイントは次のとおりです。

  • none は、アドレスバーへの直接入力やブックマークなど、利用者自身の操作を表す値なので通しています。
  • トークンの比較は crypto.timingSafeEqual で行います。 長さが違うと例外になるため、先に長さを比べています。
  • フォームには <input type="hidden" name="_csrf" value="…"> でトークンを埋め込みます。

実行ログ

ログイン後、ブラウザ(Chromium)とcurlから7パターンを送りました。以下はサーバーが実際に出力したログです。

# 送信元・方法 応答
A 同じオリジンのフォーム(トークンあり) email changed to new@example.com
B 同じオリジンの fetch(トークンなし) 200
C 同じサイト・別ポート(localhost:7054)のフォーム cross-origin request blocked(403)
D 別サイト(127.0.0.1:7054)のフォーム cross-origin request blocked(403)
E curl、トークンなし invalid csrf token(403)
F curl、正しいトークン email changed to y@example.com
G curl、Origin: http://evil.test origin not allowed(403)
[csrf] ALLOW (fetch-metadata)   POST /email sfs=same-origin origin=http://localhost:7053
[csrf] ALLOW (fetch-metadata)   POST /email sfs=same-origin origin=http://localhost:7053
[csrf] BLOCK (fetch-metadata)   POST /email sfs=same-site origin=http://localhost:7054
[csrf] BLOCK (fetch-metadata)   POST /email sfs=cross-site origin=http://127.0.0.1:7054
[csrf] BLOCK (token)            POST /email sfs=- origin=- token=missing
[csrf] ALLOW (token)            POST /email sfs=- origin=- token=present
[csrf] BLOCK (origin)           POST /email sfs=- origin=http://evil.test

注目はCです。 セッションCookieは SameSite=Lax なので、同じサイトの別ポートからのPOSTにはCookieが付きます(前の実測表のとおり)。それでもSec-Fetch-Site: same-site を見て拒否できました。 SameSiteとヘッダー検証を重ねる意味が、ここで確認できます。

なお、curlのような非ブラウザは Origin を自由に書けます(G)。CSRFは「利用者のブラウザに送らせる」攻撃なので、ヘッダー検証はブラウザ経由の偽造を防ぐためのものです。APIの認証・認可は別途必要です。

主要フレームワークの標準機能

多くのフレームワークにCSRF対策が組み込まれています。まず標準機能を有効のまま使い、除外設定を最小にするのが基本です。 以下は2026年9月時点の公式ドキュメントに基づきます。

フレームワーク 標準の仕組み 送り方・設定のポイント
Spring Security 7.1 既定で有効。トークンはセッションに保存(HttpSessionCsrfTokenRepository)、BREACH対策つきの XorCsrfTokenRequestAttributeHandler が既定 パラメータ _csrf かヘッダー。SPAは csrf.spa() でCookie(XSRF-TOKEN)方式に
Rails 8.1(安定版) protect_from_forgery によるトークン検証(authenticity_token) JSからは X-CSRF-Token ヘッダー。フォームヘルパーが自動で埋め込む
Rails 8.2(開発版・未リリース) Sec-Fetch-Site による検証が追加。新規アプリの既定は :header_only 古いブラウザ向けに :header_or_legacy_token も用意
Django 6.x CsrfViewMiddleware。トークン({% csrf_token %})に加え、Origin をホストと CSRF_TRUSTED_ORIGINS で検証 HTTPSでOriginがない場合はRefererを厳密に確認
Laravel 12 ValidateCsrfToken。@csrf のトークン X-CSRF-TOKEN、X-XSRF-TOKEN(Axios等が自動送信)
Laravel 13 PreventRequestForgery。まず Sec-Fetch-Site で同一オリジンを確認し、だめならトークンで確認 originOnly: true でヘッダーだけ、allowSameSite: true でサブドメインを許可
Next.js(App Router) Server ActionsはPOSTのみで、Origin と Host(または X-Forwarded-Host)を比較し、違えば中断 リバースプロキシ構成では serverActions.allowedOrigins。Route Handlersは自分で対策する
Go 1.25以降 標準ライブラリ net/http の CrossOriginProtection。Sec-Fetch-Site と Origin で判定 信頼するオリジンを追加登録できる

Rails・Laravel・Goのように、トークン中心からFetch Metadata中心へと標準機能が移りつつあります。 今回のミドルウェアと同じ考え方です。

よくある落とし穴と直し方

状況 起きること こうするとよい
GETで削除・更新している Laxでもリンク経由でCookieが付く(実測でLax○) 状態を変える処理はPOST/PUT/DELETEに
SameSiteを書いていない 発行2分以内はPOSTにも付く(実測)。ブラウザで既定も違う SameSite=Lax 以上を明示
サブドメインに別アプリがある 同じサイト扱いでStrictでも付く(実測) Sec-Fetch-Site / Origin を検証
Webhook受信などでCSRF検証を丸ごと外した 除外範囲が広がりやすい 除外はパス単位で最小にし、署名検証など別の認証を入れる
SameSite=None にした Secureなしは保存されない(実測) Noneが必要な場面だけに限定し、Secure を付ける
CORSを設定したから安心と考えた CORSは応答の読み取りの制御で、no-cors の送信自体は届く(実測でfetch POSTは到達) 書き込みはサーバー側で出どころを検証

よくある質問

Q. SameSite=Laxにしておけば、CSRFトークンは不要ですか? A. 別サイトからの攻撃の多くはLaxで防げますが、同じサイトの別オリジンからのPOSTにはCookieが付きました。サブドメインや別ポートに他のアプリがある場合や、古いブラウザを考慮する場合に備え、Sec-Fetch-Site/Origin の検証かトークンを重ねるのがおすすめです。

Q. Strictにすると何が困りますか? A. 別サイトのリンクから来たときもCookieが付かないため(実測でリンクGETも×)、メールや外部サイトのリンクから開くと未ログインの画面になります。ログイン状態の表示にはLax、重要な操作の確認用Cookieに限ってStrict、という使い分けがよくあります。

Q. Sec-Fetch-Site がないリクエストはすべて拒否してよいですか? A. 古いブラウザや非ブラウザのクライアントでは付かないことがあります。今回の実装のように、Origin、さらにトークンへと段階的に確かめると、利用者を締め出さずに守れます。

Q. JSONのAPIでもCSRF対策は必要ですか? A. Cookieで認証しているなら必要です。Content-Type: application/json の fetch は事前確認(プリフライト)が必要になるため単純なフォームからは送れませんが、サーバーが text/plain などでもJSONとして受け付けると送れてしまいます。Content-Type の確認と出どころの検証を合わせて行ってください。

まとめ

  • CSRF対策は、SameSite・出どころの検証・トークンの多層防御が基本
  • Laxは別サイトからのPOSTとfetchにCookieを付けないが、リンクのGETには付くことを実測で確認
  • 属性なしのCookieはChromiumでLax扱い、ただし発行2分以内はPOSTにも付いた
  • siteとoriginは別物。同じサイトの別ポートからはStrictでもCookieが付いた
  • Sec-Fetch-Site → Origin → トークンの順で判定するミドルウェアで、同じサイトからの偽造も拒否できた
  • 各フレームワークの標準機能を有効のまま使い、Fetch Metadata対応の新機能も確認する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CSRF対策とSameSite|Cookie送信をChromeで実測し多層防御を実装」

株式会社RJCでは、セキュリティの仕組みを手元で動かして確かめる姿勢を、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?