DEV Community

Bala Paranj
Bala Paranj

Posted on

Cognito With the Safety Off: MFA Disabled, Advanced Security Disabled

✓ Human-authored analysis; AI used for formatting and proofreading.

A Cognito user pool is an identity perimeter. The pool authenticates customers, issues JWTs the application trusts, and brokers federation to social identity providers. Whatever else the application does for security such as encryption-at-rest, network isolation, audit logging has the user pool decide whether this session gets to call the API at all.

By default, when you create a Cognito user pool through the console or via aws cognito-idp create-user-pool, two settings are off:

  • MfaConfiguration: OFF
  • UserPoolAddOns.AdvancedSecurityMode: OFF

Both defaults look small in the console with checkboxes you can flip later. Both defaults are how the bomma report on HackerOne and a long tail of similar disclosures end up shipped to production. The one that gets fixed quickly is MFA. The one that frequently doesn't is Advanced Security.

What "MFA Off" Costs You

The single-factor configuration looks like this in the observation:

{
  "identity": {
    "kind": "user_pool",
    "auth": {
      "mfa_enforced": false,
      "mfa_configuration": "OFF"
    },
    "advanced_security": {
      "enabled": false
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

mfa_configuration: OFF means: passwords alone authenticate. Three attack patterns become trivial:

Credential stuffing. Attackers buy email/password pairs from data-breach dumps. Each pair tests against the application's Cognito sign-in. Attempts that succeed land in valid sessions; the application has no way to tell a credentialed attacker apart from the legitimate user.

Password spraying. A reverse pattern where the attacker takes one weak password (Summer2025!, Welcome2026!) and tries it against thousands of known-valid usernames. Cognito's per-user lockout fires on the wrong threshold (multiple failures against one account); a one-failure-per-account spray walks right past it.

Phishing-recovered passwords. A successful phishing campaign yields the user's password. With MFA off, that's the entire authentication factor; the attacker signs in directly.

advanced_security: enabled: false is the layer that would catch these. Cognito's Advanced Security features run a risk model on each sign-in attempt (unusual location, impossible travel, known compromised credentials from external feeds) and either challenge the user with MFA, block the sign-in, or notify the user. With it off, none of that runs. The risk model that AWS built into the service is feature-flagged behind a paywall the operator chose not to pay.

What The Bomma Report Showed

Bomma's HackerOne report described an account-takeover chain that started with two configuration choices:

  1. The user pool used email as the username alias.
  2. The user pool did not require email verification on change.

Together these mean: an attacker who can update a victim's email (via a separate flaw, e.g., parameter pollution in a profile-update endpoint) can change the victim's email to one the attacker controls, then reset the password using the standard "forgot password" flow.

MFA-off made the password reset also the session takeover. With MFA on, the attacker would need the victim's MFA factor too and the attacker doesn't have it. With Advanced Security on, the unusual-location sign-in that follows the password reset would have challenged the attacker for additional verification. With both off, the password reset is the breach.

The other Cognito attributes (email_change_verification_enforced: false, alias_attributes: ["email"]) are the upstream contributors. The CTL.COGNITO.INCOMPLETE.001 control catches that exact shape. This article focuses on the MFA leg of the cluster because MFA is the simplest, highest-leverage fix.

The System Invariant

Customer-facing Cognito user pools must enforce MFA.

The phrasing is intentional. Internal-only pools (for back-office tools used only by employees with separate SSO) sometimes legitimately disable MFA at the pool level because the upstream IdP enforces it. That carve-out is enumerated by scope_tags on the asset; the control fires by default and consumers opt out at the pool level when the carve-out applies.

In Stave's observation schema:

{
  "id": "arn:aws:cognito-idp:us-east-1:111122223333:userpool/us-east-1_acmeApp",
  "type": "aws_cognito_user_pool",
  "properties": {
    "identity": {
      "kind": "user_pool",
      "auth": {
        "mfa_enforced": false,
        "mfa_configuration": "OFF"
      },
      "advanced_security": {
        "enabled": false
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

mfa_enforced is the engine's verdict (true only when MfaConfiguration is ON and at least one second factor such as TOTP or SMS is enabled); advanced_security.enabled carries the adaptive-auth state.

The Stave Control

id: CTL.COGNITO.MFA.001
name: Cognito User Pool Must Enforce MFA
severity: high
unsafe_predicate:
  all:
    - field: properties.identity.kind
      op: eq
      value: user_pool
    - field: properties.identity.auth.mfa_enforced
      op: eq
      value: false
Enter fullscreen mode Exit fullscreen mode

Two leaf clauses. Severity is high. The user pool is a customer-facing perimeter, but the control fires on capability, not confirmed compromise.

Why Z3 Doesn't Help

The collector observes booleans, the predicate evaluates them. No search space, no quantification.

A reachability question: "given this pool's app-client configuration, is there an ExplicitAuthFlows value that bypasses MFA even when MfaConfiguration: ON?" would be Z3-shaped. That's checked by CTL.COGNITO.MFA.ENFORCE.001 at the app-client layer. Not in scope here.

Reproducing The Detection

The repository ships a self-contained example at stave/examples/cognito-no-mfa-advanced-security/:

go run ./examples/cognito-no-mfa-advanced-security before
Enter fullscreen mode Exit fullscreen mode

Captured stdout:

=== before (MFA off, advanced security off) ===
  status: NON_COMPLIANT   total_assets=1   violations=1
  CTL.COGNITO.MFA.001 fired on 1 asset(s):
    - arn:aws:cognito-idp:us-east-1:111122223333:userpool/us-east-1_acmeApp   severity=high   exposure_score=76.64
  assertion: fires=true (expected) ✓
Enter fullscreen mode Exit fullscreen mode

After both flags are turned on:

=== after  (MFA enforced, advanced security on) ===
  status: COMPLIANT   total_assets=1   violations=0
  CTL.COGNITO.MFA.001: no findings
  assertion: fires=false (expected) ✓
Enter fullscreen mode Exit fullscreen mode

The Remediation

Two aws calls and a Terraform-or-equivalent change for permanence:

aws cognito-idp set-user-pool-mfa-config \
  --user-pool-id us-east-1_acmeApp \
  --mfa-configuration ON \
  --software-token-mfa-configuration Enabled=true

aws cognito-idp update-user-pool \
  --user-pool-id us-east-1_acmeApp \
  --user-pool-add-ons AdvancedSecurityMode=ENFORCED
Enter fullscreen mode Exit fullscreen mode

In Terraform:

resource "aws_cognito_user_pool" "main" {
  name = "acme-customer-portal-pool"

  mfa_configuration = "ON"
  software_token_mfa_configuration {
    enabled = true
  }

  user_pool_add_ons {
    advanced_security_mode = "ENFORCED"
  }
}
Enter fullscreen mode Exit fullscreen mode

The ENFORCED mode (vs AUDIT) turns Advanced Security from "log the risk" into "challenge or block." AUDIT mode is occasionally the right choice for greenfield rollouts where the team needs to see what the risk model would have done before turning the enforcement on; for steady-state, ENFORCED is the target.

The Prevention Lesson

Three layers, by priority:

Account-creation default. The IaC module that creates a Cognito user pool sets mfa_configuration = "ON" and advanced_security_mode = "ENFORCED" by default. Pools that legitimately need different settings (back-office tools fronted by SSO, etc.) opt out via an explicit flag the module author has to set deliberately. The default is the safe configuration.

Console-creation guard via SCP. Most operators create user pools via the AWS console. A Service Control Policy denies cognito-idp:CreateUserPool from any non-bootstrap principal:

{
  "Sid": "DenyConsoleUserPoolCreation",
  "Effect": "Deny",
  "Action": "cognito-idp:CreateUserPool",
  "Resource": "*",
  "Condition": {
    "ArnNotLike": {
      "aws:PrincipalArn": "arn:aws:iam::*:role/CognitoAdmin"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The operator who needs a new pool goes through the admin role (which uses the IaC module). Console creation simply isn't available.

stave apply in CI against the post-deploy observation snapshot. The example shipped with this article is the template. PRs that introduce a user pool with mfa_enforced: false produce exit code 3.

Checklist

  • [ ] Customer-facing Cognito user pools have MfaConfiguration: ON and at least one second factor enabled
  • [ ] AdvancedSecurityMode is ENFORCED (not OFF, not stuck in AUDIT)
  • [ ] IaC module for user-pool creation sets MFA and Advanced Security as defaults; carve-outs are explicit and reviewed
  • [ ] SCP denies console user-pool creation for non-admin principals
  • [ ] stave apply runs in CI against post-deploy observations; PRs with mfa_enforced: false fail

The user pool is the perimeter. The MFA flag is the second factor that makes credential theft an incomplete attack. The Advanced Security flag is the layer that catches the credential theft before the second factor matters. Cognito ships both off, and the team that ships them off-by-default ships the bomma vulnerability class with them.


The example at stave/examples/cognito-no-mfa-advanced-security/ is a self-contained Go program that loads two fixture snapshots, runs pkg/stave.Apply, asserts that CTL.COGNITO.MFA.001 fires on the MFA-off fixture and is silent on the remediated one, and exits zero when both assertions hold. Stave detects this pattern and 31 other H1-grounded scenarios from local AWS configuration snapshots, with no cloud credentials.

Top comments (1)

Collapse
 
raknaos profile image
Raknaos

The MFA-off half is well-trodden ground, but pairing it with email_change_verification_enforced: false and alias_attributes: ["email"] is the actual chain, and I like that the control fires on capability rather than confirmed compromise. One place I'd push the predicate further: deriving mfa_enforced from MfaConfiguration == ON plus "at least one second factor" lets an SMS-only pool satisfy the invariant, which turns a green verdict into false coverage once SIM swap enters the picture. If the control ever grows a severity tier, splitting TOTP-capable pools from SMS-only pools is the cheapest next step, because that's the line between phishable and phishable-and-swappable.

On "why Z3 doesn't help": if the collector only observes booleans, the control reduces to leaf clauses, so a solver buys nothing. What would justify it is cross-asset reasoning: the Bomma chain needs the alias config and the missing change-verification and a reachable profile-update endpoint that lets the attacker move the email. Is the invariant modeled as a predicate over a single asset, or does the engine ever join an identity pool against the endpoints that can mutate it?