Concepts
SP-initiated vs IdP-initiated
Two flows produce the same result — a person signed in to your application — but they start in different places, and only one of them lets you know where the person was trying to go. The difference shows up the first time a customer asks why clicking their tile always lands on the dashboard instead of the invoice they were sent.
Where the flow begins
Service-provider-initiated is the flow you design. Somebody arrives at your sign-in page, types a work email, and you look up which connection that domain belongs to and redirect them to their identity provider. When the assertion comes back you know exactly what they were doing, because you started the conversation and can carry your own state through it.
Identity-provider-initiated is the flow the customer's IT team designs. They add a tile for your application to their launcher, and clicking it POSTs an unsolicited assertion straight to your assertion consumer service URL. There was no authentication request from you, so there is no state parameter, no record of an intended destination, and nothing correlating this assertion with a browser session you were expecting.
Both are legitimate and most enterprise customers want both. The tile is how their staff find applications, and a company with two hundred internal tools will not train anybody to remember two hundred sign-in pages. Refusing to support it is possible but it is a conversation you will have during every enterprise deal.
What changes for your application
| Aspect | SP-initiated | IdP-initiated |
|---|---|---|
| Who starts it | Your sign-in page | A tile in the customer's launcher |
| Authentication request sent | Yes | No — the assertion is unsolicited |
| State parameter available | Yes, and you should sign it | No |
| Deep link preserved | Yes, carry it in your own state | No, unless the provider supports a RelayState value |
| Replay protection | Request id correlation plus assertion id | Assertion id and timestamp window only |
| Where a failure surfaces | Your sign-in page, where you control the message | The customer's launcher, where you do not |
The row that causes the most support tickets is the last one. When an IdP-initiated sign-in fails, the person sees whatever your assertion consumer endpoint returns, arrived at from a page you did not render, with no way back other than the browser's back button.
Supporting both without special cases everywhere
The trick is to converge the two flows as early as possible, so that only the entry point differs and everything after it is shared. Resolve the assertion into a profile, decide the destination, and hand off to the same session-creation code either way.
- 1Accept the unsolicited assertion at the same callback you already have, distinguishing it by the absence of a code you issued.
- 2Resolve the profile and confirm the connection is active and belongs to an organization you recognise. This is the security check that matters — an unsolicited assertion is, by definition, something a third party caused your endpoint to receive.
- 3Choose a destination deterministically: the customer's default landing page, or a per-organization setting. Do not attempt to guess from a referrer.
- 4Create the session with exactly the same code path as the SP-initiated flow, so a change to session handling cannot apply to one and not the other.
- 5Log which flow was used. When a customer reports that sign-in is broken for some people and fine for others, this single field usually explains it.
Keeping deep links working
In an SP-initiated flow the destination is yours to carry: put it in the state parameter, sign it, and read it back in the callback. Never accept an unsigned redirect target from a query string, because that is an open redirect with extra steps.
In an IdP-initiated flow you have no state of your own. Some providers can be configured to send a RelayState value, which is passed through untouched — useful, but it is configured by the customer's IT team and arrives from the browser, so validate it against an allow-list of your own paths rather than redirecting to whatever it contains.
1import { Paycux } from "@paycux/node";23const paycux = new Paycux(process.env.PAYCUX_API_KEY);45export async function POST(request: Request) {6 const form = await request.formData();7 const relayState = form.get("RelayState");89 const { profile } = await paycux.sso.getProfileAndToken({10 code: form.get("code"),11 });1213 // Hedef yalniz kendi izin listesinden secilir — tarayicidan gelen deger dogrudan kullanilmaz.14 const target = ALLOWED_PATHS.has(relayState) ? relayState : "/app";1516 await createSession(profile);17 return Response.redirect(new URL(target, process.env.APP_URL));18}
ALLOWED_PATHS is a set of paths your application owns. An allow-list is duller than parsing and validating a URL, and it is the version that is still correct after somebody adds a route with a redirect in it.
When to refuse IdP-initiated sign-in
There is a legitimate case for turning it off. An unsolicited assertion cannot be correlated with a request you made, which weakens replay protection to the assertion id and its validity window, and it cannot carry a nonce you generated. If your application handles something where that matters, disable IdP-initiated sign-in per connection and tell the customer why — most security teams accept the reasoning immediately, because it is the same argument they use internally.
If you do disable it, make the failure legible. An unsolicited assertion arriving at a connection that does not accept one should redirect the person to your sign-in page with a message telling them to start there, not return a bare error to a browser that arrived from somebody else's launcher.