セッションCookieの事故は、たいてい属性の付け忘れからではなく、属性が防ぐ範囲を広く見積もったところから起きます。HttpOnlyを付けたのにXSSで乗っ取られた、SameSite=Laxなのにサブドメインから偽の送信が通った、ログアウトしたのに盗まれたIDが生きていた、という形です。

連載「事故から逆算するWebセキュリティ」の今回は、Cookie属性とセッション管理を「何を防ぎ、何を防がないか」で整理し、ヘッドレスのChromiumで実際の挙動を確かめます。

SAME SESSION ID属性だけの違い

同じセッションIDでも属性で届く範囲が変わる

属性なし

sid=…; Path=/

  • document.cookie で読める
  • HTTP でも送られる
  • 別サイトのPOSTに2分間乗る
  • 兄弟サブドメインから上書きされる

__Host- + 3属性

__Host-sid=…; Secure; HttpOnly; SameSite=Lax; Path=/

  • スクリプトから読めない
  • HTTPS だけで送られる
  • 別サイトのPOSTに乗らない
  • Domain 付きの偽物は捨てられる
出典: 当サイトの実測(2026-10-17・Chromium 141.0.7390.37 ヘッドレス)。2分間はSameSite未指定のときの互換動作

30秒でわかる(2026年10月17日時点)

  • ・HttpOnly:値の持ち出しを止める。XSSからの「その場での操作」は止めない(実測でfetchに付いた)
  • ・SameSite=Lax:別サイトからのPOSTを止める。兄弟サブドメインは同じサイトなのでStrictでも通る
  • ・__Host-:Domain付き・Path=/なし・Secureなしの偽物をブラウザが捨てる。上書きによる固定化を防ぐ
  • ・対策:__Host-sid=…; Secure; HttpOnly; SameSite=Lax; Path=/に、ログイン時のID再発行とサーバー側の破棄を組み合わせる

セキュリティ全体の地図は サプライチェーン攻撃とは|手口・防御ツール比較・npm 12の新機構まで実践解説 にまとめています。

セッション管理の要件:IDの作り方と捨て方

Cookie属性はIDの運び方の話で、その前にIDそのものの扱いがあります。OWASP Session Management Cheat Sheet(公式リポジトリの原稿で2026年10月17日に確認)の要件を、事故の形と並べます。

SESSION LIFECYCLEIDの一生

セッションIDはサーバー側で作り直し、捨てる

  1. 発行

    CSPRNG で推測できない値(OWASP は64ビット以上)

  2. ログイン・権限変更

    IDを作り直し、古いIDを破棄する(固定化対策)

  3. 無操作・絶対の期限

    最終操作からと発行からの2つをサーバーで判定

  4. ログアウト・パスワード変更

    サーバーの記録を消す。Cookie削除だけでは写しが生きる

出典: OWASP Session Management Cheat Sheet(公式リポジトリの原稿で2026-10-17に確認)

・推測できない値:CSPRNGで作り、エントロピーは64ビット以上。DBが読まれる前提なら、IDの一部をSHA-256でハッシュ化して保存する設計(128ビット以上の乱数)も同文書が挙げています
・ログインで作り直す:ログイン前のIDを使い続けると、攻撃者が先に被害者のブラウザへ埋めたIDがそのままログイン済みになります(セッション固定化)。権限昇格とパスワード変更でも作り直します
・2つの期限:無操作タイムアウトの目安は重要なアプリで2〜5分、リスクの低いアプリで15〜30分。絶対タイムアウトは1日の業務なら4〜8時間(同文書)。どちらもサーバー側で判定します
・サーバー側で捨てる:ログアウトでCookieを消しても、盗まれた写しは生きています。サーバーのセッション記録を消して初めて無効になります

「すべての端末からログアウト」とセッション一覧

サーバー側にセッションを持つ設計なら、ユーザーごとにセッションの行(作成日時・最終利用・端末の目安)を並べて見せ、行を消すだけで「ほかの端末からログアウト」が作れます。パスワード変更のときは、今の端末以外の行をまとめて消します。

署名だけで検証する自己完結型のトークン(JWTなど)は、サーバーに記録が無いので個別に失効できません。失効の一覧を持つか寿命を短くするかの設計になり、この話は別の回で扱います。トークンの盗難を多層で止める考え方は トークン窃取を多層で止める——セッション・AI推論・課金まで含めた防御パターン にあります。

Cookie属性は何を防ぎ、何を防がないか

属性ごとに、止める攻撃と止めない攻撃をまとめます。根拠はRFC 6265bisの編集者版(後述)、MDNのSet-Cookie、OWASPの同文書と、次の節の実測です。

属性 防ぐもの 防がないもの セッションCookieでの推奨
HttpOnly XSSによる値の持ち出し(document.cookie) XSSが動いている間のリクエストの送信 付ける
Secure 平文HTTPでの送信と盗聴 端末からの読み出し、XSS 付ける
SameSite=Lax 別サイトからのPOST・画像・fetchへの添付 別サイトからのリンク移動(GET)、兄弟サブドメインからの送信 Lax以上を明示
SameSite=Strict 上に加えて別サイトからのリンク移動 兄弟サブドメインからの送信 可能ならStrict
Domain (付けると送り先が広がる) 兄弟サブドメインへの漏れと上書き 付けない
Path (送り先の整理だけ) 同じホストの別パスからの読み出し 境界として使わない
__Host- Domain付き・HTTPからの偽物での上書き XSS、CSRF 名前に付ける

Domainはサブドメインに配る指定

Domainを付けないCookieは発行したホストだけに送られます。Domain=example.comと書くと、すべてのサブドメインに送られ、どのサブドメインからも同じ名前で上書きできます。ブログやキャンペーン用のサブドメインが乗っ取られると、本体のセッションを読まれたり、攻撃者のIDを埋め込まれたりします。

Pathはセキュリティの境界ではない

MDNは、Pathが別パスからの読み出しを防がないと明記しています。同じオリジンのページは、iframeで対象パスのページを開けばそのdocument.cookieを読めるからです(下の実測で確認)。

localStorageに置かない理由

localStorageとsessionStorageは同じオリジンのどのスクリプトからも読めるため、XSSが1つあればトークンはすべて持ち出されます。OWASPは認証トークンを置かないよう求め、HttpOnlyのCookieか、ブラウザにトークンを渡さないBFF構成を勧めています。

Cookie属性の挙動をChromium 141で実測する

2026年10月17日、Python標準ライブラリのHTTPSサーバーを手元に立て、Playwright 1.56.1が同梱するChromium 141.0.7390.37(ヘッドレス)で確かめました。ホスト名は --host-resolver-rules で127.0.0.1に向けた3つで、www.shop.testが本体、evil.shop.testが乗っ取られた兄弟サブドメイン、attacker.testが別サイトの役です。Public Suffix Listに test の規則は無いので既定の規則が当たり、前の2つは登録可能ドメインがshop.testで同じサイト、attacker.testは別サイトになります。

# server.py(抜粋):/set を開いたホストが返す Set-Cookie
SET = {"/set": [
  "sid_httponly=h1; HttpOnly; Path=/", "nosamesite=d1; Path=/",
  "lax=l1; SameSite=Lax; Path=/", "strict=s1; SameSite=Strict; Path=/",
  "none=n1; SameSite=None; Secure; Path=/", "wide=w1; Domain=shop.test; Path=/",
  "admin_only=a1; Path=/admin",
  "__Host-ok=1; Secure; Path=/", "__Host-nosecure=1; Path=/",
  "__Host-domain=1; Secure; Path=/; Domain=shop.test",
  "__Host-nopath=1; Secure", "__Host-subpath=1; Secure; Path=/app"],
 "/toss": ["sid=attacker; Domain=shop.test; Path=/",          # evil.shop.test が返す
           "__Host-sid=attacker; Secure; Path=/; Domain=shop.test"]}

ブラウザ側はPlaywrightでページを順に開き、サーバーが受け取ったCookieヘッダーを記録しました。結果は次のとおりです。

確かめたこと 結果
HttpOnlyのCookieをdocument.cookieで読む 出ない。同じページからのfetchには付いて送られた
/publicから /admin 用のCookieを読む 直接は出ない。同じオリジンのiframeで /admin を開くと読めた
__Host- の4つの誤り(Secureなし・Domain付き・Pathなし・Path=/app) 4つとも保存されなかった。__Host-ok だけ保存
evil.shop.testへのリクエスト wide=w1(Domain=shop.test)だけが届いた
平文HTTP(8080番)へのリクエスト Secure付きは送られず、HttpOnlyだけのCookieは送られた
evil.shop.testがDomain付きの sid を返したあとのwww sid=attacker; sid=victim の順で届いた。Domain付きの __Host-sid は捨てられた

最後の行がCookieの上書き(cookie tossing)です。同じ名前が2つ届き、攻撃者のものが先頭でした。先頭の値を読む実装なら、ログイン後も攻撃者のIDで動きます。名前を __Host- にするとこの経路が閉じます。

SAMESITE=LAXどの要求に乗るか

Lax は別サイトのPOSTを止めるが同じサイトは通す

サイトの境界

  • POST別サイトのフォーム送信 送られないattacker.test から
  • GET別サイトからのリンク移動 送られるattacker.test から
  • img別サイトの画像・fetch 送られないattacker.test から
  • POST兄弟サブドメインのフォーム Strict でも送られるevil.shop.test から
出典: 当サイトの実測(2026-10-17・Chromium 141)。www.shop.test と evil.shop.test は登録可能ドメインが同じで same-site

SameSiteは、送り元ごとに次の結果でした。

送り元と要求 Lax Strict None 未指定
attacker.testのフォームPOST(作成直後) 送られない 送られない 送られる 送られる
同上(作成から2分超) 送られない 送られない 送られる 送られない
attacker.testからのリンク移動(GET) 送られる 送られない 送られる 送られる
attacker.testの画像・fetch 送られない 送られない 送られる 送られない
evil.shop.testのフォームPOST 送られる 送られる 送られる 送られる

未指定のCookieは作成から125秒後のPOSTには付かず、その時点で作成から119秒だったCookieは付きました。6265bisの「Lax-allowing-unsafe」(未指定のCookieに限り、作成直後は安全でないメソッドでも送る互換動作。目安2分)とMDNの「2分」の記述どおりです。SameSiteを付けないと、ログイン直後の2分間はCSRFの窓が開いたままになります。

この構成ではSameSite=Noneのサードパーティ送信(画像・fetch)が通常プロファイルでもPlaywrightの新規コンテキストでも止まりませんでした。Playwrightは起動時にフィールドトライアルを無効にし ThirdPartyStoragePartitioning も切っているため、製品版Chromeの既定とは同じと言えません。

SameSiteの既定値とサードパーティCookieのタイムライン

仕様とブラウザの変化を、MDN browser-compat-data(GitHubの最新版、2026年10月17日に確認)の対応版とChromeの公開日で並べます。

公開日 出来事
2011年 RFC 6265(現行のCookie仕様)
2016-03-02 Chrome 49で __Host-・__Secure- プレフィックス
2016-05-25 Chrome 51でSameSite属性
2020-02-04 Chrome 80で未指定をLax扱い、SameSite=NoneにSecure必須
2021-05-25 Chrome 91で「スキームを含むsame-site」(httpとhttpsは別サイト)
2023-05-30 Chrome 114でPartitioned属性(CHIPS)
2025-09-02 Chrome 140で __Http-・__Host-Http- プレフィックス
2026-01-07 RFC 6265bisの編集者版の最終更新(GitHub httpwg/http-extensions)

FirefoxのLax既定は設定項目の切り替えが必要な扱いで、Safariは未対応です。既定値に頼るとブラウザで挙動が割れるので、OWASPも明示を求めています。

RFC 6265bisは、RFC 6265を置き換える改訂です。2026年10月17日時点でGitHubの原稿は draft-ietf-httpbis-rfc6265bis-latest のままで、HTTPワーキンググループの仕様一覧もRFC 6265を掲げています。RFCとしての発行は確認できませんでした(審査の段階は未確認)。__Http- は6265bisの原稿に無く、MDNとbrowser-compat-dataで確認した機能です。実測ではChromium 141が __Http- のHttpOnlyなしを捨てました。

サードパーティCookieについて、MDNの解説は「Chromeは既定ではブロックせず、シークレットモードか利用者の設定でブロックする」と書いています。CHIPS(Partitioned)は埋め込みウィジェットの話で、自社ドメインのセッションCookieには関係しません。

対策:Cookie属性の設定と自システムの確認

セッションCookieは次の1行を基本にします。値はCSPRNGの128ビット以上の乱数です。

Set-Cookie: __Host-sid=<乱数>; Secure; HttpOnly; SameSite=Lax; Path=/

・Strictにできるか先に考える:OWASPはStrictを優先します。ただし外部サイトのリンクから来た最初の画面ではログイン状態が見えなくなるので、Laxを選ぶならCSRFトークンと組み合わせます。SameSiteはCSRF対策の補助で、置き換えではありません
・Max-Ageを付けない、または短く:ブラウザのセッション復元で、期限なしのCookieも閉じたあとに残ります。期限はサーバーで判定します
・ログインで作り直す:フレームワークの再発行関数(PHPの session_regenerate_id(true) など)を、認証成功の直後に呼びます
・ログアウトはサーバーから:記録を消してから Max-Age=0 で上書きします。OWASPは Clear-Site-Data ヘッダーの併用も挙げています
・Domainを付けない:サブドメインでどうしても共有するなら __Secure- にとどめ、サブドメインごとに守りの水準をそろえます

認証ライブラリ側の所有権確認の漏れでも乗っ取りは起きます。実例は better-auth 脆弱性|SCIM/OAuthでアカウント乗っ取り、CVSS9.9のGHSA4件 を参照してください。

自システム確認コマンド

自社のレスポンスが返すSet-Cookieを1行ずつ出し、HttpOnly・SameSiteを明示していないものを拾います。セッションCookieがログインの応答でだけ出るなら、ブラウザの開発者ツールで、ログイン時の応答ヘッダーを同じ観点で見ます。

curl -sS -o /dev/null -D - https://www.example.com/ | grep -i '^set-cookie'
curl -sS -o /dev/null -D - https://www.example.com/ | grep -i '^set-cookie' | grep -iv '; *httponly'
curl -sS -o /dev/null -D - https://www.example.com/ | grep -i '^set-cookie' | grep -iv '; *samesite='

上のサーバーで試すと、Cookie 17本を返す /set では2行目が15行、3行目が14行出て、属性をそろえた /login では2行目が0行でした。属性の手前に ; を置いているのは、nosamesite のように名前に属性名を含むCookieを見落とさないためです(名前で外れる誤りを実際に踏みました)。ただし0行だったからといって安全とは言えません。見えるのはそのURLの応答に付いた Set-Cookie だけで、ログイン後の別の応答や JavaScript(document.cookie)で置かれる Cookie はこのコマンドには出ません。

影響範囲

影響を受けるのは、サーバー側でセッションを持つWebアプリのうち、SameSiteを明示していないもの、Domainを付けてサブドメインと共有しているもの、ログイン時にIDを作り直していないものです。今回の実測はChromium 141のヘッドレスだけで、Firefox・Safariと現行の安定版Chrome(browser-compat-data上は155)では確かめていません(未検証)。

参照ソース