Common Login Portal

Designing an SSO handshake with no standard

Ashish Bhagat · 13 Aug 2026

CLP's job doesn't end when a user authenticates. Once someone signs in via PKI/SAML or the OTP fallback, they need to land in whichever of the downstream port and logistics applications they're entitled to — ACS, PCS, LPM, and others — without signing in again anywhere. That's a second handshake, entirely separate from the one that authenticated them in the first place, and I couldn't find a standard that actually covered it.

The problem SAML and OIDC don't solve

It's worth being specific about why this isn't just "SSO, but internal." A user signing into CLP has already proven who they are, against a government-grade credential, through a flow that took real engineering effort in its own right. Making them do anything resembling that again to reach ACS or PCS a few seconds later would be a genuinely bad experience for something that's supposed to be invisible — the entire point of a single sign-on layer is that the user only notices it when it fails. But "don't ask the user again" still leaves the actual trust problem unsolved: the downstream app has no independent way to know this request is legitimate unless CLP gives it something to check.

SAML and OIDC are both built around the same shape: a browser, a user, an identity provider, and one service provider being told who that user is. That's the handshake CLP itself uses to authenticate the user against the ministry's PKI. It is not the same problem as what happens next — CLP is already the identity provider at that point, from the downstream apps' point of view, and needs to silently vouch for a user to a second, independently-operated application, without asking the user to do anything and without handing that application a credential it could reuse indefinitely. Something in the general shape of OAuth's token exchange grant, RFC 8693, addresses a similar problem in the abstract — trading one token for another, scoped differently — but it's a general-purpose building block, not a specification for "downstream government application accepts a short-lived vouch from an internal SSO layer it has no other trust relationship with." Nobody had written that down for this situation, so the protocol had to be designed rather than adopted.

What shipped

The organization-membership piece matters as much as the identity piece. A downstream app doesn't just need to know who's arriving — it needs to know which organization they're arriving on behalf of, since CLP's whole registration model is organization-centric. Returning both in one server-to-server exchange means the downstream app never has to make a second round trip, or worse, trust a client-supplied organization ID it can't independently verify.

The shape that shipped: CLP issues a short-lived, single-use SSO token, tied to a specific user and a specific downstream application — not a general-purpose credential, not reusable, not valid for any app other than the one it was minted for. The downstream application takes that token and validates it with a direct server-to-server call back to CLP, and gets back the user's identity and organization membership in response. At no point does a long-lived credential — a password, a refresh token, anything reusable — cross the boundary between CLP and the downstream app. The token that does cross is good for exactly one exchange, for exactly one app, and then it's spent.

Single-use matters as much as short-lived does. A token that's still valid after its first successful exchange is a token that can be replayed — captured from a log, a browser history entry, a proxy in between — and used again by whoever captured it, for as long as its expiry window allows. Making the token expire immediately on first successful redemption, not just after a fixed time window, closes that off: even a token intercepted in transit is worthless to a second party the moment the legitimate first exchange has already happened. Short-lived limits the blast radius of a token nobody's used yet. Single-use limits the blast radius of one somebody already has.

The variants that didn't survive contact

The protocol didn't arrive at that shape on paper. It arrived there by being tried against each downstream team's actual implementation and adjusted based on what broke. Which HTTP header the token should travel in, whether the exchange should be a single call or a two-step request-then-confirm sequence — these read like implementation details until you're on a call with a downstream team whose framework has firm opinions about where a token is supposed to live, and your first design doesn't match those opinions. Each downstream application — ACS, PCS, LPM — had its own set of assumptions that only became visible once integration testing actually started, not before. A protocol that looked complete against one downstream app's expectations would hit a mismatch against the next one's, and the design absorbed each of those mismatches as it went, rather than trying to anticipate all of them in a single upfront specification.

That's the honest description of "designed from scratch, iterated through several variants" — not a clean top-down design that got implemented once, but a protocol that only converged on its final shape after enough real counterparties had pushed back on the earlier ones.

There's a reasonable question underneath all of this: why not just use an existing delegation protocol wholesale, rather than design something bespoke? The honest answer is that the standard delegation protocols solve a slightly different problem than the one in front of me. OAuth's authorization code flow, and token exchange on top of it, assume a registered client relationship and typically a user actively consenting to a specific scope at the point of exchange. What CLP needed was closer to an internal identity platform vouching silently for a user it had already authenticated, to a fixed, known set of downstream government applications it operates alongside rather than a broad ecosystem of third-party clients. Bending a general-purpose delegation protocol to fit that narrower shape is itself a design decision with real trade-offs, and building the narrower protocol on purpose, with its own explicit constraints, was the more honest option than adopting a heavier standard and only using a fraction of what it specifies.

What I'd do differently

The ACS integration specifically went through more rework cycles than the others, because ACS's own requirements for the handshake weren't fully settled on the first pass — which meant iterating the protocol against a moving target rather than a fixed one. If I did this again, I'd push harder up front to get each downstream team's constraints written down and confirmed before writing the first line of the handshake implementation, rather than treating the first integration attempt as the discovery process for what a counterparty actually needed. Live integration testing is a good way to find out where a design is wrong. It's an expensive way to find out what a counterparty's requirements were supposed to be in the first place.


More from this series:

Hand-rolling SOAP clients against undocumented APIs · Hexagonal architecture you can verify with grep