Skip to content
Published on

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

Authors

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

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

パラメータの意味です。

  • id_token_hint — 以前発行されたIDトークン。誰のどのセッションを切るのかをIdPが確認する根拠です。これがないと、IdPは「本当にログアウトしますか?」という確認画面を出すのが普通です(ログアウトCSRF対策)。
  • post_logout_redirect_uri — ログアウト完了後に戻る先。事前登録されたURIのみ許可すべきです(オープンリダイレクト防止)。
  • state — リダイレクト往復間のCSRF防止用の不透明な値。

ここまでは「アプリ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": {}
  }
}

検証規則が重要です。

  • eventsクレーム必須: backchannel-logoutイベントURIがキーとして存在しなければなりません。
  • nonce禁止: IDトークンとの混同(トークン差し替え攻撃)を防ぐため、nonceがあれば拒否しなければなりません。
  • subまたはsidの最低どちらか: sidがあれば「そのセッションだけ」、subだけなら「そのユーザーのすべてのセッション」を切れという意味です。
  • 署名検証: IdPのJWKSで署名を確認し、iss/aud/expを検証します。
  • jti再利用の検知: リプレイ防止のため、処理済みのjtiを短期間記憶します。

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の限界

万能ではありません。

  • RPのバックエンドがIdPから到達可能なネットワーク位置にある必要があります(ファイアウォール内部のアプリは困難)。
  • IdPはRPの数だけPOSTを送る必要があり、RPが多く一部が遅いとログアウト遅延が生じます。IdPのタイムアウト/並列化設定が重要です。
  • 配送失敗時のリトライポリシーはIdP実装ごとに異なり、保証はat-most-onceに近いです。バックアップ手段(短いセッション + 定期的なトークン再検証)と必ず併用すべきです。

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>

現実的な問題です。

  • Redirectバインディングはリレー競走: SP1 → IdP → SP2 → IdP → SP3のように、ブラウザがすべての参加者を順番に訪問します。途中のSPが1つ応答しなかったり、ユーザーがウィンドウを閉じたりすると、そこでチェーンが切れ、後ろのSPは永遠にログアウトを知りません。
  • SOAPバインディングはサポート不在: サーバー間通知という点でback-channelに似ていますが、実際にSOAP SLOを実装したSPは稀です。商用SaaSの多くがSAMLログインはサポートしてもSLOは未サポート、あるいは形式的なサポートにとどまります。
  • 業界の暗黙の合意: そのため多くのエンタープライズ環境はSAML SLOを有効にせず、「IdPセッションタイムアウト + アプリごとのログアウト」で妥協しています。SAML連携でSLO動作が契約要件になっているなら、必ず双方の製品の実際のサポートレベルをPoCで確認すべきです。

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

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

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

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

  • Front channel logout: on/offとFront-channel logout URL。前述の理由で無効にすることを推奨します。
  • Backchannel logout URL: RPのback-channel受信エンドポイント。
  • Backchannel logout session required: logout tokenにsidを含めるか。有効にすることを推奨します(セッション単位の精密なログアウト)。
  • Backchannel logout revoke offline sessions: オフラインセッション(長寿命refresh)まで失効させるか。

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
  • accessTokenLifespanは短く(5分前後): back-channel logoutがrefresh tokenを殺しても、access tokenは満了まで生きます。この窓を縮める唯一の方法は寿命の短縮か、リソースサーバーが毎リクエストでintrospectionを行うことです(性能コストとのトレードオフ)。
  • 管理者による強制ログアウト: Admin APIで特定ユーザーの全セッションを即時切断できます。このとき、back-channel設定済みのクライアントにはlogout tokenが送信されます。
# 特定ユーザーのすべてのセッションを強制終了
kcadm.sh create users/USER-UUID/logout -r saas-prod

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

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

  • 共用PCのシナリオ: ユーザーは「ログアウトした」と信じて席を離れたのに、IdP SSOセッションが生きていれば、次の人が同じブラウザでアプリにアクセスした瞬間に自動ログインされます。SSO環境で最も頻繁に報告される実事故のタイプです。
  • トークン残存: ログアウト後も生きているaccess tokenは、その寿命の間API接続が可能です。refresh tokenの失効が抜けた実装は、事実上ログアウトが存在しないのと同じです。RFC 7009 (Token Revocation)エンドポイントの呼び出しをログアウト手順に含めてください。
  • ログアウトCSRF: 攻撃者が被害者を強制的にログアウトさせることは些細に見えますが、フィッシングログイン画面へ誘導する前段として使われ得ます。id_token_hintの検証と確認画面、post_logout_redirect_uriのホワイトリストが防御線です。
  • セッション固定の逆方向の教訓: ログイン時にセッションIDを再発行するように、ログアウト時にはセッション識別子に紐づくすべての派生状態(CSRFトークン、キャッシュされた権限)を一緒に破棄すべきです。

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

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

  • モバイルアプリの「ログイン状態」は通常、Keychain/Keystoreに保存されたrefresh tokenです。ログアウトは (1) ローカルトークンの削除、(2) サーバー側のtoken revocation呼び出し、の両方を行う必要があります。ローカル削除だけのアプリが意外に多く、それは奪取されたトークンがサーバーで依然有効という意味です。
  • システムブラウザ(ASWebAuthenticationSession、Custom Tabs)でログインした場合、IdPのSSO Cookieはシステムブラウザに残ります。完全なログアウトを望むなら、RP-Initiated Logout URLを同じメカニズムで一度開き、IdPセッションも切る必要があります。
  • back-channelのlogout tokenをモバイルアプリが直接受け取ることはできないため(サーバーではないので)、プッシュ通知や次回API呼び出し時の401処理で「強制ログアウトされた」ことを検知するよう設計します。
モバイルログアウトのチェックリスト
  [ ] ローカルのrefresh/access token削除 (Keychain/Keystore)
  [ ] POST /revoke でサーバー側トークン廃棄 (RFC 7009)
  [ ] 必要に応じてRP-Initiated LogoutでIdP Cookieを整理
  [ ] アプリ内のキャッシュ済みユーザーデータ/暗号鍵の整理
  [ ] サーバー発の強制ログアウトの受信経路 (pushまたは401ハンドリング)

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

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

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

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

  • 通常の「ログアウト」ボタンはグローバルログアウト(RP-Initiated → IdPセッション終了 → back-channel伝播)につなぎます。ユーザーのメンタルモデル(「ログアウトしたなら終わったはず」)と一致させることが事故を減らします。
  • 単一アプリログアウトが必要な場合は「アカウント切り替え」のような別動線に分離します。
  • セキュリティ設定ページに「すべてのデバイスからログアウト」(Admin APIベースの全セッション廃棄)を別途提供すれば、アカウント乗っ取り対応の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に入れましょう。ログアウトのリグレッションは静かに発生し、事故が起きて初めて発見されます。

参考資料