The IdP Protects the User — Even from the SPs
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.
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 "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.
That wasn't hypothetical. The mechanic was in. I took it out yesterday.
How I thought about it before
The first blueprint of an IdP implicitly assumes: 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.
In this picture, hardening is a question 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.
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 passing through SP metadata to the user less important, but more important.
The picture isn't wrong. It isn't complete.
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 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.
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.
What the SP can do if you let it
The logo is the obvious example. There's more.
javascript: URIs in the metadata. An SP supplies a URI in the javascript: scheme. If the IdP builds this URI in as a link on an auth-flow page — even if only a "back to the application" button for the error case — the SP has XSS in the trusted IdP domain.
External logo URLs. An SP supplies https://tracker.evil.com/pixel.png as the logo URL. The IdP renders that on the consent page as <img 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 "Approve".
Arbitrary redirect_uri. An SP publishes https://legitimate-app.example.com/callback as a valid callback in its DDISA manifest. But in the auth request it passes redirect_uri=https://attacker.com/steal. If the IdP doesn't validate against the published metadata but believes the request parameter, tokens flow to the attacker.
Passkey graft. A variant at the user level: an unauthenticated 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.
Each of these gaps is plausible. Each is exploitable. None is exotic.
The inversion
The point that became clear while closing these issues: this is not hardening against the SPs. This is hardening for the users against the SPs.
A different trust boundary. Not between IdP and SP — where the SP is the potential attacker on the IdP service — but between user and SP, with the IdP as the mediator in the middle. The IdP filters what the SP passes through to the user.
Concretely in code: what the user sees on the consent page is no longer a 1:1 projection of the SP metadata.
// before: what the SP delivered was what the user saw
const consentView = {
appName: sp.metadata.name,
logoUrl: sp.metadata.logo, // arbitrary source
redirectUri: req.query.redirect_uri, // arbitrary URI
};
// now: nothing SP-supplied goes to the user unchecked
const consentView = {
appName: sanitize(sp.metadata.name),
logoUrl: null, // SP-supplied logos: dropped
redirectUri: matchRegistered(
req.query.redirect_uri,
sp.metadata.redirect_uris, // exact entry or reject
),
};
Logos are dropped entirely — there's currently no curated allowlist, so there is no logo. URIs must have an https:// scheme, anything else is rejected at metadata ingest. redirect_uri from the auth request must exactly match an entry listed in the published metadata. add-credential runs only with an existing authenticated session.
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.
Default consent instead of open
The visible consequence is a default change: the policy for new SP attachments is no longer open (every SP that runs through the metadata dance can immediately authenticate users), but consent (every new SP must be explicitly allowed by the user-owner before it gets user sessions).
open was the old default because I thought of SPs as consumers — the lower the friction on attaching, the better. consent is the new default because I thought of SPs as potential attackers on users — the more explicit the allow, the more controlled the trust layer.
That's not a small config change. It's a statement about who the IdP belongs to.
One direction
Logos dropped, URIs validated, redirect_uri 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.
What holds them together is the picture behind it: which party is the one worth protecting?
If that question is answered wrong, you build the filters in the wrong place. You harden against replay (the SP annoys me) and forget the logo (the SP lies to my users). Both are real attacks, but they have different victims.
Who is the customer
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.
The user barely appears in this picture. They click "Approve" on the consent page and are gone. They see the logo, see the app name, see a permissions list — that's it. They are user agent, not customer.
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 theirs.
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.
Code: github.com/openape-ai/openape, MIT-licensed.