The Danger of Multi-SSO AWS Cognito User Pools
05 May 2026 - Posted by Francesco Lacerenza, Mohamed OuadAfter 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:
- The
PreSignuptrigger 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 - First federated sign-in and subsequent sign-in execution ordering only share the
TokenGenerationtrigger. Any authentication constraint applied only in one of the two chains might allow full authentication in the other - Once the user is created in the pool, there is no automatic rollback mechanism; cleanup must be handled manually
- 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:
-
Register a malicious OIDC server as an IdP (
EvilCorp) using the self-service SSO config page -
Federate with an
attacker@company.comemail -
PreSignUp_ExternalProviderfires and does not include the domain check, hence Cognito persists the user record -
PostConfirmation(the JIT provisioning Lambda) fires and the domain check throws, the session is blocked but the user record stays.PreAuthenticationis 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:
| triggerSource | When it fires / security risk |
|---|---|
InboundFederation_ExternalProvider |
fires before the user record is written on every federated sign-in, for new and returning users; skipping it means attribute checks fall to PreSignUp, which only fires on the first login |
PreSignUp_ExternalProvider |
fires when a first federated login would create a local user; missing id checks in it allow durable ghost identity |
PreSignUp_AdminCreateUser |
Usually fires on admin / SCIM creation paths |
PostConfirmation_ConfirmSignUp |
fires after confirmation, including auto-confirm on first federated login; cannot prevent user creation, only acts on an already-persisted record |
PreAuthentication_Authentication |
fires on subsequent logins only; does not fire on first federated login, so placing checks only there leaves first-login unprotected |
PostAuthentication_Authentication |
fires after every successful authentication but cannot block the session; detection and audit hook only, not a security gate |
TokenGeneration_Authentication |
fires on SDK/admin auth; different source from HostedAuth, logic applied to one is silently absent on the other |
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:
<ProviderName>_<sub>
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:
| ProviderName | Confusable codepoints | Visible rendering | Notes |
|---|---|---|---|
LegitCorp |
none (ASCII) | LegitCorp | baseline, accepted |
LеgitCorp |
е = U+0435 (Cyrillic small ie) |
LegitCorp | homoglyph “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:
| Lambda | Code | Index | Sees |
|---|---|---|---|
pre_signup (uniqueness guard) |
sub.split("_")[1] |
second token | "noise" not in pool, passes |
jit_provisioning (consumer) |
sub.split("_")[-1] |
last token | "internal@company.com", stored as custom:primaryEmail |
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:
- Does the pool register external IdPs?
- For each IdP, what is in
AttributeMapping? Anything in there is attacker-controlled if the IdP is malicious or compromised, regardless ofWriteAttributes. - How is the
PreSignUpLambda branch onevent.triggerSource? Does it coverPreSignUp_ExternalProviderandPreSignUp_AdminCreateUser, not justPreSignUp_SignUp? - 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.
- Does any Lambda parse
event.userNameorcognito:usernamewith something likesplit("_")and a positional index? If yes, the parser is fragile againstsubvalues containing_and you should look for a guard/consumer differential. - Are
IdpIdentifiersexposed 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. - Is
AttributeMappingmapping any security-sensitive custom attributes (e.g.,custom:tenantID,custom:role,custom:isAdmin)? Even withWriteAttributeslocked down, JIT Lambdas usingAdminUpdateUserAttributeswill 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!
