Skip to content

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

AspectSP-initiatedIdP-initiated
Who starts itYour sign-in pageA tile in the customer's launcher
Authentication request sentYesNo — the assertion is unsolicited
State parameter availableYes, and you should sign itNo
Deep link preservedYes, carry it in your own stateNo, unless the provider supports a RelayState value
Replay protectionRequest id correlation plus assertion idAssertion id and timestamp window only
Where a failure surfacesYour sign-in page, where you control the messageThe 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.

  1. 1Accept the unsolicited assertion at the same callback you already have, distinguishing it by the absence of a code you issued.
  2. 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.
  3. 3Choose a destination deterministically: the customer's default landing page, or a per-organization setting. Do not attempt to guess from a referrer.
  4. 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.
  5. 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.

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.

app/callback/sso/route.ts
1import { Paycux } from "@paycux/node";
2
3const paycux = new Paycux(process.env.PAYCUX_API_KEY);
4
5export async function POST(request: Request) {
6 const form = await request.formData();
7 const relayState = form.get("RelayState");
8
9 const { profile } = await paycux.sso.getProfileAndToken({
10 code: form.get("code"),
11 });
12
13 // Hedef yalniz kendi izin listesinden secilir — tarayicidan gelen deger dogrudan kullanilmaz.
14 const target = ALLOWED_PATHS.has(relayState) ? relayState : "/app";
15
16 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.