🍪

Webアプリのセッション管理ベストプラクティス― セッション固定攻撃・ログアウト設計・ストア選択

に公開

Webアプリのセッション管理ベストプラクティス

関連記事

― セッション固定攻撃・ログアウト設計・ストア選択

前回の記事では、OAuth + OIDCを使った認証フローと、それがカバーしないセキュリティリスクを整理しました。その中で「セッション管理はOAuthの仕様外であり、アプリが独自に設計する必要がある」という点に触れました。

本記事ではその部分を単独で深掘りします。

  • セッションとは何か、なぜ壊れやすいのか
  • セッション固定攻撃・セッションハイジャックの仕組みと対策
  • ログアウト設計の落とし穴(強制ログアウトを含む)
  • CookieベースのセッションとJWT、それぞれの特性と使い分け
  • セッションストア(Redis vs DB)の選択基準
  • セッションの有効期限設計
  • ブラウザのCookie仕様(Third-party廃止・CHIPS・__Host-プレフィックス)

セッションとは何か

HTTPはステートレスなプロトコルです。リクエストをまたいで「同じユーザーからのアクセスである」と判断するには、セッションという仕組みが必要になります。

①  POST /login
    → サーバーが認証を確認し、セッションIDを発行
    → Set-Cookie: session_id=SID_XXXX; HttpOnly; Secure

②  GET /dashboard
    → ブラウザが Cookie: session_id=SID_XXXX を自動送信
    → サーバーがセッションIDを照合して「田中さんのリクエスト」と判断

セッションIDは「ログイン状態の証明書」です。これが漏れると、パスワードを知らなくてもそのユーザーとして操作できるため、適切な設計が欠かせません。


セッションIDの生成

最初に「セッションIDをどう作るか」という基礎を確認します。

満たすべき性質

性質 理由
十分な長さ(128ビット以上) ブルートフォース(総当たり)対策
暗号論的にランダム 予測・列挙できないようにする
一意性 衝突によるアカウント混同を防ぐ

実装例

# Python(secrets モジュールを使う)
import secrets
session_id = secrets.token_hex(32)  # 256ビット = 64文字の16進数文字列
// Node.js(crypto モジュールを使う)
import { randomBytes } from "crypto";
const sessionId = randomBytes(32).toString("hex"); // 256ビット

Math.random() はセッションIDに使ってはいけません。暗号論的に安全ではなく、予測可能です。


セッション固定攻撃(Session Fixation)

攻撃の仕組み

セッション固定攻撃は、「攻撃者が知っているセッションIDを使ってユーザーにログインさせる」攻撃です。

① 攻撃者がターゲットサービスにアクセスし、未認証のセッションIDを取得
   攻撃者のブラウザ → GET https://myapp.com/
   ← Set-Cookie: session_id=KNOWN_SID

② 攻撃者がKNOWN_SIDをURLパラメータや細工したリンクで被害者に渡す
   例:https://myapp.com/login?session_id=KNOWN_SID

③ 被害者がそのリンクからログインする
   被害者のブラウザ → POST /login (Cookie: session_id=KNOWN_SID)
   → サーバーが認証を確認、KNOWN_SIDをそのままログイン後のセッションに使う

④ 攻撃者がKNOWN_SIDでアクセスすると、被害者のセッションを乗っ取れる

根本的な原因

ログイン前後でセッションIDが変わらないことが原因です。ログイン成功時にセッションIDを新しく発行し直すだけで、この攻撃は防げます。

対策:ログイン後にセッションIDを必ず再発行する

# ❌ アンチパターン:ログイン前のセッションIDをそのまま使う
def login(request):
    user = authenticate(request.form["email"], request.form["password"])
    if user:
        session["user_id"] = user.id  # 同じセッションIDに書き込む
        return redirect("/dashboard")

# ✅ 正しい実装:ログイン後にセッションIDを再生成する(疑似コード)
# 実際のメソッド名はフレームワークによって異なる(下記の表を参照)
def login(request):
    user = authenticate(request.form["email"], request.form["password"])
    if user:
        old_data = dict(session)       # 既存データを退避
        session.clear()                # 旧セッションを削除
        session.regenerate()           # 新しいセッションIDを発行(フレームワーク固有)
        session.update(old_data)       # データを引き継ぐ
        session["user_id"] = user.id
        return redirect("/dashboard")

主要フレームワークでの対応:

フレームワーク セッション再生成の方法
Express(Node.js) req.session.regenerate(callback)
Django(Python) request.session.cycle_key()
Laravel(PHP) $request->session()->regenerate()
Spring Security(Java) デフォルトでログイン後に自動再生成

セッションハイジャックへの対策

セッションIDが有効である限り、それを手に入れた第三者はそのユーザーに「なりすます」ことができます。セッションIDの盗難経路と対策を整理します。

セッションIDをCookieで扱う場合、以下の属性をすべて設定します。

Set-Cookie: session_id=SID_XXXX;
  HttpOnly;          ← JavaScriptから読み取れない(XSS対策)
  Secure;            ← HTTPS通信のみで送信される
  SameSite=Lax;      ← トップレベルナビゲーション以外のクロスサイトリクエストにはCookieを付与しない(CSRF対策)
  Path=/;
  Max-Age=3600       ← セッションの有効期限
属性 防ぐ攻撃 設定しないリスク
HttpOnly XSS経由のCookie盗取 document.cookie でJSから読み取られる
Secure 通信経路での盗聴 HTTPで平文送信される
SameSite=Lax or Strict CSRF 外部サイトからの偽リクエストにCookieが付く

SameSite の選択

挙動 適しているケース
Strict 外部サイトからのあらゆるリクエストにCookieを付与しない 高セキュリティ(SNSシェアからのアクセスでも再ログインになる)
Lax トップレベルナビゲーション(リンクのクリック等、URLバーが変わる遷移)かつ安全なメソッド(GET等)の場合のみ付与する 一般的なWebアプリ
None すべてのリクエストに付与する(Secure必須) クロスオリジンが必要な埋め込みウィジェット等

SameSite=Lax が現在のブラウザデフォルトであり、多くのWebアプリにとって適切な選択です。

セッションIDのURLへの混入を禁止する

セッションIDはCookieのみで管理し、URLパラメータやリクエストボディには絶対に含めないことが原則です。

URLにセッションIDが混入すると、以下の経路から漏洩します。

① ブラウザの閲覧履歴
   https://myapp.com/dashboard?session_id=SID_XXXX
   → 共有PCや端末紛失時に第三者が履歴から取得できる

② Referer ヘッダー
   外部リンクをクリックすると、遷移先サーバーのアクセスログに
   Referer: https://myapp.com/dashboard?session_id=SID_XXXX
   が記録される

③ サーバー・プロキシのアクセスログ
   GET /dashboard?session_id=SID_XXXX HTTP/1.1
   → ログ管理者や侵害されたログシステムから漏洩する

④ ブラウザのキャッシュ・ブックマーク
   URLごとキャッシュ・保存されると長期間残存する

なぜこうなるか: 昔のJava EEアプリ(Tomcat等)では、ブラウザがCookieを受け入れない場合のフォールバックとして jsessionid をURLに付与する仕様がありました(URL Rewriting)。現代のアプリでは不要であり、明示的に無効化すべきです。

# ❌ アンチパターン:URLにセッションIDを含める
return redirect(f"/dashboard?session_id={session_id}")

# ✅ 正しい実装:Cookieのみで管理する
response = redirect("/dashboard")
response.set_cookie("session_id", session_id, httponly=True, secure=True, samesite="Lax")
return response
// Spring Boot:URL Rewriting を無効化する
@Configuration
public class SessionConfig {
    @Bean
    public HttpSessionIdResolver httpSessionIdResolver() {
        // CookieベースのみにしてURLへの埋め込みを禁止
        return new CookieHttpSessionIdResolver();
    }
}
// または application.properties で
// server.servlet.session.tracking-modes=cookie

Referer ヘッダーの漏洩は Referrer-Policy: no-referrer または origin を設定することで軽減できますが、URLへの混入自体をなくすことが根本対策です。

セッションハイジャックの追加対策

バインディング(補助的な対策)

セッションIDに加えて、ログイン時のクライアント情報をセッションデータに記録し、リクエストのたびに照合する方法があります。

# ログイン時:クライアント情報をセッションに記録
def create_session(user_id, request):
    session_data = {
        "user_id": user_id,
        "ip_address": request.remote_addr,
        "user_agent": request.headers.get("User-Agent", ""),
        "created_at": time.time(),
    }
    session_id = generate_session_id()
    redis.setex(f"session:{session_id}", 3600, json.dumps(session_data))
    return session_id

# リクエスト時:照合する
def validate_session(session_id, request):
    data = redis.get(f"session:{session_id}")
    if not data:
        return None
    session = json.loads(data)
    if session["user_agent"] != request.headers.get("User-Agent", ""):
        # User-Agentが変わった → セッション無効化
        redis.delete(f"session:{session_id}")
        return None
    return session

ただし、バインディングには注意が必要です。

バインディング対象 メリット デメリット・注意点
IPアドレス 別IPからの盗用を検知できる モバイル回線切り替え・VPNで正規ユーザーが弾かれる
User-Agent ブラウザ・OS変更を検知できる User-Agentはブラウザ更新でも変わる、改ざんも容易

IPバインディングは誤失効リスクが高く、現在は推奨されません。 User-Agentバインディングも万能ではないため、あくまで多層防御の一要素として扱います。


ログアウト設計

ログアウトは「セッションIDのCookieを削除する」だけでは不十分です。

不完全なログアウトの問題

❌ よくある実装:
  - ブラウザのCookieからセッションIDを削除するだけ
  - サーバーサイドのセッションデータはそのまま残る

問題:
  - セッションIDがすでに第三者に漏れていた場合、
    ログアウト後もそのIDでアクセスできてしまう
  - ブラウザの履歴やプロキシのキャッシュにセッションIDが残っていると再利用される

正しいログアウトの実装

□ サーバーサイドのセッションデータを削除する(最重要)
□ Set-Cookie でセッションIDを無効な値・過去の有効期限で上書きしてブラウザから削除する
□ OIDCを使っている場合はIdPへのRP-Initiated Logoutも実行する(オプション)
□ アクセストークン・リフレッシュトークンをリボークする
# ✅ サーバーサイドでセッションを削除してからCookieを削除する
def logout(request):
    session_id = request.cookies.get("session_id")
    if session_id:
        session_store.delete(session_id)          # ① サーバーのセッションデータ削除

    response = redirect("/login")
    response.delete_cookie(                        # ② ブラウザのCookieを削除
        "session_id",
        path="/",
        secure=True,
        httponly=True,
        samesite="Lax"
    )
    return response

強制ログアウト(管理者・セキュリティインシデント対応)

「このユーザーの全セッションを今すぐ無効にする」という操作は、アカウント乗っ取り対応や退職者処理で必要になります。

要件:
  - 特定ユーザーIDに紐づくすべてのセッションを一括削除できる

実現方法:
  - セッションストアのキーを "session:{session_id}" ではなく
    ユーザーIDで引けるように設計しておく
  - "user_sessions:{user_id}" → [session_id_1, session_id_2, ...] を別途管理し、
    ユーザーIDから全セッションIDを取得 → 一括削除

Redis での実装例:

# ログイン時:ユーザーIDとセッションIDを紐づけて保存
def create_session(user_id, session_data, ttl=3600):
    session_id = generate_session_id()
    pipe = redis.pipeline()
    pipe.setex(f"session:{session_id}", ttl, json.dumps(session_data))
    pipe.sadd(f"user_sessions:{user_id}", session_id)   # セット管理
    pipe.expire(f"user_sessions:{user_id}", ttl)
    pipe.execute()
    return session_id

# 強制ログアウト:ユーザーの全セッションを削除
# 注: redis-py の decode_responses=False(デフォルト)前提。
# decode_responses=True の場合は sid.decode() は不要
def force_logout_all(user_id):
    session_ids = redis.smembers(f"user_sessions:{user_id}")
    pipe = redis.pipeline()
    for sid in session_ids:
        pipe.delete(f"session:{sid.decode()}")
    pipe.delete(f"user_sessions:{user_id}")
    pipe.execute()

トークンベース vs Cookieベースのセッション管理

「JWTを使えばセッション管理は不要」という誤解が多く見られます。ここでは**サーバーサイドセッション(Cookieベース)トークンベース(JWT等)**の特性を整理します。

仕組みの違い

【Cookieベース(サーバーサイドセッション)】

ブラウザ              サーバー              セッションストア
  ──── Cookie: session_id=SID ────▶  ──── GET session:SID ────▶
                   ◀─── {user_id, role, ...} ──
  ◀── レスポンス ─────────────────────

セッションIDはランダムな「参照キー」。実データはサーバー側に置く。


【トークンベース(JWT)】

ブラウザ              サーバー
  ──── Authorization: Bearer eyJhb... ────▶

          署名を検証するだけ(外部ストア不要)
          ペイロードから user_id, role を取得
  ◀── レスポンス ─────────────────────

JWTはデータ自体をトークンに内包する。ストアへの問い合わせが不要。

比較表

観点 Cookieベース(サーバーサイドセッション) JWTベース
即時失効 できる(ストアから削除するだけ) できない(有効期限まで有効)
スケールアウト セッションストアの共有が必要 検証が自己完結するためストア不要
格納できる情報量 ストア側に置けるため制限なし Cookieサイズ上限4KBに収める必要あり
サーバーの状態 ステートフル(ストアへの依存あり) ステートレス(ストア不要)
トークン盗取時のリスク セッションIDを無効化できる 有効期限が切れるまで悪用される
実装の複雑さ シンプル(フレームワーク標準機能) 鍵管理・有効期限設計が必要

JWTをセッションとして使う場合の落とし穴

問題1:ログアウトしても即座に無効化できない

JWTをCookieから削除 → ブラウザは送らなくなる
しかしそのトークン自体はまだ「有効」

→ 漏洩したJWTは有効期限(exp)まで使い続けられる

これを回避するには、失効させたJWTの jti(JWT ID)をブラックリストとしてストアに保持する必要があります。しかしそうするとステートレスの利点が消えます。

# JWTブラックリストの実装例(Redisを使う場合)
def logout(token: str):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
    except jwt.ExpiredSignatureError:
        return  # すでに期限切れなので何もしなくてよい
    except jwt.InvalidTokenError:
        return

    jti = payload["jti"]
    exp = payload["exp"]
    ttl = exp - int(time.time())
    if ttl > 0:
        redis.setex(f"revoked_jwt:{jti}", ttl, "1")

def verify_token(token: str):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
    except jwt.InvalidTokenError as e:
        raise Exception("Invalid token") from e

    jti = payload["jti"]
    if redis.exists(f"revoked_jwt:{jti}"):
        raise Exception("Token has been revoked")
    return payload

問題2:機密情報の意図しない露出

JWTのペイロードはBase64エンコードされているだけで暗号化ではありません。

import base64, json

# JWTはBase64URLエンコードを使用(通常のBase64とは異なる)
payload_b64url = "eyJ1c2VyX2lkIjogMTIzLCAicm9sZSI6ICJhZG1pbiJ9"
# パディングを補完してデコード
padded = payload_b64url + "=" * (4 - len(payload_b64url) % 4)
print(json.loads(base64.urlsafe_b64decode(padded)))
# → {"user_id": 123, "role": "admin"}

JWTペイロードには機密情報(メールアドレス、権限の詳細等)を含めないか、JWEを使って暗号化します。

問題3:alg: none / アルゴリズム混同攻撃

古いJWTライブラリには alg: none を受け入れる脆弱性があります。常に検証時のアルゴリズムを明示的に指定します。

# ❌ アルゴリズムを指定しない(alg: none 攻撃を受ける可能性)
payload = jwt.decode(token, SECRET_KEY)

# ✅ アルゴリズムを明示する
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

どちらを選ぶべきか

Cookieベース(サーバーサイドセッション)を選ぶケース:
  - 即時ログアウト・強制ログアウトが必要(金融、企業向けSaaS)
  - セッションの監査・管理UIが必要
  - モノリシックなWebアプリ

JWTを選ぶケース:
  - マイクロサービス間のサービス間認証(APIトークン)
  - モバイルアプリやSPAから呼ぶAPIのアクセストークン
  - 短命なトークン(数分〜1時間)で失効要件が緩い場合

ブラウザ向けWebアプリのセッション管理には、一般的にCookieベースのサーバーサイドセッションが適しています。 JWTはセッション管理よりも、サービス間認証やアクセストークンとしての用途で本来の力を発揮します。


セッションストアの選択:Redis vs DB

セッションデータをどこに保存するかは、パフォーマンス・可用性・運用コストに直結します。

比較表

観点 Redis(インメモリ) RDB(PostgreSQL等)
読み書き速度 マイクロ秒オーダー ミリ秒オーダー(インデックスあり)
TTL管理 EXPIRE コマンドで自動削除 バッチ削除ジョブが必要
スケールアウト Redis Cluster / Sentinel で対応 レプリカ・シャーディングが必要
永続性 デフォルトは揮発(RDB/AOFで永続化可能) 永続保存
運用コスト 別サービスの管理が必要 既存DBを流用できる
セッション監査ログ 別途設計が必要 クエリで集計・分析しやすい

選択の目安

Redis を選ぶケース:

  • リクエスト数が多く、セッション読み取りがボトルネックになりうる
  • セッションの自動期限切れ管理を楽にしたい
  • すでにキャッシュ目的でRedisを運用している

DBを選ぶケース:

  • トラフィックが少なく、インフラを増やしたくない
  • セッションデータを監査・分析したい(いつ誰がログインしたか等)
  • マネージドRDSなど既存DBの信頼性・バックアップ体制に乗りたい

どちらを選んでも、セッションデータをアプリサーバーのメモリに持つのは避けてください。 スケールアウト時にロードバランサーのスティッキーセッションが必要になり、設計の自由度が大きく下がります。

Redisを使う場合の設計上の注意点

□ セッションデータはシリアライズして保存する(JSONまたはMessagePack等)
□ TTLはセッション有効期限と合わせる(長すぎない)
□ Redis の障害時にフォールバックを考慮する(セッション消失 → 再ログインを許容するか)
□ Redis に保存する情報は最小限にする(個人情報や機密データは入れない)
□ 本番環境では認証(requirepass / ACL)と通信の暗号化(TLS)を有効にする

セッションの有効期限設計

「セッションをいつ切るか」も設計が必要です。

有効期限の種類

1. 絶対有効期限(Absolute Timeout)
   ─ ログインから固定時間後に必ず失効する
   ─ 例:8時間後にセッション切れ → 再ログイン必要

2. 非アクティブ有効期限(Idle Timeout / Sliding Expiration)
   ─ 最後のアクセスから一定時間操作がなければ失効する
   ─ 例:30分操作がなければセッション切れ
   ─ Redisでは `EXPIRE` を各リクエスト時に更新することで実現できる

3. 両方組み合わせる(推奨)
   ─ 「最終アクセスから30分」かつ「ログインから最大8時間」
# Redisで両方の有効期限を管理する例
def get_session(session_id):
    data = redis.get(f"session:{session_id}")
    if not data:
        return None

    session = json.loads(data)

    # 絶対有効期限チェック
    if time.time() > session["absolute_expires_at"]:
        redis.delete(f"session:{session_id}")
        return None

    # 非アクティブ有効期限を延長(スライディング)
    redis.expire(f"session:{session_id}", IDLE_TIMEOUT_SECONDS)
    return session

リスクに応じた有効期限の目安

アプリの性質 絶対有効期限 非アクティブ有効期限
社内ツール(低リスク) 24時間 2時間
一般向けWebサービス 30日(Remember me含む) 1〜2時間
金融・医療(高リスク) 8時間 15〜30分

ブラウザのCookieストレージの制限と挙動

Cookieは単純なキーバリューストアに見えますが、ブラウザの挙動や仕様の変化がセッション設計に直接影響します。

Cookieの基本的な制限

制限 値(目安) 影響
1件あたりの最大サイズ 4096バイト セッションデータをCookieに入れすぎると切り捨てられる
ドメインあたりの最大件数 50〜600件(ブラウザ依存。Chrome: 180件、Firefox: 150件、Safari: 約600件) 多数のCookieを設定すると古いものが削除される
ドメイン全体の最大合計サイズ 数十KB〜数百KB(ブラウザ依存) 超過すると自動削除される

セッションIDのCookie自体は小さく問題になりませんが、JWTをCookieに直接格納する場合はサイズ上限に注意が必要です。

Third-party Cookie とは、現在開いているサイト(myapp.com)とは異なるドメイン(analytics.com 等)が発行するCookieです。

ユーザーが myapp.com を開く
  → ページ内の <img src="https://tracker.com/pixel"> が読み込まれる
  → tracker.com がCookieを発行・読み取りできた(従来の挙動)
  → tracker.com は複数サイトをまたいでユーザーを追跡できた

2024年以降、Chromeを中心に主要ブラウザがThird-party Cookieを段階的に廃止しています。

通常のWebアプリへの影響:

自サービスのセッションCookieはFirst-party Cookiemyapp.commyapp.com に発行)なので、この変化の影響を受けません。影響が出るのは以下のケースです。

影響を受けるケース:
  - 自サービスを <iframe> として他サイトに埋め込んでいる
    例:chat widget, payment form, OAuth popup
  - 異なるドメインのサービスと認証状態を共有している
    例:myapp.com のセッションを partner.com のiframe内でも使いたい

サブドメイン間(app.myapp.comdocs.myapp.com など)のCookieはFirst-party Cookieとして扱われます。 Third-party Cookie廃止の直接的な影響は受けませんが、Domain=.myapp.com の設定が必要で、それに伴うセキュリティリスクは別途考慮が必要です(後述)。

サブドメインをまたいだCookie共有

サブドメイン間でCookieを共有する場合は Domain 属性を設定します。

Set-Cookie: session_id=SID_XXXX;
  Domain=.myapp.com;   ← サブドメインすべてに送信される
  HttpOnly; Secure; SameSite=Lax

ただし、Domain を設定するとCookieのスコープが広がるため、1つのサブドメインが侵害されると他のサブドメインのセッションも危険にさらされます。 サブドメインが異なるチームや外部サービスによって管理されている場合は特に注意が必要です。

Partitioned Cookie(CHIPS)

Third-party Cookieの廃止により代替として策定されたのが CHIPS(Cookies Having Independent Partitioned State) です。

Set-Cookie: session_id=SID_XXXX;
  Secure;
  SameSite=None;
  Partitioned;   ← CHIPSを有効にする属性

Partitioned を設定すると、Cookieは埋め込み元のトップレベルサイトごとに分離して保存されます。

従来(Third-party Cookie):
  myapp.com に埋め込まれた widget.example.com のCookie
  = news.com に埋め込まれた widget.example.com のCookie
  → 同じCookie → クロスサイトトラッキングが可能

CHIPS(Partitioned):
  myapp.com 内の widget.example.com のCookie
  ≠ news.com 内の widget.example.com のCookie
  → 別々に保存 → クロスサイトトラッキングが不可能

自社のWebアプリを他サイトに埋め込む場合や、iframeでの認証セッションが必要な場合は、CHIPSへの対応を検討します。

__Host- / __Secure- プレフィックス

Cookieに特定のプレフィックスを付けると、ブラウザが属性を強制的に検証します。

# __Secure- プレフィックス:Secureフラグを強制
Set-Cookie: __Secure-session_id=SID_XXXX; Secure; HttpOnly; SameSite=Lax

# __Host- プレフィックス:Secure強制 + Path=/ 強制 + Domain属性禁止
Set-Cookie: __Host-session_id=SID_XXXX; Secure; HttpOnly; SameSite=Lax; Path=/

__Host- プレフィックスは最も厳格で、以下を保証します。

  • Secure フラグが必須(HTTPS以外では設定・送信されない)
  • Path=/ が強制される(パス限定のCookieで上書きできない)
  • Domain 属性が設定できない(サブドメインへの漏洩が防がれる)

セッションCookieに __Host- プレフィックスを使うことで、設定ミスによるスコープの拡大を防げます。


まとめ:セッション管理チェックリスト

セッションIDの生成
□ 暗号論的に安全な乱数(secrets.token_hex / crypto.randomBytes)を使う
□ 128ビット以上のエントロピーを確保する

Cookie属性
□ HttpOnly を設定する
□ Secure を設定する(HTTPS必須)
□ SameSite=Lax(または Strict)を設定する

セッション固定攻撃対策
□ ログイン成功後にセッションIDを再生成する(regenerate)

セッションハイジャック対策
□ セッションIDはCookieのみで管理し、URLパラメータに含めない
□ URLにセッションIDが混入する機能(URL Rewriting等)を無効化する
□ User-Agentバインディングを補助的な対策として検討する(IPバインディングは非推奨)

トークン選択
□ ブラウザ向けWebアプリはCookieベースのサーバーサイドセッションを基本とする
□ JWTをセッションに使う場合は jti ブラックリストで即時失効を実装する
□ JWTペイロードに機密情報を含めない(平文でデコードされる)
□ JWT検証時はアルゴリズムを明示的に指定する

ログアウト
□ サーバーサイドのセッションデータを削除する
□ ブラウザのCookieを削除する(Max-Age=0 で上書き)
□ OIDCを使っている場合はIdPにもログアウトリクエストを送る

強制ログアウト対応
□ ユーザーIDからセッションIDを逆引きできるように設計する
□ 特定ユーザーの全セッションを一括削除できる機能を用意する

有効期限
□ 絶対有効期限と非アクティブ有効期限を両方設定する
□ リスクレベルに応じた有効期限を設定する

セッションストア
□ アプリサーバーのメモリにセッションを持たない
□ Redis を使う場合は認証・TLSを有効にする
□ セッションデータに機密情報を入れすぎない

Cookieストレージ・ブラウザ挙動
□ サブドメイン共有が不要な場合は __Host- プレフィックスを使いスコープを最小化する
□ iframeやクロスオリジン埋め込みが必要な場合はCHIPS(Partitioned)を検討する
□ サブドメイン共有でDomain属性を使う場合は侵害時の影響範囲を評価する

参考資料

ヘッドウォータース

Discussion