LabHub

ブログ

Single Logout(SLO)の難しさ — Front-Channel、Back-Channelログアウトの設計

한국어English日本語

はじめに — ログインよりログアウトが難しい

SSOプロジェクトで最も遅く発見され、最も長引く問題は、意外にもログアウトです。ログインは「一箇所で認証すればすべてが開く」という一方向のフローなので設計が明快です。しかしログアウトは「一箇所で切ればすべてが閉じなければならない」という分散状態伝播の問題です。分散システムにおける状態伝播がいかに難しいかを知るエンジニアなら、Single Logout(SLO)がなぜ数多くの標準(OIDCだけで3つのログアウト仕様)を生みながら、いまだにすっきりしないのか想像がつくはずです。

2026年現在、この問題はさらに難しくなりました。すべての主流ブラウザがサードパーティCookieをデフォルトでブロックするようになり、十数年間SLOの主力だったfront-channel(iframe)方式が事実上寿命を迎えたからです。本記事では、SSOセッションの3層モデルからOIDCの3つのログアウト仕様、SAML SLOの現実、Keycloakの設定、モバイル・UX・テストまで、ログアウト設計の全体地図を描きます。

SSOセッションの3層モデル — 何を切らなければならないのか

ログアウトを設計するには、まず「ログイン状態」がどこにいくつ存在するかを知る必要があります。典型的なOIDC SSO環境には最低3層のセッションが存在します。

層1: IdP(OP) SSOセッション
  - KeycloakなどIdPのブラウザCookie (例: KEYCLOAK_IDENTITY)
  - これが生きている限り、どのアプリも再認証なしでSSOログイン可能

層2: 各アプリ(RP)の自前セッション
  - アプリサーバーのセッションCookie (例: JSESSIONID) またはアプリ独自のセッションストア
  - アプリは一度OIDC認証を受けた後、自分のセッションでユーザーを記憶

層3: 発行済みトークン群
  - access token (自身の寿命の間は有効 — セッションと無関係に動作し得る)
  - refresh token (失効させるまで新しいaccess tokenを発行可能)
  - モバイルアプリ/SPAが保持するトークンのコピー

図にするとこうなります。

                +---------------------------+
                |  IdP (Keycloak)           |
                |  [層1] SSOセッションCookie |
                +------+--------+-----------+
                       |        |
            OIDC認証   |        |  OIDC認証
                       v        v
        +--------------+--+  +--+---------------+
        | アプリA (RP)    |  | アプリB (RP)      |
        | [層2] アプリ    |  | [層2] アプリ      |
        |      セッション |  |      セッション    |
        | [層3] トークン  |  | [層3] トークン     |
        +-----------------+  +------------------+

「ログアウトした」が意味を持つには、3層すべてが片付けられる必要があります。よくある失敗モードは次のとおりです。

失敗モード症状原因
アプリセッションだけ終了アプリAでログアウト直後に再ログインするとパスワードなしで入れるIdP SSOセッション(層1)が生きている
IdPセッションだけ終了IdPでログアウトしてもアプリBは使い続けられるアプリBの自前セッション(層2)が生きている
セッションは全部切ったのにAPIは動くログアウト後もモバイルアプリのAPI呼び出しが成功access/refresh token(層3)が失効していない

この表の2行目こそ、SLOが解こうとしている問題です。IdPでセッションが切れたという事実をすべてのRPに伝播しなければなりません。

OIDC RP-Initiated Logout — ログアウトの起点

ユーザーがアプリAで「ログアウト」を押すと、アプリAは自分のセッションを切るだけでなく、IdPのログアウトエンドポイントへユーザーを送ります。これがOIDC RP-Initiated Logoutです。

GET /realms/saas-prod/protocol/openid-connect/logout?id_token_hint=eyJhbGciOiJSUzI1NiIs...&post_logout_redirect_uri=https%3A%2F%2Fapp-a.example.com%2Fbye&state=af0ifjsldkj HTTP/1.1
Host: auth.example.com

パラメータの意味です。

ここまでは「アプリAとIdP」の間の後始末にすぎません。アプリB、C、Dに知らせることが本当の問題であり、その方法がfront-channelとback-channelです。

Front-Channel Logout — iframeの栄光と没落

OIDC Front-Channel Logoutはブラウザを伝播媒体として使います。IdPのログアウトページが各RPのログアウトURLを隠しiframeとしてレンダリングすると、各iframeリクエストにRPのセッションCookieが載って送られ、RPが自分のセッションを終了する仕組みです。

[ブラウザがIdPのログアウトページをロード]
  IdPのレスポンスHTML内に:
    iframe 1 -> https://app-a.example.com/oidc/front-logout?iss=...&sid=abc123
    iframe 2 -> https://app-b.example.com/oidc/front-logout?iss=...&sid=abc123
    iframe 3 -> https://app-c.example.com/oidc/front-logout?iss=...&sid=abc123
  各RPはリクエストに載った「自ドメインのCookie」でセッションを見つけて終了

この方式の決定的な弱点が、2026年に致命傷となりました。

  1. サードパーティCookieのブロック: iframe内のapp-a.example.comへのリクエストは、IdPページ(auth.example.com)の文脈から見ればサードパーティリクエストです。Safari(ITP)はずっと前から、Chromeも段階的なブロックを経て、2026年現在の主流ブラウザのデフォルト設定では、このiframeリクエストにRPのセッションCookieが載りません。Cookieがなければ、RPはどのセッションを切ればよいか分かりません。つまりfront-channel logoutは静かに失敗します。
  2. 結果の確認が不可能: iframeのロードが成功したかIdPには分かりません。失敗してもリトライはありません。
  3. タイミング: ユーザーがログアウトページをすぐ離れると、iframeのロードが完了する前に中断されることがあります。

同じドメインファミリー(例: IdPとアプリがすべてexample.comのサブドメイン)であればファーストパーティとみなされまだ動作しますが、マルチテナントSaaSのようにドメインが分かれた瞬間、front-channelは信頼できません。2026年の結論: front-channel logoutは新規設計では選択しません。

Back-Channel Logout — サーバー間の直接通知

OIDC Back-Channel Logoutはブラウザを迂回します。IdPが各RPのバックエンドエンドポイントへ、logout tokenという署名付きJWTを直接POSTします。

[IdPセッション終了が発生]
   |
   |  HTTP POST (サーバー -> サーバー、ブラウザ無関係)
   +---> https://app-a.example.com/oidc/back-logout   (logout_token=...)
   +---> https://app-b.example.com/oidc/back-logout   (logout_token=...)
   +---> https://app-c.example.com/oidc/back-logout   (logout_token=...)

Logout Tokenの構造

logout tokenはIDトークンに似たJWTですが、いくつか固有の規則があります。

{
  "iss": "https://auth.example.com/realms/saas-prod",
  "aud": "app-a-client",
  "iat": 1781234567,
  "exp": 1781234687,
  "jti": "bWJq-09cd-4a4f-a3f9",
  "sub": "f3a8c2e1-9b47-4d6a-8c21-0e5f7a9b3d44",
  "sid": "abc123-session-id",
  "events": {
    "http://schemas.openid.net/event/backchannel-logout": {}
  }
}

検証規則が重要です。

RP側の受信エンドポイント実装

@PostMapping(value = "/oidc/back-logout",
             consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public ResponseEntity<Void> backChannelLogout(@RequestParam("logout_token") String token) {
    LogoutToken lt = logoutTokenValidator.validate(token); // 署名、iss、aud、events、nonce不在を検証

    if (lt.sid() != null) {
        sessionRegistry.terminateBySid(lt.sid());          // 該当OIDCセッションに紐づくアプリセッションを終了
    } else {
        sessionRegistry.terminateAllForUser(lt.sub());     // ユーザーのすべてのアプリセッションを終了
    }
    tokenStore.revokeRefreshTokens(lt.sub(), lt.sid());    // 層3の後始末
    return ResponseEntity.ok().build();                    // 200 OK — 失敗時はIdPがリトライできるよう5xx
}

実装の隠れた難所はsidとアプリセッションのマッピングです。ログイン時にIDトークンのsidクレームをアプリセッションとともに保存しておかないと、後でlogout tokenのsidから逆引きできません。ステートレスJWTセッションだけを使うアプリの場合、「切るべきサーバーセッション」自体が存在しないため、拒否リスト(denylist)のような状態を導入して初めてback-channel logoutが意味を持ちます。ログアウトは本質的にステートフルな作業なのです。

Back-Channelの限界

万能ではありません。

3つのOIDCログアウト仕様の比較

ここまでの内容を1つの表にまとめます。

基準Session Management (iframeポーリング)Front-Channel LogoutBack-Channel Logout
伝播媒体ブラウザ (OP iframeの状態ポーリング)ブラウザ (隠しiframeのロード)サーバー間HTTP POST
サードパーティCookie依存あり — 致命的あり — 致命的なし
配送確認/リトライ不可不可可能 (実装依存)
RPの実装負担フロントエンドのポーリングロジックログアウトURL 1つ署名検証 + セッションマッピング
ブラウザの閉鎖/離脱に強いかいいえいいえはい
ファイアウォール内のRP動作動作困難
2026年の推奨度非推奨非推奨標準の選択肢

SAML SLO — 標準はあるが現実は険しい

SAML 2.0にもSingle Logout Profileがあります。LogoutRequest/LogoutResponseメッセージをRedirect(ブラウザ経由)またはSOAP(サーバー間)バインディングでやり取りする構造です。

<samlp:LogoutRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
                     xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
                     ID="_8f2b3c4d" Version="2.0"
                     IssueInstant="2026-06-12T09:30:00Z"
                     Destination="https://app-a.example.com/saml/slo">
  <saml:Issuer>https://auth.example.com/realms/saas-prod</saml:Issuer>
  <saml:NameID Format="urn:oasis:names:tc:SAML:2.0:nameid-format:persistent">
    f3a8c2e1-9b47-4d6a-8c21-0e5f7a9b3d44
  </saml:NameID>
  <samlp:SessionIndex>abc123-session-index</samlp:SessionIndex>
</samlp:LogoutRequest>

現実的な問題です。

Keycloakにおけるログアウト設定

Keycloak(26.6基準)は上記すべてのメカニズムをサポートします。クライアント単位で設定します。

OIDCクライアント設定のポイント

管理コンソールのクライアント設定で:

kcadm CLIで表現すると次のとおりです。

# クライアントのback-channel logout設定
kcadm.sh update clients/CLIENT-UUID -r saas-prod \
  -s 'attributes."backchannel.logout.url"=https://app-a.example.com/oidc/back-logout' \
  -s 'attributes."backchannel.logout.session.required"=true' \
  -s 'attributes."backchannel.logout.revoke.offline.tokens"=false' \
  -s 'frontchannelLogout=false'

セッション・トークン寿命との組み合わせ

ログアウト伝播は失敗し得るため、セッション/トークンの寿命がセーフティネットです。realm設定で:

kcadm.sh update realms/saas-prod \
  -s ssoSessionIdleTimeout=1800 \
  -s ssoSessionMaxLifespan=28800 \
  -s accessTokenLifespan=300 \
  -s offlineSessionIdleTimeout=2592000
# 特定ユーザーのすべてのセッションを強制終了
kcadm.sh create users/USER-UUID/logout -r saas-prod

セッション固定とトークン残存 — ログアウトのセキュリティリスク

ログアウトが不完全だと、次の攻撃/リスク面が開きます。

モバイルアプリのログアウト

モバイルはブラウザのセッションモデルが通用しない別世界です。

モバイルログアウトのチェックリスト
  [ ] ローカルのrefresh/access token削除 (Keychain/Keystore)
  [ ] POST /revoke でサーバー側トークン廃棄 (RFC 7009)
  [ ] 必要に応じてRP-Initiated LogoutでIdP Cookieを整理
  [ ] アプリ内のキャッシュ済みユーザーデータ/暗号鍵の整理
  [ ] サーバー発の強制ログアウトの受信経路 (pushまたは401ハンドリング)

グローバルログアウト vs 単一アプリログアウト — UXの意思決定

技術が揃っても「ログアウトボタンは何をすべきか」は製品の意思決定です。

オプション動作適する文脈
単一アプリログアウトこのアプリのセッションのみ終了、IdPセッション維持職場ポータル系 — 他の業務アプリは使い続ける必要がある
グローバルログアウトIdPセッション + 全RPセッション終了共用PC、セキュリティ重視環境、「すべてのデバイスからログアウト」
選択型ログアウト時にユーザーに範囲を質問混在環境 — ただしUXの摩擦あり

推奨パターンは次のとおりです。

テストマトリクス

ログアウトはリグレッションが頻発する領域なので、マトリクスベースの自動化が必須です。

#シナリオ期待結果
1アプリAログアウト後にアプリAへ再アクセス再認証を要求
2アプリAログアウト後にアプリBへアクセス (グローバルポリシー)再認証を要求 (back-channel伝播の確認)
3ログアウト後5分以内に既存access tokenでAPI呼び出しポリシーに従い401または寿命内許可 — 文書化どおり
4ログアウト後にrefresh tokenで更新を試行401/400 invalid_grant
5IdP管理者による強制セッション終了全RPへlogout token送信、アプリセッション終了
6RPのback-logoutエンドポイントがダウン中のログアウトIdPログアウトは成功、復旧後その RPセッションはidle timeoutで消滅
7logout tokenのリプレイ (同一jtiの再送)拒否
8nonceを含む偽造logout token拒否
9post_logout_redirect_uriに未登録URL拒否または無視
10サードパーティCookieブロックのブラウザで全フローback-channel経路で正常動作
11モバイル: ログアウト後に奪取想定トークンでAPI呼び出しrevocation反映により401
12ログアウト直後に戻るボタンで個人化ページへアクセスキャッシュ非露出 (no-store確認)

E2E自動化の骨格例です。

#!/usr/bin/env bash
set -euo pipefail
# シナリオ4: ログアウト後のrefresh拒否を確認

TOKENS=$(curl -sf -X POST "$IDP/realms/saas-prod/protocol/openid-connect/token" \
  -d "grant_type=password&client_id=e2e&username=tester&password=$E2E_PW")
RT=$(echo "$TOKENS" | jq -r .refresh_token)
IDT=$(echo "$TOKENS" | jq -r .id_token)

# RP-Initiated Logout
curl -sf "$IDP/realms/saas-prod/protocol/openid-connect/logout?id_token_hint=$IDT" > /dev/null

# refresh試行は失敗しなければならない
STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
  "$IDP/realms/saas-prod/protocol/openid-connect/token" \
  -d "grant_type=refresh_token&client_id=e2e&refresh_token=$RT")
test "$STATUS" = "400" && echo "PASS: refresh rejected after logout"

アンチパターン集 — こうすると事故が起きます

実際のコードレビューやペネトレーションテストで繰り返し発見されるログアウトのアンチパターンです。

アンチパターン1 — フロントエンドだけのログアウト

// これはログアウトではありません
function logout() {
  localStorage.removeItem('access_token');
  localStorage.removeItem('refresh_token');
  window.location.href = '/login';
}

トークンのローカルコピーを消しただけで、サーバー側ではトークンは依然有効、IdPセッションも生きています。ブラウザ履歴やXSSでトークンを入手した攻撃者には何の影響もありません。必ずrevocationエンドポイントの呼び出しとRP-Initiated Logoutリダイレクトを伴わせる必要があります。

アンチパターン2 — ログアウトをGETリンクで公開

確認手続きのないGET /logoutリンクは、imgタグ1行で被害者を強制ログアウトさせられます(ログアウトCSRF)。POST + CSRFトークン、またはid_token_hint検証を経る標準フローを使うべきです。

アンチパターン3 — ステートレスJWTセッションと「ログアウト済み」の仮定

サーバーセッションなしでJWTだけで認証状態を管理しながら、ログアウトAPIが200を返す実装です。実際には何も無効化されていません。denylist(jti基準)、トークンバージョンクレーム、または短い寿命 + refresh失効のいずれかは必ず必要です。

アンチパターン4 — back-channelエンドポイントの無認証の信頼

logout_tokenの署名検証なしにsubだけ読んでセッションを切る実装は、偽造トークンで任意のユーザーをログアウトさせるDoS経路になります。署名・iss・aud・events・nonce不在まで含めた完全な検証が必須です。

アンチパターン5 — ログアウト後のキャッシュ残存

CDNやブラウザキャッシュに残った個人化ページが、ログアウト後も戻るボタンで露出する問題です。認証済みレスポンスにはCache-Control private/no-storeを一貫して適用すべきです。

2026年のブラウザプライバシー変化が残したもの

まとめると、ブラウザのプライバシー強化(サードパーティCookieのデフォルトブロック、パーティション化ストレージ、bounce tracking緩和)は、SSOログアウト設計に次の影響を残しました。

  1. front-channel logoutとOIDC Session Management(iframeポーリング)系は新規設計から除外 — クロスドメインでの動作を保証できません。
  2. back-channel logoutが唯一信頼できる伝播手段 — ただしat-most-once配送であることを受け入れ、短いトークン寿命と組み合わせるべきです。
  3. FedCMのようなブラウザ仲介のアイデンティティAPIが、サードパーティCookie以後のセッションシグナルを一部代替する方向に発展中です。ログイン状態共有とログアウトシグナルの未来はブラウザAPIとの協調へ移行しつつあるため、認証レイヤーを抽象化しておけば移行が容易になります。
  4. 究極のセーフティネットは寿命設計 — どの伝播メカニズムも100%ではないため、「伝播がすべて失敗してもN分後には必ず失効する」ことが保証されるaccess token寿命とセッションidle timeoutが最後の防衛線です。

おわりに

ログアウトは分散状態の無効化問題であり、完璧な解法はなく、良いトレードオフがあるだけです。設計原則を要約します。

  1. セッション3層(IdPセッション、アプリセッション、トークン)を明示的にモデリングし、ログアウトが各層に何をするかを文書化しましょう。
  2. 新規設計はRP-Initiated Logout + Back-Channel Logoutの組み合わせが基本です。front-channelは2026年のブラウザ環境では信頼できません。
  3. SAML SLOは、相手製品の実サポートレベルを検証する前に約束してはいけません。
  4. 伝播は失敗すると仮定し、短いaccess token寿命 + refresh失効 + idle timeoutをセーフティネットとして置きましょう。
  5. ログアウトUX(グローバル vs 単一アプリ)はセキュリティの意思決定です。ユーザーのメンタルモデルと一致させ、「すべてのデバイスからログアウト」を提供しましょう。
  6. テストマトリクスをCIに入れましょう。ログアウトのリグレッションは静かに発生し、事故が起きて初めて発見されます。

参考資料

コメント

まだコメントはありません。

ログインするとコメントできます