SAMLのIdP-initiated SSOは、利用者がIdPのポータルでアプリを選び、SPが頼んでいない認証応答をIdPから直接受け取る方式です。この記事では、SP-initiatedとのメッセージの流れの違いをOASIS SAML 2.0の仕様書で確かめ、InResponseToが無い応答で生じるログインCSRFとリプレイ、RelayStateの扱いを整理します。python3-samlで要求ありと要求なしの応答を分けて検証するACSのコード、Entra IDとKeycloakでSP起点へ寄せる設定、IdP-initiatedを有効にしてよい条件まで、SPを実装する側の視点でまとめます。
まとめ:IdP-initiatedは既定で無効にしSP起点へ切り替えて受ける
違いは「SPが要求を出したかどうか」の1点です。SP-initiatedではAuthnRequestのIDが応答のInResponseToに戻り、SPは自分が送った要求への答えだと照合できます。IdP-initiatedの応答にはInResponseToが無く、ブラウザから届いた署名付きの応答が、今の利用者の操作から生まれたものかを確かめる手段がありません。
結論として、新しく作るSPではIdP-initiatedを無効にしておきます。ポータルのタイルからの起動は、IdP側をSPのログイン開始URLへ飛ばす設定に変え、SP起点で受けられます。
取引先の都合で有効にする場合は、InResponseTo付きの未要求応答の拒否、使用済みIDの保持、短い有効期限、RelayStateの許可リストを入れます。それでもログインCSRFの余地は残る。そう理解したうえで採用してください。
IdP-initiatedとSP-initiatedのフローをメッセージ単位で見た違い
SAMLの登場人物やアサーションの基本はSAMLとは?認証フロー・IdP/SPの仕組みとOAuth・OIDCとの使い分けを実装視点で解説で扱っています。ここでは2つの起点の差を、やり取りされるメッセージの単位で比べます。
SP-initiatedでAuthnRequestのIDが応答に戻るまでの5工程
SP-initiatedは、利用者が業務システムのURLを直接開くところから始まります。
- 利用者が未ログインのままSPのページを開く
- SPがID属性付きのAuthnRequestを作り、そのIDをセッションに保存してIdPへ転送する
- IdPが利用者を認証する(既にIdPにログイン済みなら画面は出ない)
- IdPがInResponseToに要求IDを入れたResponseを、ブラウザ経由でSPのACSへPOSTさせる
- SPが保存していたIDとInResponseToを照合し、一致したらセッションを作る
OASIS SAML 2.0 Profiles仕様のSection 4.1.4.2は、bearerのSubjectConfirmationDataについて「AuthnRequestへの応答ならInResponseToは要求のIDと一致しなければならない」と定めています。工程2で保存したIDが、工程5で応答とブラウザのセッションを結び付ける鍵になります。
IdP-initiatedで要求なしに届く応答と仕様4.1.5の4つの規定
IdP-initiatedは、利用者がIdPのポータルにログインし、アプリのタイルを押すところから始まります。IdPはAuthnRequestを受け取らないままResponseを作り、ブラウザにSPのACSへPOSTさせます。工程は3つに縮み、SP側には「頼んでいない応答」が届く形です。
Profiles仕様のSection 4.1.5「Unsolicited Responses」は、この形を次のように規定しています。
- IdPは未要求のResponseを送ってこのプロファイルを開始してよい(MAY)
- 未要求のResponseはInResponseToを含んではならず、bearerのSubjectConfirmationDataも同様
- メタデータを使う場合、送り先はSPが既定に指定したAssertionConsumerServiceとする
- IdPはSPとの取り決めに基づくRelayStateを付けてよく、SPは成功後の既定の遷移先を用意しておく
SP側の処理規則であるSection 4.1.4.3も、InResponseToは元の要求IDと一致することを確かめ、未要求の応答ならInResponseToが存在しないことを確かめるよう求めています。要求していないのにInResponseToが付いた応答は、仕様上あり得ない形です。
比較表で見るRelayState・InResponseTo・開始URLの違い
実装と設定で差が出る項目を並べると、次のとおりです。
| 項目 | SP-initiated | IdP-initiated |
|---|---|---|
| 開始する場所 | SPのページ・ブックマーク | IdPのポータルのタイル |
| AuthnRequest | あり(SPが発行) | なし |
| InResponseTo | 要求IDと一致が必須 | 含めてはならない |
| RelayStateを決める側 | SP(戻り先の保持) | IdP(SPとの取り決め) |
| 応答と利用者操作の結び付け | 要求IDで照合できる | 照合する手段が無い |
| IdP側に登録する値 | ACS URL・ログイン開始URL | ACS URL・既定のRelayState |
差の中心は下から2行目です。IdP-initiatedの危険は、ほぼすべてここから派生します。
IdP-initiatedを受け入れたSPが抱えるログインCSRFとリプレイの危険
IdP-initiatedを受け付けるACSは、「署名が正しく、期限内で、自分宛てのResponseなら、どのページから送られたPOSTでもログインさせる」受信口になります。
攻撃者のアサーションを被害者のブラウザからACSへPOSTさせる注入
攻撃者にとって必要なのは、同じIdPにある自分の正規アカウントだけです。自分でポータルからSPを開き、ブラウザがACSへ送る直前のResponseを取り出して、自動送信フォームとして罠のページに埋め込みます。被害者がそのページを開くと、被害者のブラウザが攻撃者のアカウントでSPにログインした状態になります。
Responseは本物のIdPが署名した正規のものなので、署名検証、Recipient、Audience、NotOnOrAfterをすべて通過します。被害者が気付かずに取引データや連絡先を入力すれば、攻撃者のアカウントに蓄積される。SP-initiatedなら、被害者のセッションに保存された要求IDと応答のInResponseToが一致しないため、この注入は成立しません。
使用済みIDの保持や有効期限の短縮では防げない点も押さえておきます。攻撃者は自分のアカウントで新しいResponseを何度でも発行できるからです。
盗まれたResponseの再送を使用済みIDとNotOnOrAfterで防ぐ範囲
もう1つの経路は、他人のResponseの再送です。プロキシのログ、共有端末のブラウザ履歴、デバッグ用に貼り付けたSAML-tracerの出力などから漏れたResponseを、別のブラウザからACSへ送り直します。
Profiles仕様のSection 4.1.4.5は、HTTP POSTで受けたbearerアサーションについて、SPがNotOnOrAfterまで使用済みのIDを保持し、再利用を防がなければならないと定めています。これで防げるのは「正規の利用者が先に使った後の再送」だけ。正規の利用者より先に攻撃者が使えば通るため、IdP側でNotOnOrAfterを数分に絞る設定と組み合わせます。
python3-samlのresponse.pyは、NotOnOrAfterの判定にALLOWED_CLOCK_DRIFT(constants.pyで300秒)を足しています。IdP側を2分にしても、SP側では最大7分受け付ける計算です。保持期間はこの余裕を含めて決めます。使用済みIDの保存をRedisで書く例はSAMLアサーションとは:XML構造の読み方と署名・Audience・有効期限の検証実装に載せています。
IdPが決めるRelayStateをそのまま転送先にするオープンリダイレクト
IdP-initiatedのRelayStateは、SPではなくIdP側の設定や、ポータルへのリンクに付いた値で決まります。ACSがそれを検証せずにredirect(RelayState)すると、ログイン直後に外部の偽サイトへ飛ばすオープンリダイレクトになります。
OASIS SAML 2.0 Bindings仕様のSection 3.4.3と3.5.3は、RelayStateを80バイト以下とし、作った側が完全性を保護すべきとしています。Section 3.5.5の注意事項は、SAMLメッセージとRelayStateの組み合わせ自体は保護されず、正規の応答2つのRelayStateを入れ替えられるという点です。署名済みの応答に付いてきた値でも、RelayStateは信頼できない入力として扱います。
python3-samlのACSで要求ありと要求なしの応答を分けて検証する実装
ここからはSP側のコードです。python3-saml(PyPIの最新は2023年10月公開の1.16.0系、2026年11月時点)を例にします。
request_idを渡さないとInResponseToの照合が丸ごと省かれる理由
ACSで呼ぶprocess_response(request_id=None)は、引数を省略した呼び出しも可能です。response.pyのis_validを読むと、InResponseToの照合はin_response_to is not None and request_id is not Noneのときだけ実行されます。request_idを渡さなければ、InResponseTo付きの応答もそのまま合格します。
つまりpython3-samlは、未要求の応答にInResponseToがあれば拒否するというSection 4.1.4.3の規則を、ライブラリ側では強制していません。IdP-initiatedを受けるかどうかにかかわらず、次の2つは利用側の責務です。
- ログイン開始時に
get_last_request_id()でIDを保存し、ACSでrequest_idに渡す - 保存したIDが無いのにInResponseToが付いた応答を、利用側のコードで拒否する
ライブラリによって、この照合を既定で行うもの、設定で切り替えるもの、利用側に任せるものがあります。ライブラリを替えたときは、照合の有無をソースで確かめてください。
標準ライブラリで受信判定とRelayState許可リストを試すコード
判定の規則は外部パッケージなしで書けます。次のacs_policy.pyは、InResponseToと保存中の要求IDの組から応答を4種類に分け、RelayStateを許可した相対パスだけに絞ります。
from urllib.parse import urlsplit
# IdP起点のRelayStateで遷移を許す相対パス(末尾が / のものは配下も許可)
ALLOWED_PATHS = ("/dashboard", "/orders/", "/reports/")
DEFAULT_PATH = "/dashboard"
def classify(in_response_to, pending_id):
"""ACSに届いた応答を、InResponseToと保存中の要求IDの組で分類する"""
if in_response_to is None:
return "unsolicited" # 仕様4.1.5どおりのIdP起点の応答
if pending_id is None:
return "reject:injected" # 要求していないのにInResponseToが付いている
if in_response_to != pending_id:
return "reject:mismatch" # 別の要求に対する応答
return "solicited" # SP起点の正常な応答
def safe_relay_state(value):
"""RelayStateを許可リストの相対パスに限って転送先に使う"""
if not value or len(value.encode("utf-8")) > 80:
return DEFAULT_PATH
parts = urlsplit(value)
if (parts.scheme or parts.netloc or not value.startswith("/")
or value.startswith("//") or "\\" in value or ".." in parts.path):
return DEFAULT_PATH
for p in ALLOWED_PATHS:
if parts.path == p or (p.endswith("/") and parts.path.startswith(p)):
return value
return DEFAULT_PATH
if __name__ == "__main__":
cases = [
(None, None),
("_req-7a1", "_req-7a1"),
("_req-7a1", None),
("_req-0ff", "_req-7a1"),
]
for irt, pending in cases:
print(f"InResponseTo={irt!s:9} pending={pending!s:9} -> {classify(irt, pending)}")
for rs in ["/orders/2026-1105", "https://evil.example/", "//evil.example/",
"/orders/../admin", "/admin", ""]:
print(f"RelayState={rs!r:26} -> {safe_relay_state(rs)}")
Python 3.14.10でpython acs_policy.pyを実行した結果です。
InResponseTo=None pending=None -> unsolicited
InResponseTo=_req-7a1 pending=_req-7a1 -> solicited
InResponseTo=_req-7a1 pending=None -> reject:injected
InResponseTo=_req-0ff pending=_req-7a1 -> reject:mismatch
RelayState='/orders/2026-1105' -> /orders/2026-1105
RelayState='https://evil.example/' -> /dashboard
RelayState='//evil.example/' -> /dashboard
RelayState='/orders/../admin' -> /dashboard
RelayState='/admin' -> /dashboard
RelayState='' -> /dashboard
スキーム付きのURL、//で始まるプロトコル相対URL、..で上の階層へ抜けるパスは、どれも既定の遷移先に置き換わります。許可リストを前方一致にする場合は、この3種を先に弾くのが要点です。
FlaskのACSで未要求応答を拒否しSP起点へ送り直す実装例
次に示すのは、上の判定をFlaskのACSに組み込む実装例です。未要求の応答を受けたら、そのアサーションではログインさせず、SP起点のログインを始め直します。利用者はIdPにログイン済みなので、画面上は一瞬の転送で終わります。
from urllib.parse import quote
from flask import Flask, request, session, redirect, abort
from onelogin.saml2.auth import OneLogin_Saml2_Auth
from acs_policy import classify, safe_relay_state
app = Flask(__name__)
app.secret_key = "change-me"
# IdPからACSへのクロスサイトPOSTでもセッションCookieを届かせる
app.config.update(SESSION_COOKIE_SAMESITE="None", SESSION_COOKIE_SECURE=True)
ALLOW_UNSOLICITED = False # IdP起点の応答でそのままログインさせるか
def saml_req():
return {
"https": "on",
"http_host": request.host,
"script_name": request.path,
"get_data": request.args.copy(),
"post_data": request.form.copy(),
}
@app.get("/saml/login")
def saml_login():
auth = OneLogin_Saml2_Auth(saml_req(), custom_base_path="saml")
url = auth.login(return_to=safe_relay_state(request.args.get("next")))
session["authn_request_id"] = auth.get_last_request_id()
return redirect(url)
@app.post("/saml/acs")
def saml_acs():
pending = session.pop("authn_request_id", None)
auth = OneLogin_Saml2_Auth(saml_req(), custom_base_path="saml")
auth.process_response(request_id=pending)
if auth.get_errors() or not auth.is_authenticated():
app.logger.warning("SAML rejected: %s", auth.get_errors())
abort(401)
kind = classify(auth.get_last_response_in_response_to(), pending)
if kind.startswith("reject"):
app.logger.warning("SAML rejected: %s", kind)
abort(401)
relay = safe_relay_state(request.form.get("RelayState"))
if kind == "unsolicited" and not ALLOW_UNSOLICITED:
# 未要求のアサーションは使わず、SP起点でログインをやり直す
return redirect("/saml/login?next=" + quote(relay, safe=""))
# ここで使用済みアサーションIDの保存と照合を行う(NotOnOrAfter+300秒まで保持)
session.clear()
session["saml_nameid"] = auth.get_nameid()
return redirect(relay)
関数名と引数は、python3-samlのauth.pyとresponse.pyのソースで照合しています。get_last_response_in_response_to()は検証に合格した応答のInResponseToを返すメソッドで、session.clear()はログイン前のセッションを引き継がないための処理です。
SameSiteの設定を外すと、IdPからのPOSTにセッションCookieが付かず、正規のSP起点の応答までreject:injectedになります。原因の切り分けとCookieを使わない保存方法はSAML HTTP POSTバインディングとは:Redirect・Artifactとの違いで解説しました。
Entra IDとKeycloakでIdP-initiatedを止めてSP起点に寄せる設定
SPを直すだけでは、ポータルのタイルから未要求の応答が届き続けます。IdP側も、タイルがSPのログイン開始URLを開く設定に変えます。
Entra IDのSign on URL入力で変わるMy Appsタイルの起動方式
Microsoft Entra IDでは、エンタープライズアプリの「基本的なSAML構成」でどちらの方式にするかが決まります。Microsoft LearnのRFPIO連携チュートリアルは、IdP起点モードでは識別子とRelay Stateを、SP起点モードではSign-on URLを入力する手順を示しています。My Appsのタイルを押したとき、SPモードならアプリのサインオンページへ転送されてログインが始まり、IdPモードならそのままサインインする、という説明です。
自社開発のSPなら、Sign on URLに設定するのは前章の/saml/loginです。Enable SAML single sign-on for an enterprise applicationの手順でも、SP側が示すSP Initiated Login URLをSign on URLに設定しています。Relay Stateは空欄のままにしておきます。
Keycloakで空欄にすると無効になるIdP起点SSO用のURL名
KeycloakのServer Administration Guide(2026年11月時点の最新は26.8系)によると、SAMLクライアントの設定項目「IDP-Initiated SSO URL name」を空欄にすると、そのクライアントのIdP起点SSOは無効になります。値を入れた場合のURLはserver-root/realms/{realm-name}/protocol/saml/clients/{client-url-name}で、送るRelayStateは「IDP Initiated SSO Relay State」で指定します。
ポータルを自作している場合も、リンク先をこのURLではなくSPのログイン開始URLにすれば、Keycloak側の設定を変えずにSP起点へ寄せられます。Amazon Cognitoを挟む構成では、IdPのIDPInitをtrueにしたときに併用できるIdPが制限されます。詳細はCognito SAML連携の手順:Entra IDでSSOを組む設定と属性マッピングを参照してください。
IdP-initiatedを有効にしてよい条件と見送るべき業務システムの場面
受託開発でSPを作るときの判断を、条件付きで書きます。
社内ポータル起点の要件はSP起点への送り直しで満たして無効のまま運用
「社員がポータルのタイルから業務システムに入れること」という要件は、IdP-initiatedを有効にしなくても満たせます。Entra IDのSign on URLかポータルのリンク先をSPのログイン開始URLにすれば、利用者の操作は同じで、SP側は要求IDで照合できます。
この要件を理由にIdP-initiatedを有効にする設計は、レビューで差し戻します。顧客情報、決済、承認ワークフローを扱う業務システムでは、ログインCSRFでデータが攻撃者のアカウントに入る影響が大きいためです。
OIDCに移る場合も考え方は同じです。OpenID Connect Core 1.0のSection 4「Initiating Login from a Third Party」は、第三者が起点のログインでも、RPのログイン開始エンドポイントへissなどを渡し、RPが通常の認証リクエストを送る形を定めています。起点がどこでも、要求を出すのはRPという設計です。
取引先IdPごとに有効化を求められたSaaSで入れる5つの防御
複数企業のIdPを受け入れるSaaSでは、取引先のIdPがSP起点のタイル設定に対応しておらず、IdP-initiatedの受け入れを契約条件にされる場合があります。その場合だけ、取引先単位で有効にし、次の5つを必ず入れます。
- 保存した要求IDが無いのにInResponseToが付いた応答を拒否する
- 使用済みアサーションIDをNotOnOrAfterと時計ずれの余裕まで保持する
- IdP側のアサーション有効期限を数分に絞るよう取引先に依頼する
- RelayStateは許可リストの相対パスだけを転送先に使う
- IdP起点でのログインを監査ログで区別し、直後の重要操作に再認証を求める
5つ入れても、自分のアカウントを持つ攻撃者によるログインCSRFは原理的に塞げません。有効化した取引先と理由は設計書に残し、取引先の設定が変わった時点で無効へ戻します。
SSOそのものの運用上の弱点はシングルサインオンのデメリット:単一障害点とID漏えいの被害拡大、費用と打ち消し方で整理しています。取引先ごとのIdP設定や起点の制御を含むSAML SPの組み込みは、一創の認証基盤・ID管理システム開発で設計から実装まで対応しています。
よくある質問
IdP-initiatedとSP-initiatedについて、設定や実装でよく出る質問をまとめました。
IdP-initiatedとSP-initiatedはどちらを使うべきですか?
新しく作るSPではSP-initiatedを使い、IdP-initiatedは無効にします。SP-initiatedはAuthnRequestのIDと応答のInResponseToを照合でき、攻撃者のアサーションを被害者のブラウザに送り込む注入を防げるためです。ポータルからの起動が必要なら、IdP側のタイルをSPのログイン開始URLへ向けて、SP起点で始めます。
IdP-initiatedでもRelayStateは使えますか?
RelayStateはIdP-initiatedでも使用可能です。OASIS Profiles仕様のSection 4.1.5は、IdPがSPとの取り決めに基づくRelayStateを付けてよいとしており、SPのURLを入れる使い方も想定しています。ただしIdP側で決まる値なので、SPは80バイト以下で許可リストにある相対パスだけを転送先に使い、それ以外は既定のページへ送ります。
IdP-initiatedのResponseにInResponseToが付いていたらどうしますか?
拒否します。仕様は未要求のResponseにInResponseToを含めてはならないと定めており、要求を出していないのに付いている応答は、別のSP起点ログインから盗まれた応答を流用した可能性があります。python3-samlはrequest_idを渡さないとこの照合を行わないため、利用側のコードで判定してください。
IdP-initiatedを無効にするとポータルからアプリを開けなくなりますか?
開けます。Entra IDならSign on URLにSPのログイン開始URLを入れると、My Appsのタイルはそこへ転送してSP起点でログインを始めます。利用者はIdPにログイン済みなので、追加の入力は発生しません。自作のポータルやKeycloakでも、リンク先をSPのログイン開始URLにすれば同じ動きになります。
SAMLの署名を検証していればIdP-initiatedでも安全ですか?
安全とは言えません。署名が証明するのは、応答を正規のIdPが作り改ざんされていないことまでです。攻撃者が自分の正規アカウントで得た応答は署名も宛先も正しいため、被害者のブラウザから送られても検証を通ります。応答と利用者の操作を結び付けるには、SP起点の要求IDとの照合が要ります。
関連記事
- SAMLとは?認証フロー・IdP/SPの仕組みとOAuth・OIDCとの使い分けを実装視点で解説:SAML全体の仕組みと認証フロー
- SAMLアサーションとは:XML構造の読み方と署名・Audience・有効期限の検証実装:アサーションの検証順序とリプレイ防止の実装
- SAML HTTP POSTバインディングとは:Redirect・Artifactとの違い:ACSへのPOSTとSameSiteの落とし穴
- SAML NameIDFormatとメタデータの不一致でSSOが失敗する原因の切り分け:SSOが失敗したときの通信の採取と切り分け
- シングルサインオンのデメリット:単一障害点とID漏えいの被害拡大、費用と打ち消し方:SSO導入時の運用上の弱点