Find Cognito User Pools Without MFA

A hardware security key resting on the keyboard of a silver laptop

Photo by cottonbro studio on Pexels

A Cognito user pool with MFA not enabled has MfaConfiguration set to OFF, so users sign in with a password alone. To find these pools, list them with ListUserPools and call GetUserPoolMfaConfig on each; it returns OFF, OPTIONAL or ON plus the SMS, email, TOTP and passkey settings. Fix one by setting MFA to OPTIONAL first, not ON.

User pools tend to be created once, early, by whoever built the sign-up screen, and then left alone. The MFA setting chosen that day is often still the setting today, including on pools that now hold every customer account.

This example is for engineers responsible for application sign-in. The script reports, for every pool in a Region, whether Cognito user pool MFA is not enabled, which factors are configured, the feature plan, threat protection mode, deletion protection, minimum password length and estimated users. With --apply it switches the pools you name from OFF to OPTIONAL with TOTP enabled. It’s the application-side twin of the check to find IAM users without MFA, which covers people who sign in to AWS itself.

What do OFF, OPTIONAL and ON mean for a Cognito user pool?

Setting What happens at sign-in Risk of changing to it
OFF Password only. No MFA prompt for anyone. None, but it’s the finding.
OPTIONAL Users who have set up a factor are prompted for it. Managed login doesn’t prompt the others to set one up; your app has to offer that. Low. Nobody is locked out.
ON Every user must set up MFA before they can sign in. High. Users without a factor, and apps without a setup flow, can’t sign in.

That’s why the script never sets ON. The Cognito API reference also says required MFA can only be specified when you first create a user pool, and required MFA isn’t possible in pools that support passwordless one-time passwords. For pools that use threat protection’s adaptive authentication, AWS recommends OPTIONAL. Federated users are unaffected either way: Cognito leaves authentication, including MFA, to their identity provider.

Which MFA factors can a user pool offer?

  • TOTP (authenticator apps): no messaging setup and no per-message cost. The script turns this on.
  • SMS: needs an Amazon SNS role, and US carriers require a registered origination number. NIST SP 800-63B treats one-time codes over the phone network as a restricted authenticator; see the NIST digital identity guidelines on authenticators.
  • Email: requires the Essentials plan or higher.
  • Passkeys: can satisfy MFA on their own when WebAuthnConfiguration.FactorConfiguration is MULTI_FACTOR_WITH_USER_VERIFICATION. They can’t be the second factor after a password.

What else does the report check?

DescribeUserPool adds the feature plan (UserPoolTier: LITE, ESSENTIALS or PLUS), threat protection (UserPoolAddOns.AdvancedSecurityMode: OFF, AUDIT or ENFORCED, Plus plan only), DeletionProtection and the password policy’s minimum length. Deletion protection matters because deleting a pool deletes its users; with it ACTIVE, DeleteUserPool fails with InvalidParameterException until someone turns it off.

What does the script do?

  1. Lists poolspaginateListUserPools with MaxResults: 60, the maximum the API allows (it’s a required parameter).
  2. Reads MFA settingsGetUserPoolMfaConfig per pool: mode, SMS, email, TOTP and passkey configuration.
  3. Reads pool settingsDescribeUserPool for the plan, threat protection, deletion protection, password length and estimated user count.
  4. Changes, if asked--apply --pools calls SetUserPoolMfaConfig with MfaConfiguration: "OPTIONAL" and TOTP enabled, only for pools currently OFF. Existing SMS, email and passkey settings are passed back as they were read, then the pool is read again.

Prerequisites

  • Node.js 18 or later with tsx, plus @aws-sdk/client-cognito-identity-provider.
  • IAM credentials: these are admin API operations authorized by IAM policy, not by a user’s tokens. The guide to AWS SDK v3 credential providers covers profiles and SSO.
  • Before --apply, an MFA setup screen in your app. With OPTIONAL, nobody is prompted until they enroll, so the setting alone changes nothing for existing users.

Which IAM permissions does it need?

ListUserPools needs "*"; the rest are scoped to user pool ARNs. The last statement is only for --apply.

cognito-mfa-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ListPools",
      "Effect": "Allow",
      "Action": "cognito-idp:ListUserPools",
      "Resource": "*"
    },
    {
      "Sid": "ReadPools",
      "Effect": "Allow",
      "Action": ["cognito-idp:DescribeUserPool", "cognito-idp:GetUserPoolMfaConfig"],
      "Resource": "arn:aws:cognito-idp:*:111122223333:userpool/*"
    },
    {
      "Sid": "SetMfaOnlyWithApply",
      "Effect": "Allow",
      "Action": "cognito-idp:SetUserPoolMfaConfig",
      "Resource": "arn:aws:cognito-idp:*:111122223333:userpool/*"
    }
  ]
}

Keep the write statement off the role you use for audits; the guide to reviewing a generated IAM policy for least privilege explains the split.

The script to find Cognito user pools with MFA not enabled

find-cognito-user-pools-without-mfa.ts

// find-cognito-user-pools-without-mfa.ts
// Lists every Cognito user pool in a Region with its MFA setting (OFF / OPTIONAL / ON), the MFA factors
// configured, feature plan, threat protection mode, deletion protection and minimum password length.
// --apply sets MFA to OPTIONAL with TOTP enabled for the pools you name. It never sets MFA to ON.
// Usage:
//   npx tsx find-cognito-user-pools-without-mfa.ts [--region us-east-1]
//   npx tsx find-cognito-user-pools-without-mfa.ts --region us-east-1 --apply --pools us-east-1_AbC123
import {
  CognitoIdentityProviderClient,
  DescribeUserPoolCommand,
  GetUserPoolMfaConfigCommand,
  SetUserPoolMfaConfigCommand,
  paginateListUserPools,
  type GetUserPoolMfaConfigCommandOutput,
} from "@aws-sdk/client-cognito-identity-provider";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const region = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const apply = args.includes("--apply");
const toFix = (flag("--pools") ?? "").split(",").map((p) => p.trim()).filter(Boolean);
const cognito = new CognitoIdentityProviderClient({ region });

interface Row {
  Pool: string;
  Id: string;
  MFA: string;
  Factors: string;
  Plan: string;
  ThreatProtection: string;
  DeletionProtection: string;
  MinPassword: number | string;
  Users: number | string;
}

const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));

function factors(mfa: GetUserPoolMfaConfigCommandOutput): string {
  const f: string[] = [];
  if (mfa.SmsMfaConfiguration?.SmsConfiguration?.SnsCallerArn) f.push("SMS");
  if (mfa.SoftwareTokenMfaConfiguration?.Enabled) f.push("TOTP");
  if (mfa.EmailMfaConfiguration) f.push("EMAIL");
  if (mfa.WebAuthnConfiguration?.FactorConfiguration === "MULTI_FACTOR_WITH_USER_VERIFICATION") f.push("PASSKEY");
  return f.join(", ") || "none";
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  const configs = new Map<string, GetUserPoolMfaConfigCommandOutput>();
  for await (const page of paginateListUserPools({ client: cognito, pageSize: 60 }, { MaxResults: 60 })) {
    for (const p of page.UserPools ?? []) {
      if (!p.Id) continue;
      const mfa = await cognito.send(new GetUserPoolMfaConfigCommand({ UserPoolId: p.Id }));
      const { UserPool: pool } = await cognito.send(new DescribeUserPoolCommand({ UserPoolId: p.Id }));
      configs.set(p.Id, mfa);
      rows.push({
        Pool: p.Name ?? p.Id,
        Id: p.Id,
        MFA: mfa.MfaConfiguration ?? "OFF",
        Factors: factors(mfa),
        Plan: pool?.UserPoolTier ?? "?",
        ThreatProtection: pool?.UserPoolAddOns?.AdvancedSecurityMode ?? "OFF",
        DeletionProtection: pool?.DeletionProtection ?? "INACTIVE",
        MinPassword: pool?.Policies?.PasswordPolicy?.MinimumLength ?? "?",
        Users: pool?.EstimatedNumberOfUsers ?? "?",
      });
    }
  }
  console.table(rows);
  const off = rows.filter((r) => r.MFA === "OFF");
  console.log(
    `${rows.length} user pools in ${region}: ${off.length} with MFA OFF, ` +
      `${rows.filter((r) => r.MFA === "OPTIONAL").length} OPTIONAL, ${rows.filter((r) => r.MFA === "ON").length} ON.`,
  );

  if (!apply) {
    console.log("Report only: nothing was changed. Use --apply --pools <id,id> to set MFA to OPTIONAL with TOTP.");
    return;
  }
  for (const id of toFix) {
    const current = configs.get(id);
    if (!current || current.MfaConfiguration !== "OFF") {
      console.error(`Skipping ${id}: not a pool with MFA OFF in ${region}`);
      continue;
    }
    try {
      // Pass the existing SMS, email and passkey settings back unchanged; only MFA mode and TOTP change.
      await cognito.send(
        new SetUserPoolMfaConfigCommand({
          UserPoolId: id,
          MfaConfiguration: "OPTIONAL",
          SoftwareTokenMfaConfiguration: { Enabled: true },
          SmsMfaConfiguration: current.SmsMfaConfiguration,
          EmailMfaConfiguration: current.EmailMfaConfiguration,
          WebAuthnConfiguration: current.WebAuthnConfiguration,
        }),
      );
      const after = await cognito.send(new GetUserPoolMfaConfigCommand({ UserPoolId: id }));
      console.log(`Updated ${id}: MFA ${after.MfaConfiguration}, factors ${factors(after)}`);
    } catch (err) {
      console.error(`Could not update ${id}: ${errText(err)}`);
      process.exitCode = 1;
    }
  }
}

main().catch((err) => {
  console.error(errText(err));
  process.exit(1);
});

Never force ON by script: required MFA blocks every user who hasn’t enrolled a factor, and your app may have no screen for enrolling. Move to OPTIONAL, ship the enrollment flow, measure how many users have a factor, and only then plan a move to required MFA.

How do you run it?

Terminal

npm install @aws-sdk/client-cognito-identity-provider
npm install --save-dev tsx typescript @types/node

# Report only
AWS_PROFILE=readonly npx tsx find-cognito-user-pools-without-mfa.ts --region us-east-1

# Move one pool from OFF to OPTIONAL with TOTP
AWS_PROFILE=identity-admin npx tsx find-cognito-user-pools-without-mfa.ts --region us-east-1 \
  --apply --pools us-east-1_Cust0mer1

Sample output

Output (report only)

┌─────────┬──────────────────┬───────────────────────┬────────────┬─────────────────┬──────────────┬──────────────────┬────────────────────┬─────────────┬───────┐
│ (index) │ Pool             │ Id                    │ MFA        │ Factors         │ Plan         │ ThreatProtection │ DeletionProtection │ MinPassword │ Users │
├─────────┼──────────────────┼───────────────────────┼────────────┼─────────────────┼──────────────┼──────────────────┼────────────────────┼─────────────┼───────┤
│ 0       │ 'customers-prod' │ 'us-east-1_Cust0mer1' │ 'OFF'      │ 'none'          │ 'ESSENTIALS' │ 'OFF'            │ 'ACTIVE'           │ 12          │ 48210 │
│ 1       │ 'internal-admin' │ 'us-east-1_Adm1nPool' │ 'ON'       │ 'TOTP, PASSKEY' │ 'PLUS'       │ 'ENFORCED'       │ 'ACTIVE'           │ 12          │ 37    │
│ 2       │ 'staging'        │ 'us-east-1_Test42'    │ 'OPTIONAL' │ 'SMS'           │ 'ESSENTIALS' │ 'OFF'            │ 'INACTIVE'         │ 6           │ 37    │
└─────────┴──────────────────┴───────────────────────┴────────────┴─────────────────┴──────────────┴──────────────────┴────────────────────┴─────────────┴───────┘
3 user pools in us-east-1: 1 with MFA OFF, 1 OPTIONAL, 1 ON.
Report only: nothing was changed. Use --apply --pools <id,id> to set MFA to OPTIONAL with TOTP.

Pool IDs and counts are illustrative. customers-prod is the finding: tens of thousands of users with MFA off and no factor configured. Set it to OPTIONAL once the app can enroll TOTP. internal-admin is in good shape, with required MFA, passkeys and threat protection enforced. staging uses SMS only, has a 6-character minimum password and no deletion protection; fine for test data, not if real users ever land there. The check of the IAM account password policy is the AWS-side equivalent of that last column.

Troubleshooting

  • FeatureUnavailableInTierException. A setting you passed needs a higher feature plan, most often email MFA on the Lite plan. The script only passes back settings the pool already returned, so compare the pool’s current plan with the factors it has configured.
  • InvalidSmsRoleAccessPolicyException or InvalidSmsRoleTrustRelationshipException. The pool’s SMS role can’t publish through Amazon SNS, or doesn’t trust cognito-idp.amazonaws.com with the expected external ID. Fix the role or remove SMS as a factor.
  • ConcurrentModificationException. Another change to the pool was in progress. Run it again.
  • NotAuthorizedException or AccessDeniedException. Check the policy above. The guide to troubleshooting IAM access denied errors walks through the evaluation.
  • SMS codes don’t arrive after enabling MFA. The account may be in the SNS SMS sandbox, where only verified numbers receive messages.

Ask ChatWithCloud instead

For a quick check, ask ChatWithCloud “Which Cognito user pools in us-east-1 have MFA off, and which have deletion protection?” It writes AWS SDK for JavaScript v2 code, runs it locally with your AWS profile and summarizes the answer. Changes run without a confirmation step, so don’t ask it to change MFA settings on a profile that can; the ChatWithCloud FAQ on profiles and limits has the details. To create test users in a pool, the guide to Cognito AdminCreateUser with AWS SDK v3 has the code.

Frequently asked questions

How do I check if MFA is enabled on a Cognito user pool?

Call GetUserPoolMfaConfig with the pool ID (aws cognito-idp get-user-pool-mfa-config). MfaConfiguration is OFF, OPTIONAL or ON, and the response shows which factors are configured.

Can I change a Cognito user pool from MFA OFF to required?

The API reference says required MFA can only be specified when you create the pool. You can switch an existing pool between OFF and OPTIONAL with SetUserPoolMfaConfig.

Will optional MFA lock users out?

No. With OPTIONAL, only users who have set up a factor are asked for it. Everyone else signs in as before until they enroll.

Is SMS MFA good enough for a Cognito user pool?

It’s better than no MFA, but NIST treats one-time codes over the phone network as a restricted authenticator. Prefer TOTP or passkeys, and keep SMS as a fallback if you need it.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud