クッキー — 誰が、いつ、どこへ自動で送るか
クッキーの厄介さは、開発者が明示的に送らなくてもブラウザが勝手に付けることにあります。この自動送出があるからログイン状態が保てる一方、CSRF が成立する理由もここにあります。
そして属性の意味を取り違えやすい。Domain を指定すると範囲が絞られると思っている人が多いのですが、実際は逆に広がります。
この章で学ぶこと
- クッキーの属性が「どこへ送るか」「いつまで持つか」「誰が読めるか」のどれを決めているか
- クッキーのスコープがオリジンと一致しないこと。scheme と port を無視する
SameSiteの 3 値と、その判定が site 単位である理由- 「ログインが切れる」を属性から切り分ける手順
HTTP のヘッダーとステートレス性を前提にします。クッキーは Set-Cookie と Cookie という 1 組のヘッダーで動きます。
クッキーは何のためにあるか
HTTP はステートレスです。サーバーはリクエスト間で状態を覚えないので、ログイン状態のような継続する情報は毎回のリクエストで運ぶしかありません。
運ぶ方法は 2 つあります。
| 方法 | 誰が付けるか |
|---|---|
Authorization ヘッダー | アプリケーションのコードが明示的に付ける |
| クッキー | ブラウザが条件に合致すれば自動で付ける |
この違いが後の話すべてに効きます。自動送出は便利ですが、開発者が意図しないリクエストにも付くということです。
サーバーが渡し、ブラウザが返す
HTTP/1.1 200 OK
Set-Cookie: session_id=abc123; Path=/; Secure; HttpOnly; SameSite=Lax
ブラウザはこれを保存し、条件に合うリクエストで返します。
GET /mypage HTTP/1.1
Host: example.com
Cookie: session_id=abc123
Set-Cookie は1 ヘッダーにつき 1 個です。3 個渡すなら Set-Cookie を 3 行書きます。逆に返す Cookie ヘッダーは 1 行にまとめられ、name=value を ; で区切って並べます。
属性を役割で分ける
属性は 3 つの問いに答えています。
1. どこへ送るか
| 属性 | 意味 |
|---|---|
Domain | 送る対象のホスト。指定するとサブドメインへ広がる |
Path | 送る対象のパス。前方一致で判定する |
SameSite | 他サイトからのリクエストに付けるか |
Secure | HTTPS のときだけ送る |
2. いつまで持つか
| 属性 | 意味 |
|---|---|
Expires | 期限を日時で指定する |
Max-Age | 期限を秒数で指定する。Expires より優先される |
| (どちらも無し) | セッションクッキー。ブラウザを閉じると消える |
削除は Max-Age=0 を送ります。過去の日時を Expires に入れても同じです。
3. 誰が読めるか
| 属性 | 意味 |
|---|---|
HttpOnly | JavaScript から読めなくする (document.cookie に現れない) |
Partitioned | 埋め込み先のサイトごとに別のクッキーとして扱う |
スコープはオリジンではない
ここが最も誤解されます。オリジンは scheme + host + port の 3 つ組ですが、クッキーのスコープは host + path で決まり、scheme と port を見ません。
https://example.com:443 で設定したクッキーは
http://example.com:8080 のリクエストにも付く
Secure を付ければ scheme は絞れます。しかしport を絞る手段はありません (draft-ietf-httpbis-rfc6265bis-21 は port をセキュリティ境界と見なさない立場を取っています)。ローカル開発で複数のポートを使うとき、クッキーが混ざるのはこのためです。
Domain は絞らずに広げる
Set-Cookie: a=1 (Domain 指定なし)
Set-Cookie: b=2; Domain=example.com (Domain 指定あり)
| クッキー | example.com へ | app.example.com へ |
|---|---|---|
a (指定なし) | 送る | 送らない |
b (Domain=example.com) | 送る | 送る |
指定しないほうが範囲が狭くなります。Domain を書くと、そのドメインとすべてのサブドメインが対象になるからです。
範囲を広げたい理由がないなら書かないのが安全です。書くのは「app.example.com と api.example.com で同じセッションを使いたい」のような場合だけです。
SameSite — 他サイトからのリクエストに付けるか
| 値 | 他サイトからのリクエストに付くか |
|---|---|
Strict | 付かない |
Lax | トップレベルの遷移かつ安全なメソッドのときだけ付く |
None | 常に付く。Secure が必須 |
None に Secure が要るのは仕様上の要求です (同 draft の Section 5.7 が same-site-flag が None かつ secure-only-flag が false ならクッキーを無視すると定めています)。
判定は site 単位で、オリジン単位ではない
「SameSite」の名前が示すとおり、判定単位は site です。site は登録可能ドメイン (eTLD+1) で決まります。
| 遷移元 → 遷移先 | 同一 site か |
|---|---|
app.example.com → api.example.com | 同一 (どちらも example.com) |
example.com → example.co.jp | 別 |
alice.github.io → bob.github.io | 別 (github.io は公開サフィックス) |
つまり SameSite はサブドメイン間の移動を制限しません。同一 site 内なら Strict でも送られます。
Strict は利用体験を損ないます。外部サイトのリンクから来たユーザーはクッキーが送られないので、ログイン済みなのに未ログイン扱いになります。この問題があるので、Lax を既定にして CSRF 対策を別に置くのが現実的です。SameSite 単独で CSRF を防げるかについては Web アプリの主要な攻撃と対策 が OWASP の立場を扱っています。
名前の接頭辞で制約を宣言する
属性は Cookie ヘッダーで返ってきません。返るのは name=value だけです。だからサーバーは「このクッキーが Secure 付きで設定されたか」を後から知れません。
これを名前で解決するのが接頭辞です。
| 接頭辞 | ブラウザが要求する条件 |
|---|---|
__Secure- | Secure が付いていること |
__Host- | Secure が付き、Domain が無く、Path=/ であること |
条件を満たさない Set-Cookie はブラウザが拒否します。名前が返ってきた時点で条件が満たされていたと分かるので、サーバー側の検証が不要になります。
認証クッキーには __Host- が最も強い制約です。Domain を禁じるので、サブドメインから上書きされる攻撃を構造的に防げます。
「このリクエストにクッキーが付くか」の判定
「ログインが切れる」を切り分ける
症状から属性を絞ります。上から順に確認します。
| 症状 | 疑う属性 |
|---|---|
| サブドメインへ移ると切れる | Domain 未指定 (そのホストにしか送られない) |
| ローカルでは動くが本番で切れる | Secure (本番は HTTPS、ローカルは http) |
| 特定のページだけ切れる | Path の前方一致から外れている |
| 外部サイトのリンクから来ると切れる | SameSite=Strict |
| ブラウザを閉じると切れる | Max-Age / Expires が無い (セッションクッキー) |
| iframe 内で切れる | SameSite が None でない、または Partitioned |
DevTools の Application タブでクッキーの属性を直接見られます。推測する前に実際の属性を見るのが速いです。
認証クッキーの既定値
判断に迷ったらここから始めます。
Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
| 指定 | 理由 |
|---|---|
__Host- | Domain を禁じ、サブドメインからの上書きを防ぐ |
Secure | 平文で流れないようにする |
HttpOnly | XSS が起きてもスクリプトから読めない |
SameSite=Lax | 他サイトからの POST に付かない。Strict の体験劣化を避ける |
Path=/ | __Host- の要求。サイト全体で使う |
ここから外すときは理由が要ります。サブドメイン間で共有したいなら __Host- を諦めて Domain を指定する、というように何を捨てるかを意識します。
よくある誤解
「HttpOnly を付ければ XSS 対策になる」 — なりません。クッキーの窃取は防げますが、スクリプトが動く以上、そのスクリプトがブラウザに代わってリクエストを送れます。クッキーは自動で付くので、値を読めなくても操作はできます。
「Domain を指定すると対象が絞られる」 — 逆です。指定するとサブドメインへ広がります。絞りたいなら指定しません。
「Secure はクッキーの中身を暗号化する」 — しません。HTTPS のときだけ送るという指定で、値そのものは平文です。暗号化しているのは TLS です。
「クッキーは同一オリジンにしか送られない」 — scheme と port を無視するので、オリジンより広い範囲に送られます。
「SameSite=Strict にすれば CSRF は防げる」 — 同一 site 内からの攻撃は防げません。サブドメインを乗っ取られた場合、Strict でもクッキーは送られます。
確認問題
問 1. Set-Cookie: token=x; Domain=example.com; Path=/api を返しました。次のうちクッキーが送られるのはどれですか。
A. https://example.com/api/orders
B. https://example.com/mypage
C. https://app.example.com/api/orders
D. http://example.com/api/orders
答え: A・C・D
| 判定 | 理由 | |
|---|---|---|
| A | 送る | host も path も合致する |
| B | 送らない | Path=/api の前方一致から外れる |
| C | 送る | Domain 指定でサブドメインも対象 |
| D | 送る | Secure を付けていないので http でも送る |
D が引っかかりやすい点です。Domain や Path は絞っても、scheme は Secure を付けない限り絞られません。token という名前のクッキーが平文で流れる設計になっています。
問 2. SPA からの API 呼び出しでクッキーが送られません。SameSite=None; Secure を付けたのに変わりません。何を確認しますか。
答え: リクエスト側が資格情報を送る設定になっているかを確認します。
SameSite=None はブラウザ側の制限を外す指定ですが、それだけでは足りません。fetch は既定でクロスオリジンのリクエストにクッキーを付けないので、credentials: 'include' が要ります。
さらにサーバー側も条件を満たす必要があります。
| 必要なもの | 理由 |
|---|---|
Access-Control-Allow-Credentials: true | 資格情報付きのリクエストを許可する |
Access-Control-Allow-Origin に具体的なオリジン | * は資格情報付きでは使えない |
つまりクッキー側の設定・リクエスト側の設定・CORS の設定の 3 つが揃わないと送られません。詳しくは オリジンと CORS で扱います。
問 3. app.example.com の管理画面が乗っ取られました。Domain=example.com のセッションクッキーを使っています。何が起きますか。
答え: example.com と、すべてのサブドメインのセッションが影響を受けます。
Domain=example.com を指定したクッキーは、app.example.com からも読み書きできます。乗っ取られたサブドメインで動くスクリプトは次ができます。
- クッキーを読む (
HttpOnlyなら読めませんが、リクエストには自動で付く) - 同じ名前のクッキーを上書きする (セッション固定攻撃)
HttpOnly は読み取りを防ぎますが、上書きは防ぎません。攻撃者が用意したセッション ID を書き込めば、被害者はそのセッションでログインすることになります。
これを構造的に防ぐのが __Host- です。Domain を禁じるので、サブドメインは親ドメインのクッキーに手を出せません。代償はサブドメイン間でセッションを共有できないことです。共有が要るなら、サブドメインの信頼境界を設計で分ける必要があります。
まとめ
- クッキーはブラウザが自動で送る。この自動送出がログイン維持を支え、同時に CSRF を成立させる
- スコープはオリジンではない。host + path で決まり、scheme と port を見ない
Domainを指定するとサブドメインへ広がる。絞りたいなら指定しないSameSiteの判定は site (eTLD+1) 単位。サブドメイン間の移動は制限しないHttpOnlyは読み取りを防ぐが上書きは防がない。上書きを防ぐのは__Host-- 属性は
Cookieヘッダーで返らない。だから名前の接頭辞で条件を宣言する - 認証クッキーの出発点は
__Host-+Secure+HttpOnly+SameSite=Lax
- React ガイド — SPA の認証 — トークンの保管場所の選択とリフレッシュフローの実装
- MDN — Set-Cookie
- draft-ietf-httpbis-rfc6265bis-21 — Cookies —
SameSiteと名前の接頭辞を定める最新のドラフト。RFC 6265 (2011) にはSameSiteが含まれない - RFC 6265 — HTTP State Management Mechanism
- Public Suffix List — site の判定に使われる公開サフィックスの一覧
次に読む
- オリジンと CORS — クッキーのスコープと一致しないもう 1 つの境界
- 認証と認可 — セッション方式がクッキーで何を運ぶか
- Web アプリの主要な攻撃と対策 — 自動送出を悪用する CSRF と、その対策の優先順位