[{"data":1,"prerenderedAt":563},["ShallowReactive",2],{"blog-en-idp-protects-user-from-sps":3,"header-blog-translations-/en/blog/idp-protects-user-from-sps":560},{"id":4,"title":5,"author":6,"body":7,"date":544,"description":545,"draft":546,"extension":547,"image":548,"meta":549,"navigation":289,"path":550,"seo":551,"stem":552,"tags":553,"translationKey":558,"__hash__":559},"blog_en/blog/en/idp-protects-user-from-sps.md","The IdP Protects the User — Even from the SPs","Patrick Hofmann",{"type":8,"value":9,"toc":535},"minimark",[10,19,22,27,34,41,48,51,58,61,65,68,86,104,125,135,138,142,152,163,166,408,421,424,435,444,459,462,466,472,478,489,493,496,506,512,515,518,531],[11,12,13,14,18],"p",{},"A service provider publishes its metadata via DDISA — logo URL, app name, redirect URIs in a DNS-published manifest. For the next user who clicks through from there, the consent page reads ",[15,16,17],"em",{},"\"Sign in to trusted-bank-services\""," with a logo that looks like a well-known bank. It is no bank. It is an arbitrary SP deceiving the user about its identity. Phishing, through the front door of the consent page.",[11,20,21],{},"That wasn't hypothetical. The mechanic was in. I took it out yesterday.",[23,24,26],"h2",{"id":25},"how-i-thought-about-it-before","How I thought about it before",[11,28,29,30,33],{},"The first blueprint of an IdP implicitly assumes: ",[15,31,32],{},"SPs are consumers."," They publish their metadata, they fetch tokens, they send the user through the auth flow and get an identity back at the end. The IdP has two roles — it authenticates the user, and it serves the SPs.",[11,35,36,37,40],{},"In this picture, hardening is a question ",[15,38,39],{},"against abusive SPs",": against replay attacks, against quota abuse, against lateral movement between tenants. Classic API hardening. The SP is the potential attacker on the IdP service.",[11,42,43,44,47],{},"With DDISA the picture sharpens. SPs don't register — every domain in the world can publish its metadata as a DNS manifest and thereby appear as an SP, without the IdP knowing beforehand. There's no upfront vetting, no approval step before the first auth request. That doesn't make the filter on ",[15,45,46],{},"passing through"," SP metadata to the user less important, but more important.",[11,49,50],{},"The picture isn't wrong. It isn't complete.",[11,52,53,54,57],{},"What it overlooks: the user trusts the IdP, not the SP. The user has a relationship with the IdP — they have their account there, they know the branding, they expect ",[15,55,56],{},"the IdP"," to tell them what they're connecting to right now. The SP is a third party the user has no direct trust relation with. It only comes into the context via IdP mediation.",[11,59,60],{},"So if the IdP simply passes through whatever metadata the SP delivers, the SP has a channel through which it can manipulate the user directly. The IdP becomes the megaphone.",[23,62,64],{"id":63},"what-the-sp-can-do-if-you-let-it","What the SP can do if you let it",[11,66,67],{},"The logo is the obvious example. There's more.",[11,69,70,78,79,81,82,85],{},[71,72,73,77],"strong",{},[74,75,76],"code",{},"javascript:"," URIs in the metadata."," An SP supplies a URI in the ",[74,80,76],{}," scheme. If the IdP builds this URI in as a link on an auth-flow page — even if only a ",[15,83,84],{},"\"back to the application\""," button for the error case — the SP has XSS in the trusted IdP domain.",[11,87,88,91,92,95,96,99,100,103],{},[71,89,90],{},"External logo URLs."," An SP supplies ",[74,93,94],{},"https://tracker.evil.com/pixel.png"," as the logo URL. The IdP renders that on the consent page as ",[74,97,98],{},"\u003Cimg src>",". Every user going through the consent page loads the pixel — the SP knows which users even get to see it, before they ever clicked ",[15,101,102],{},"\"Approve\"",".",[11,105,106,112,113,116,117,120,121,124],{},[71,107,108,109,103],{},"Arbitrary ",[74,110,111],{},"redirect_uri"," An SP publishes ",[74,114,115],{},"https://legitimate-app.example.com/callback"," as a valid callback in its DDISA manifest. But in the auth request it passes ",[74,118,119],{},"redirect_uri=https://attacker.com/steal",". If the IdP doesn't validate against the ",[15,122,123],{},"published"," metadata but believes the request parameter, tokens flow to the attacker.",[11,126,127,130,131,134],{},[71,128,129],{},"Passkey graft."," A variant at the user level: an unauthenticated ",[74,132,133],{},"add-credential"," endpoint allows attaching another passkey to an existing account. Without hard session auth in front of it, an attacker can graft their own passkey onto the account — and then has legitimate permanent access.",[11,136,137],{},"Each of these gaps is plausible. Each is exploitable. None is exotic.",[23,139,141],{"id":140},"the-inversion","The inversion",[11,143,144,145,148,149,103],{},"The point that became clear while closing these issues: this is not ",[15,146,147],{},"hardening against the SPs",". This is hardening ",[15,150,151],{},"for the users against the SPs",[11,153,154,155,158,159,162],{},"A different trust boundary. Not between IdP and SP — where the SP is the potential attacker on the IdP service — but between ",[15,156,157],{},"user"," and ",[15,160,161],{},"SP",", with the IdP as the mediator in the middle. The IdP filters what the SP passes through to the user.",[11,164,165],{},"Concretely in code: what the user sees on the consent page is no longer a 1:1 projection of the SP metadata.",[167,168,173],"pre",{"className":169,"code":170,"language":171,"meta":172,"style":172},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","// before: what the SP delivered was what the user saw\nconst consentView = {\n  appName:     sp.metadata.name,\n  logoUrl:     sp.metadata.logo,         // arbitrary source\n  redirectUri: req.query.redirect_uri,   // arbitrary URI\n};\n\n// now: nothing SP-supplied goes to the user unchecked\nconst consentView = {\n  appName:     sanitize(sp.metadata.name),\n  logoUrl:     null,                     // SP-supplied logos: dropped\n  redirectUri: matchRegistered(\n    req.query.redirect_uri,\n    sp.metadata.redirect_uris,           // exact entry or reject\n  ),\n};\n","ts","",[74,174,175,184,202,228,253,278,284,291,297,308,333,346,359,375,395,403],{"__ignoreMap":172},[176,177,180],"span",{"class":178,"line":179},"line",1,[176,181,183],{"class":182},"sHwdD","// before: what the SP delivered was what the user saw\n",[176,185,187,191,195,199],{"class":178,"line":186},2,[176,188,190],{"class":189},"spNyl","const",[176,192,194],{"class":193},"sTEyZ"," consentView ",[176,196,198],{"class":197},"sMK4o","=",[176,200,201],{"class":197}," {\n",[176,203,205,209,212,215,217,220,222,225],{"class":178,"line":204},3,[176,206,208],{"class":207},"swJcz","  appName",[176,210,211],{"class":197},":",[176,213,214],{"class":193},"     sp",[176,216,103],{"class":197},[176,218,219],{"class":193},"metadata",[176,221,103],{"class":197},[176,223,224],{"class":193},"name",[176,226,227],{"class":197},",\n",[176,229,231,234,236,238,240,242,244,247,250],{"class":178,"line":230},4,[176,232,233],{"class":207},"  logoUrl",[176,235,211],{"class":197},[176,237,214],{"class":193},[176,239,103],{"class":197},[176,241,219],{"class":193},[176,243,103],{"class":197},[176,245,246],{"class":193},"logo",[176,248,249],{"class":197},",",[176,251,252],{"class":182},"         // arbitrary source\n",[176,254,256,259,261,264,266,269,271,273,275],{"class":178,"line":255},5,[176,257,258],{"class":207},"  redirectUri",[176,260,211],{"class":197},[176,262,263],{"class":193}," req",[176,265,103],{"class":197},[176,267,268],{"class":193},"query",[176,270,103],{"class":197},[176,272,111],{"class":193},[176,274,249],{"class":197},[176,276,277],{"class":182},"   // arbitrary URI\n",[176,279,281],{"class":178,"line":280},6,[176,282,283],{"class":197},"};\n",[176,285,287],{"class":178,"line":286},7,[176,288,290],{"emptyLinePlaceholder":289},true,"\n",[176,292,294],{"class":178,"line":293},8,[176,295,296],{"class":182},"// now: nothing SP-supplied goes to the user unchecked\n",[176,298,300,302,304,306],{"class":178,"line":299},9,[176,301,190],{"class":189},[176,303,194],{"class":193},[176,305,198],{"class":197},[176,307,201],{"class":197},[176,309,311,313,315,319,322,324,326,328,331],{"class":178,"line":310},10,[176,312,208],{"class":207},[176,314,211],{"class":197},[176,316,318],{"class":317},"s2Zo4","     sanitize",[176,320,321],{"class":193},"(sp",[176,323,103],{"class":197},[176,325,219],{"class":193},[176,327,103],{"class":197},[176,329,330],{"class":193},"name)",[176,332,227],{"class":197},[176,334,336,338,340,343],{"class":178,"line":335},11,[176,337,233],{"class":207},[176,339,211],{"class":197},[176,341,342],{"class":197},"     null,",[176,344,345],{"class":182},"                     // SP-supplied logos: dropped\n",[176,347,349,351,353,356],{"class":178,"line":348},12,[176,350,258],{"class":207},[176,352,211],{"class":197},[176,354,355],{"class":317}," matchRegistered",[176,357,358],{"class":193},"(\n",[176,360,362,365,367,369,371,373],{"class":178,"line":361},13,[176,363,364],{"class":193},"    req",[176,366,103],{"class":197},[176,368,268],{"class":193},[176,370,103],{"class":197},[176,372,111],{"class":193},[176,374,227],{"class":197},[176,376,378,381,383,385,387,390,392],{"class":178,"line":377},14,[176,379,380],{"class":193},"    sp",[176,382,103],{"class":197},[176,384,219],{"class":193},[176,386,103],{"class":197},[176,388,389],{"class":193},"redirect_uris",[176,391,249],{"class":197},[176,393,394],{"class":182},"           // exact entry or reject\n",[176,396,398,401],{"class":178,"line":397},15,[176,399,400],{"class":193},"  )",[176,402,227],{"class":197},[176,404,406],{"class":178,"line":405},16,[176,407,283],{"class":197},[11,409,410,411,414,415,417,418,420],{},"Logos are dropped entirely — there's currently no curated allowlist, so there is no logo. URIs must have an ",[74,412,413],{},"https://"," scheme, anything else is rejected at metadata ingest. ",[74,416,111],{}," from the auth request must exactly match an entry listed in the published metadata. ",[74,419,133],{}," runs only with an existing authenticated session.",[11,422,423],{},"The SP is not the IdP's customer. The user is the IdP's customer. The SP is a third party the IdP has a duty of protection toward — toward the user.",[23,425,427,428,431,432],{"id":426},"default-consent-instead-of-open","Default ",[74,429,430],{},"consent"," instead of ",[74,433,434],{},"open",[11,436,437,438,440,441,443],{},"The visible consequence is a default change: the policy for new SP attachments is no longer ",[74,439,434],{}," (every SP that runs through the metadata dance can immediately authenticate users), but ",[74,442,430],{}," (every new SP must be explicitly allowed by the user-owner before it gets user sessions).",[11,445,446,448,449,452,453,455,456,103],{},[74,447,434],{}," was the old default because I thought of SPs as consumers — ",[15,450,451],{},"the lower the friction on attaching, the better",". ",[74,454,430],{}," is the new default because I thought of SPs as potential attackers on users — ",[15,457,458],{},"the more explicit the allow, the more controlled the trust layer",[11,460,461],{},"That's not a small config change. It's a statement about who the IdP belongs to.",[23,463,465],{"id":464},"one-direction","One direction",[11,467,468,469,471],{},"Logos dropped, URIs validated, ",[74,470,111],{}," mapped against the metadata, passkey graft closed, a hardening batch, default flipped. None of these is a dramatic architecture refactoring — each one is a few lines of code, an additional filter, a removed path.",[11,473,474,475],{},"What holds them together is the picture behind it: ",[15,476,477],{},"which party is the one worth protecting?",[11,479,480,481,484,485,488],{},"If that question is answered wrong, you build the filters in the wrong place. You harden against replay (",[15,482,483],{},"the SP annoys me",") and forget the logo (",[15,486,487],{},"the SP lies to my users","). Both are real attacks, but they have different victims.",[23,490,492],{"id":491},"who-is-the-customer","Who is the customer",[11,494,495],{},"An IdP feels at first like a service for SPs. They're the ones who use the API, trigger OAuth flows, fetch tokens. The ones who read documentation and open tickets. They are visible.",[11,497,498,499,501,502,505],{},"The user barely appears in this picture. They click ",[15,500,102],{}," on the consent page and are gone. They see the logo, see the app name, see a permissions list — that's it. They are ",[15,503,504],{},"user agent",", not customer.",[11,507,508,509,103],{},"But they're the only actor who really lost when something goes wrong. The SP that abused a logo got its account suspended in the worst case. The user who fell for the logo lost their identity in the worst case — which they can't lock out, because it's ",[15,510,511],{},"theirs",[11,513,514],{},"The IdP is not the SPs' servant. It's the mediator between user and SP. And in that mediator role its loyalty belongs to the party that can't go back in case of damage.",[516,517],"hr",{},[11,519,520],{},[15,521,522,523,530],{},"Code: ",[524,525,529],"a",{"href":526,"rel":527},"https://github.com/openape-ai/openape",[528],"nofollow","github.com/openape-ai/openape",", MIT-licensed.",[532,533,534],"style",{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":172,"searchDepth":186,"depth":186,"links":536},[537,538,539,540,542,543],{"id":25,"depth":186,"text":26},{"id":63,"depth":186,"text":64},{"id":140,"depth":186,"text":141},{"id":426,"depth":186,"text":541},"Default consent instead of open",{"id":464,"depth":186,"text":465},{"id":491,"depth":186,"text":492},"2026-05-05","A service provider uploads its logo and can use it to deceive the user about its identity on the consent page. A note from a hardening sprint in which I realized the party worth protecting isn't the SP.",false,"md",null,{},"/blog/en/idp-protects-user-from-sps",{"title":5,"description":545},"blog/en/idp-protects-user-from-sps",[554,555,556,557],"OpenApe","Identity","Security","Building in Public","idp-protects-user-from-sps","2fmmj-_4H8obokMGoFHUJ9klzwgeaNGC1ODgKUeiXhA",{"en":561,"de":562},"/en/blog/idp-protects-user-from-sps","/de/blog/der-idp-schuetzt-den-user-auch-vor-den-sps",1779001886805]