# The Danger of Multi-SSO AWS Cognito User Pools

05 May 2026 – Posted by Francesco Lacerenza, Mohamed Ouad

After a small detour, the **CloudSecTidbits** series is back with new episodes. We had the opportunity to present them at the first DEFCON in Singapore few days ago during our DemoLabs sessions. Meeting Singapore’s community was indeed amazing – thanks again for having us!

#### From the Previous Episodes

CloudSec Tidbits is a blogpost series showcasing interesting bugs found by Doyensec during cloud security testing activities.

We focus on vulnerabilities resulting from an insecure combination of web and cloud related technologies.

Every article includes an Infrastructure as Code (IaC) laboratory that can be easily deployed to experiment with the described vulnerability.

Time to get ready and dive into a new tidbit.

## Tidbit No. 4 – The Danger of Multi-SSO User Pools

What is AWS Cognito? If you need a refresher, you can start by reading the initial AWS Cognito introduction we did back in S1 Ep.2, Tampering User Attributes In AWS Cognito User Pools.

This time we leave simple setups behind and walk through the kind of multi-tenant Cognito deployment that is becoming the SaaS default: one User Pool, many tenants, and each tenant bringing “their” external IdP.

### AWS Cognito Multi-SSO Flows

With Cognito User Pools, developers can register multiple external IdPs (OIDC and SAML) against a single pool and expose them via the hosted UI (managed login page), or via a custom login page that still hits the hosted SSO endpoints.

External IdPs are registered through the `CreateIdentityProvider` API. A minimal OIDC registration looks like this:

Of course, such a creation is typically made by the backend of the platform supporting custom IdP settings for its tenants.

### Introducing a New Actor, AWS Lambda Triggers Primer

Triggers are **synchronous hooks** that allow developers to embed custom logic into event-driven flows.

When it comes to Cognito, the service invokes multiple _triggers_ at specific stages of user creation and authentication through SSO. They stop the SSO authentication flow and allow custom logic to accept, reject, or modify it. In a normal implementation, they end up carrying all the “identity glue” required by the platform to be coherent with its other identity constraints: domain allowlists and ownership checks, tenant restrictions, JIT provisioning, attribute normalization, token shaping and so on.

The clearest way to think about it is by mapping the SSO triggers execution order and event types. Below you can find our go-to boundary guide for identity checks within the numerous triggers.

The main takeaways from a security perspective are:

1. The `PreSignup` trigger is the only gate before the actual user object creation in the Cognito User Pool. Any identity landed in the pool could be interacted with by exploiting other features in the platform
2. First federated sign-in and subsequent sign-in execution ordering only share the `TokenGeneration` trigger. Any authentication constraint applied only in one of the two chains might allow full authentication in the other
3. Once the user is created in the pool, there is no automatic rollback mechanism; cleanup must be handled manually
4. Federated sign-in does not invoke any other custom authentication challenge, migrate user, custom message, or custom sender triggers in your user pool

### What if the IdP Is Malicious? Full Flow Example

In the example below we see what happens when an external OIDC IdP is involved, Cognito performs a full OIDC code flow, fetching `/userinfo`, and merging claims according to the setup defined at creation.

The high-resolution SVG file can be downloaded here.

A malicious IdP could attack the platform relying on the multi-SSO Cognito User Pools in different ways, depending on constraints and the complex identity logic embedded in it.

**Now we have everything**: an extra injection point as malicious IdP talking to AWS Cognito, a set of complex triggers gluing together the labyrinth of identity constraints.

Let’s go through the possible anti-patterns that might introduce bugs:

### 1. JIT Ghost Identity Injection: Sometimes Landing Is Enough

As mentioned before, the trigger `PreSignUp_ExternalProvider` is the only one that fires _before_ Cognito has persisted the user record in the pool.

Getting a ghost identity is straightforward most of the time:

1. Register a malicious OIDC server as an IdP (
`EvilCorp`) using the self-service SSO config page
2. Federate with an
`attacker@company.com` email
3. `PreSignUp_ExternalProvider` fires and does **not** include the domain check, hence Cognito persists the user record
4. `PostConfirmation`(the JIT provisioning Lambda) fires and the domain check throws, the **session** is blocked but the **user record** stays. `PreAuthentication` is configured with the same check too, but SSO is not the only way to interact with a user

From that point, even if there are rollback mechanisms that will delete it, you have an operational window where it is possible to abuse other features of the platform and interact with such identity. Worst case scenarios include a _forceful password reset_ to gain non-SSO auth capability, _impersonation_ of a user to get direct session and so on.

**Tip:** Weird escapes and other means of injections in other fields could lend you a vast range of vulnerabilities. Always review the components reading the identity object as a whole.

### 2. Trigger Source Values: Forgotten Events

Cognito distinguishes creation and authentication paths through multiple `event.triggerSource` values. The `triggerSource` is the named info given to the custom handlers to understand the identity event and act consequently.

**There are many values, some might get lost or misinterpreted by developers, introducing vulnerabilities.**

The core values relevant to any multi-SSO security review are:

triggerSourceWhen it fires / security riskfires before the user record is written on every federated sign-in, for new and returning users; skipping it means attribute checks fall to fires when a first federated login would create a local user; missing id checks in it allow durable ghost identityUsually fires on admin / SCIM creation pathsfires after confirmation, including auto-confirm on first federated login; cannot prevent user creation, only acts on an already-persisted recordfires on subsequent logins only; does not fire on first federated login, so placing checks only there leaves first-login unprotectedfires after every successful authentication but cannot block the session; detection and audit hook only, not a security gatefires on SDK/admin auth; different source from

The complete reference with every possible `triggerSource` lives in the Lambda triggers documentation.

## 3. Federated Username Format & the Sub-Splitting Attack

Cognito’s internal identity key for federated users is **not** the email, it is:

“`
_
“`

This appears as `event.userName` in triggers and as `cognito:username` in tokens. `ProviderName` is the IdP name registered in the pool and `sub` is the IdP subject identifier (attacker-controlled if the IdP is malicious).

#### Provider Collision: Case and Homoglyph

Cognito enforces uniqueness on byte-equal `ProviderName`, but two IdPs whose names are visually similar but byte-distinct are accepted in the same pool.

As an example:

ProviderNameConfusable codepointsVisible renderingNotesnone (ASCII)LegitCorpbaseline, acceptedLegitCorphomoglyph “e”, accepted on the same pool

This is dangerous because most human-facing places do not surface the difference: Hosted UI buttons, audit logs, CLI output, and grep-based audits all just render Unicode and move on. Moreover, things could get even worse in case of parser differentials caused by an application then normalizes inconsistently ( `lower()`, `NFKC`, etc.), it could end up with split identities for the same IdP, or lookups resolving to the wrong record.

#### Sub-Level Splitting Attack

The `ProviderName` regex forbids `_`. The `sub` claim does not. The complete identity string can therefore contain multiple underscores:

“`
Corp_admin_override
“`

If component **A** reads `split(“_”, 1)` and component **B** reads `split(“_”)[-1]` (or any other positional index), the same input produces two different meanings.

Sending `sub = EVIL_noise_internal@company.com` from the malicious IdP would result in:

LambdaCodeIndexSeessecond tokenlast token

### 4. IdP Identifiers and Routing Hijacks

IdP identifiers are the strings Cognito uses for IdP redirection. The standard pattern is email-domain routing: a user types `user@company.com`, Cognito looks up `company.com`, and the browser is redirected to the IdP that owns that identifier.

> Controlling an identifier effectively controls the initial redirection for all users of that identifier.

Hence, if a tenant drops or avoids registering an identifier, another IdP could claim it in the gap. As AWS Cognito does not ensure domain ownership, the platform itself **should never allow** claiming an **idp-identifier** without checking in advance that the tenant controls it.

It is a classic takeover of a domain with very dangerous outcomes. As an example, if `gmail.com` is claimable via a custom IdP configuration in a platform, you might end up redirecting every Google user to an attacker-controlled page.

## Do Not Trust the IdP

Multi-SSO changes which triggers fire, what the application treats as the identity key, and how many attacker-controlled strings you accidentally parse as structure. A control placed on the wrong trigger creates ghost identities, a parser placed on attacker-controlled `sub` values creates privilege escalation, or a self-service `IdpIdentifiers` field creates a routing hijack window.

## For Cloud Security Auditors

While reviewing a Cognito-backed multi-tenant platform, answer the following questions:

1. Does the pool register external IdPs?
2. For each IdP, what is in `AttributeMapping`? Anything in there is **attacker-controlled** if the IdP is malicious or compromised, regardless of `WriteAttributes`.
3. How is the `PreSignUp` Lambda branch on `event.triggerSource`? Does it cover `PreSignUp_ExternalProvider` _and_ `PreSignUp_AdminCreateUser`, not just `PreSignUp_SignUp`?
4. Are all identity checks covered in both the trigger chains for JIT and subsequent SSO sign-in? If not, you should check for unwanted identities creation.
5. Does any Lambda parse `event.userName` or `cognito:username` with something like `split(“_”)` and a positional index? If yes, the parser is fragile against `sub` values containing `_` and you should look for a guard/consumer differential.
6. Are `IdpIdentifiers` exposed in self-service IdP registration UIs? If yes, does the platform ensure that a domain id is being claimed by a tenant that confirmed its ownership? If not, arbitrary redirection of incoming users with unclaimed domains is possible.
7. Is `AttributeMapping` mapping any security-sensitive custom attributes (e.g., `custom:tenantID`, `custom:role`, `custom:isAdmin`)? Even with `WriteAttributes` locked down, JIT Lambdas using `AdminUpdateUserAttributes` will write them.

## For Developers

**Place security gates in `PreSignUp`, branched per `triggerSource`.** This is the single most impactful change for multi-SSO deployments. A working pattern:

“`
def lambda_handler(event, context): if event[“triggerSource”] in ( “PreSignUp_SignUp”, “PreSignUp_ExternalProvider”, “PreSignUp_AdminCreateUser”, ): enforce_domain_policy(event[“request”][“userAttributes”][“email”]) return event
“`

**Never do `split(“_”)` `event.userName` to extract identity.** If you must parse it, use `split(“_”, 1)` ( `maxsplit=1`) **everywhere it is parsed**. The guard and the consumer must use identical extraction logic, positional indices on attacker-controlled strings are a parser differential vulnerability waiting to happen.

**Keep security-relevant custom attributes out of `AttributeMapping`.** Derive `tenantID` and similar fields server-side from a verified email domain inside a trigger, never read them from `event.request.userAttributes` after federation.

**Validate `email` strictly in `PreSignUp`**.

**For `IdpIdentifiers`:** never expose them as a free-form field in self-service IdP registration. In IaC, register identifiers atomically. Do not “drop then add” in the same apply.

## Tool Release: maSSO, a Malicious IdP for the Job

Almost every abuse described above assumes the same primitive: an attacker-controlled IdP that a Service Provider trusts, and the ability to tamper with the exact tokens, SAML assertions, and `/userinfo` payloads that reach it.

Running custom IdPs just for testing purposes was time-consuming, so we decided to release the one we use during pentests: doyensec/maSSO

maSSO is a weaponized compliant Single Sign-On (SSO) Identity Provider (IdP) for security testing of OIDC and SAML 2.0 Service Providers, also supporting the SCIM protocol.

For us, it was the missing Swiss Army knife for actual SP testing. Let us know your feedback!

## Hands-On IaC Lab

As promised in the series’ introduction, we developed a Terraform (IaC) laboratory to deploy a vulnerable dummy application and play with the vulnerability: https://github.com/doyensec/cloudsec-tidbits/tree/main/lab-masso

Stay tuned for the next episode!