Home/Journal/Teardown
TeardownHighauthenticationcognitosso

The Login Flow That Was Supposed to Be Off

A team moved to SAML SSO but left the old username-password flow and self-signup enabled. Registering through it reached internal hospital data.

Rahul Dharan··8 min read·a healthcare recognition platformAuthentication misconfiguration (CWE-287)

The setup

The target was a B2B healthcare recognition platform: software hospitals use to run staff recognition and engagement programs. It is not clinical software, but it sits inside hospital tenants and carries internal organizational data - staff, structure, and the customer records that identify each hospital using it. The product was built on a managed identity provider, AWS Cognito, with a GraphQL backend served through AWS AppSync. That is a modern, sensible stack, and the kind we see a lot as companies move enterprise customers onto single sign-on.

We looked at this class of product because enterprise-healthcare buyers push their vendors toward SSO, and SSO migrations are a reliable source of authentication gaps. The move itself is done for security. The way it is done often leaves a seam.

Recon

Working black-box, from the open internet with no account, we mapped the front end and the identity flows behind it. The client artifacts and the Cognito configuration told us how authentication was meant to work: the intent was SAML single sign-on, the enterprise pattern where a hospital’s own identity provider vouches for its users.

But the identity provider’s configuration exposes more than the intended path. Cognito user pools advertise which auth flows they will accept, and here the pool still accepted a direct username-password flow - a Cognito USER_AUTH flow - alongside the SAML intent. Self-signup was on too: the pool would let a brand-new account register itself.

The hypothesis

Our working belief was specific to this migration shape: when a team moves to SAML SSO, the old username-password login is usually left enabled by accident. Turning on the new path is the visible, celebrated work. Turning off the old one is a separate, invisible step that no user story asks for, so it slips.

So the hypothesis was that the front end might present SSO as the only way in, while the identity provider quietly still honored the legacy flow underneath. And if self-signup was also still on from the product’s early days, an outsider would not even need stolen credentials - they could mint their own account through the door the team believed was closed, then see how far it reached.

What we tried

We tested the legacy flow directly against the identity provider rather than through the app’s login screen, because the screen is not where the decision is made - the user pool is. We registered a new account through the still-enabled self-signup, then authenticated it through the leftover USER_AUTH flow. Both worked. We now held a valid, self-created identity that the front end’s design implied should not exist.

The real question was whether that identity reached anything. An authenticated session is only as serious as what the data layer will hand it. We issued a query against the AppSync GraphQL backend - the kind of read a legitimate authenticated user would make - to see whether authorization was enforced beyond the mere fact of being logged in.

It was not. The query returned internal hospital data, including a real customer record. We ran this on a test environment, where the record we retrieved confirmed the chain end to end without touching a live production tenant. We then confirmed the same leftover flow and signup posture were present in production - but we did not exercise it there. The test-environment proof was complete, and reproducing it against live customer data would have added risk without adding evidence.

What we found

The chain was three configuration facts lined up:

  1. A Cognito USER_AUTH username-password flow was still enabled despite the SAML-only intent.
  2. Open self-signup was still on, so an outsider could create their own account.
  3. The AppSync GraphQL layer authorized by authentication status, not by tenant-scoped permission, so any logged-in identity could read across.
1. self-signup -> new account created on the user pool
2. USER_AUTH flow -> the new account authenticates
3. AppSync query -> internal hospital data, including a
   real customer record, returned to the self-made account

An account that registered itself through a flow the team believed was disabled reached internal organizational data belonging to hospital customers. Severity is High rather than Critical because the exposed data is internal business and organizational data rather than clinical patient records, and because we confirmed the impact on a test environment. But the access path was real, and it was live in production.

Why it happens

No one left this open on purpose. This is what a well-intentioned security upgrade looks like when its cleanup step goes unwritten.

Enabling a new auth path and disabling the old one are two different tasks, and only the first has an obvious owner. The SSO project is scoped as “add SAML,” it succeeds, the login screen shows SSO, and everyone reasonably concludes the migration is done. The legacy USER_AUTH flow keeps working underneath because nothing explicitly turned it off. Self-signup, meanwhile, was switched on in the product’s early days when open registration was the point, and it was never revisited because normal users now arrive through SSO and never see it. The UI tells one story; the user pool tells another, and the user pool is the one that decides.

For developers

If you run a managed identity provider, four controls close this:

  1. When you move to SSO, explicitly disable the legacy auth flows. Adding SAML does not remove USER_AUTH or password flows. Turn them off on the pool as a named, verified step in the migration - not an assumption.
  2. Turn off open self-signup once you have real customers. If accounts should be provisioned through SSO or an admin, the identity provider should refuse self-registration. Leaving it on from the early days is the second half of this chain.
  3. Verify the pools, not just the app UI. The login screen showing only SSO proves nothing. Read the user-pool configuration directly and confirm which flows and signup settings are actually enabled, because that is what an attacker will read too.
  4. Authorize the data layer independently of how the caller authenticated. A GraphQL or API layer should enforce tenant-scoped, least-privilege permissions on every query, so that simply being logged in - by any path - is not enough to read across customers.

And one detection step: audit your identity provider for accounts created through the legacy flow after your SSO cutover date. Self-registrations that arrive after you believed signup was closed are worth a look.

The takeaway

Turning on a new door does not lock the old one. An SSO migration adds a path; it does not remove the paths that were there before, and the leftover username-password flow plus open self-signup is a combination we see again and again after a team believes it has moved to SSO. The durable lesson is to treat the identity provider’s configuration as the source of truth over the app’s UI, disable what you migrated away from as deliberately as you enabled what you migrated to, and never let “authenticated” alone stand in for “authorized.”

This teardown is one instance of a pattern we see repeatedly. We wrote up the general case, and how to defend against it, in When New Accounts Are Born as Admin.

Frequently asked

What is an authentication misconfiguration?
An authentication misconfiguration is when the identity system is set up in a way that leaves an unintended way in - an old login flow still enabled, self-signup left on, or a permissive default never tightened. The auth technology works as designed; the configuration allows access the team did not mean to allow.
Why does migrating to SSO leave old login flows exposed?
Enabling a new authentication path, like SAML single sign-on, does not automatically disable the old username-password path. Unless the team explicitly turns the legacy flow off on the identity provider, both remain valid, and an attacker can use the one the team stopped watching.
How do you secure an identity provider after an SSO migration?
Explicitly disable the legacy auth flows and open self-signup on the identity provider itself, verify the user-pool configuration rather than trusting the app's UI, and authorize the data layer independently so that reaching it depends on permissions, not merely on having authenticated.

This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.

How it works →