- 技術 💻
CSRF対策とSameSite|Cookie送信をChromeで実測し多層防御を実装
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期連続の黒字決算。