To find IAM users without MFA, list every user with ListUsers, call GetLoginProfile to see who has a console password, and call ListMFADevices for each one. A console user with zero MFA devices is a finding. For a one-shot view of the whole account, the IAM credential report’s password_enabled and mfa_active columns give the same answer.
An IAM user who can sign in to the console with only a password is one phishing email away from an incident, and it’s one of the first things any audit checks. This example is for engineers who need to find IAM users without MFA across an account, fast, with a script they can read and rerun in CI. You get a TypeScript script built on AWS SDK for JavaScript v3 that reports every console user without an MFA device, plus a shorter variant that reads the credential report instead.
Our guide to analyzing your AWS security posture with an AI CLI asks this question in plain English. This page is the script version, one of our runnable AWS practical examples in TypeScript.
What the script checks, and why console users matter most
MFA on an IAM user protects sign-ins to the AWS Management Console. It does not automatically protect API calls made with that user’s access keys; that needs a policy condition on aws:MultiFactorAuthPresent. So the script sorts users into three groups:
| User has | MFA devices | Finding |
|---|---|---|
| A console password | 0 | NO MFA (console user), fix first |
| No console password (API or CI only) | 0 | Reported with --all; review its access keys instead |
| Any | 1 or more | ok |
This matches the CIS recommendation to enable MFA for every IAM user with a console password (recommendation 1.9 in CIS AWS Foundations Benchmark v5.0.0; the numbering changes between versions). The CIS Amazon Web Services Foundations Benchmark is free to download and lists the related IAM checks.
How it works, per user:
- List users
paginateListUserswalks every page, so accounts with hundreds of users are covered. Each user also carriesPasswordLastUsed, which shows whether the password is actually in use. - Check for a console password
GetLoginProfilesucceeds when the user has a password and throwsNoSuchEntityExceptionwhen it doesn’t. That error is the expected “no console access” answer, not a failure. - Count MFA devices
ListMFADeviceswith the user name returns every assigned device. IAM users can register up to eight MFA devices: passkeys or security keys, virtual authenticator apps, or hardware TOTP tokens. - Report and set an exit codeFlagged users are printed with
console.table, and the process exits with code 2 when any are found, so a scheduled job can alert on it.
Prerequisites
- Node.js 20 or later, npm and
tsxto run TypeScript directly. - The
@aws-sdk/client-iampackage. - An AWS profile in the account you’re auditing. IAM is global, so any Region works; the script defaults to
us-east-1for the endpoint.
Which IAM permissions does it need?
Three read actions. iam:ListUsers doesn’t support resource-level permissions, so it needs "Resource": "*"; the per-user actions can be scoped to user ARNs. Replace 123456789012 with your account ID.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ListAllUsers",
"Effect": "Allow",
"Action": "iam:ListUsers",
"Resource": "*"
},
{
"Sid": "ReadPerUserCredentials",
"Effect": "Allow",
"Action": ["iam:GetLoginProfile", "iam:ListMFADevices"],
"Resource": "arn:aws:iam::123456789012:user/*"
},
{
"Sid": "CredentialReportVariant",
"Effect": "Allow",
"Action": ["iam:GenerateCredentialReport", "iam:GetCredentialReport"],
"Resource": "*"
}
]
}
AWS’s SecurityAudit and IAMReadOnlyAccess managed policies include all of these. The last statement is only for the credential report variant further down. If you adapt the script, paste your version into the IAM policy generator for TypeScript code to draft the matching policy, then check it against the list to review a generated IAM policy for least privilege. The method to find the IAM actions your AWS SDK for JavaScript code needs explains how each Command maps to an action.
The script to find IAM users without MFA
// find-iam-users-without-mfa.ts
// Lists every IAM user, checks whether it has a console password (login profile)
// and how many MFA devices are assigned, and flags console users without MFA.
// Read-only. Usage: npx tsx find-iam-users-without-mfa.ts [--all]
import {
IAMClient,
GetLoginProfileCommand,
NoSuchEntityException,
paginateListUsers,
paginateListMFADevices,
} from "@aws-sdk/client-iam";
type Row = {
user: string;
console: "yes" | "no";
passwordLastUsed: string;
mfaDevices: number;
finding: string;
};
// IAM is global, but the SDK still needs a Region to resolve the endpoint.
// Adaptive retry mode slows the loop down if IAM starts throttling.
const iam = new IAMClient({
region: process.env.AWS_REGION ?? "us-east-1",
maxAttempts: 8,
retryMode: "adaptive",
});
const showAll = process.argv.includes("--all");
async function hasConsolePassword(userName: string): Promise<boolean> {
try {
await iam.send(new GetLoginProfileCommand({ UserName: userName }));
return true;
} catch (err) {
// No login profile means the user has no console password.
if (err instanceof NoSuchEntityException) return false;
throw err;
}
}
async function countMfaDevices(userName: string): Promise<number> {
let count = 0;
for await (const page of paginateListMFADevices({ client: iam }, { UserName: userName })) {
count += page.MFADevices?.length ?? 0;
}
return count;
}
async function main(): Promise<void> {
const rows: Row[] = [];
for await (const page of paginateListUsers({ client: iam }, {})) {
for (const user of page.Users ?? []) {
const name = user.UserName;
if (!name) continue;
const [hasPassword, mfaDevices] = await Promise.all([hasConsolePassword(name), countMfaDevices(name)]);
let finding = "ok";
if (hasPassword && mfaDevices === 0) finding = "NO MFA (console user)";
else if (!hasPassword && mfaDevices === 0) finding = "no MFA, API only";
rows.push({
user: name,
console: hasPassword ? "yes" : "no",
passwordLastUsed: user.PasswordLastUsed?.toISOString().slice(0, 10) ?? "never",
mfaDevices,
finding,
});
}
}
const flagged = rows.filter((r) => r.finding.startsWith("NO MFA"));
console.table(showAll ? rows : flagged);
console.log(
`${rows.length} users checked, ${rows.filter((r) => r.console === "yes").length} with a console password, ` +
`${flagged.length} console users without MFA.`,
);
// A non-zero exit code lets CI or a cron job alert on findings.
if (flagged.length > 0) process.exitCode = 2;
}
main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});
The client uses retryMode: "adaptive" with 8 attempts. IAM applies request rate limits, and a large account means several calls per user, so client-side rate limiting keeps the loop from failing halfway through on throttling errors.
How do you run it?
npm install @aws-sdk/client-iam
npm install --save-dev tsx typescript
# Console users without MFA only
AWS_PROFILE=security-audit npx tsx find-iam-users-without-mfa.ts
# Every user, with password and MFA columns
AWS_PROFILE=security-audit npx tsx find-iam-users-without-mfa.ts --all
Sample output
┌─────────┬──────────────┬─────────┬──────────────────┬────────────┬─────────────────────────┐
│ (index) │ user │ console │ passwordLastUsed │ mfaDevices │ finding │
├─────────┼──────────────┼─────────┼──────────────────┼────────────┼─────────────────────────┤
│ 0 │ 'dana.ops' │ 'yes' │ '2026-09-24' │ 0 │ 'NO MFA (console user)' │
│ 1 │ 'contractor' │ 'yes' │ 'never' │ 0 │ 'NO MFA (console user)' │
└─────────┴──────────────┴─────────┴──────────────────┴────────────┴─────────────────────────┘
14 users checked, 6 with a console password, 2 console users without MFA.
User names are illustrative. contractor has a password that was never used: often the cleanest fix is to delete the login profile rather than enroll a device nobody will use.
The credential report alternative
The IAM credential report is a CSV covering every user in one download, so it needs 2 API calls instead of 2 or 3 per user. IAM reuses a report generated in the last four hours and otherwise builds a new one.
// mfa-from-credential-report.ts
// Same check from the IAM credential report: one CSV for the whole account.
// Read-only apart from asking IAM to (re)generate the report.
import {
IAMClient,
GenerateCredentialReportCommand,
GetCredentialReportCommand,
} from "@aws-sdk/client-iam";
import { setTimeout as sleep } from "node:timers/promises";
const iam = new IAMClient({ region: process.env.AWS_REGION ?? "us-east-1" });
async function main(): Promise<void> {
// IAM reuses a report generated in the last four hours; otherwise it builds a new one.
for (let i = 0; i < 20; i++) {
const { State } = await iam.send(new GenerateCredentialReportCommand({}));
if (State === "COMPLETE") break;
await sleep(3000);
}
const report = await iam.send(new GetCredentialReportCommand({}));
const csv = new TextDecoder().decode(report.Content);
const [header, ...lines] = csv.trim().split("\n");
const cols = header.split(",");
const idx = (name: string) => cols.indexOf(name);
console.log(`Report generated: ${report.GeneratedTime?.toISOString()}`);
for (const line of lines) {
const f = line.split(",");
const user = f[idx("user")];
if (user === "<root_account>") continue;
if (f[idx("password_enabled")].toLowerCase() === "true" && f[idx("mfa_active")].toLowerCase() === "false") {
console.log(`NO MFA ${user} (password last used: ${f[idx("password_last_used")]})`);
}
}
}
main().catch((err: unknown) => {
console.error(err);
process.exit(1);
});
Use the report for large accounts and scheduled audits. Use the per-user script when you need live data (a report can be up to four hours old) or want to add checks such as device type or tags. The report also covers access keys: its access_key_1_last_rotated and access_key_1_last_used_date columns are the starting point for key hygiene, though it only includes the first two access keys per user. For live key data, the companion script can find IAM access keys older than 90 days or never used.
How do you fix a user without MFA?
Start with the users who hold admin rights: the script to find IAM policies that grant admin access lists them, including admin granted through a group. Users whose permissions come from policies attached to them rather than a group are harder to review; the script to find IAM users with directly attached policies lists them.
- The user needs console access: ask them to register a passkey or security key under Security credentials in the IAM console. AWS recommends these phishing-resistant FIDO authenticators over TOTP apps. They can only be enabled in the console, not through the API.
- The user doesn’t need console access: delete the login profile (
iam:DeleteLoginProfile). That removes the finding without adding a device. - Enforce it: attach a policy that denies most actions unless
aws:MultiFactorAuthPresentis true, so a new user can do little until MFA is set up. - Longer term: move people to IAM Identity Center, which manages MFA centrally and issues temporary credentials. The guide to connect ChatWithCloud to AWS with profiles, SSO and roles shows how SSO profiles work from the terminal.
Troubleshooting
AccessDeniedoniam:GetLoginProfileoriam:ListMFADevices. The profile can list users but not read their credentials. Add the second policy statement, or follow the steps to troubleshoot AWS IAM access denied errors if a boundary or SCP is in the way. To see what your session actually has, check the permissions of your currently assumed IAM role.Throttling: Rate exceeded. RaisemaxAttempts, or switch to the credential report, which avoids per-user calls.- Credential report errors.
CredentialReportNotPresentExceptionorCredentialReportExpiredExceptionmean the report has to be generated first; the variant above does that.CredentialReportNotReadyExceptionmeans generation is still running. - The root user isn’t listed.
ListUsersonly returns IAM users. Check root MFA withGetAccountSummary(AccountMFAEnabled) or the<root_account>row of the credential report. The script to check the AWS root user for MFA and access keys does both.
Ask ChatWithCloud instead
If you’d rather not maintain a script, start ChatWithCloud with a read-only profile and ask “Which IAM users have a console password but no MFA device?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and explains the result; how ChatWithCloud turns a question into AWS API calls covers the loop. It runs generated code without a confirmation step, so use a profile limited to the read actions above. User names and MFA status are part of the JSON sent for processing; the ChatWithCloud security model and data flow lists what leaves your machine.
For the rest of an account review, pair this script with the examples that find security groups open to the internet on common ports and find public and private S3 buckets with the AWS SDK. Roles need the same review as users; the script to find unused IAM roles with RoleLastUsed covers them. To make sure the account records and watches what those users do, add the checks that CloudTrail is logging in every AWS Region and that GuardDuty is enabled in every AWS Region.
Frequently asked questions
How do I find IAM users without MFA in the AWS CLI?
Run aws iam generate-credential-report, then aws iam get-credential-report, decode the base64 Content and filter rows where password_enabled is true and mfa_active is false.
Does MFA on an IAM user protect its access keys?
No. MFA applies to console sign-in. API calls with access keys are only MFA-protected when a policy requires aws:MultiFactorAuthPresent and the caller gets temporary credentials with an MFA code.
Do users without a console password need MFA?
CIS scopes the check to users with a console password. For API-only users, focus on access key age and use instead.
Why does GetLoginProfile throw an error for some users?
It returns NoSuchEntity (404) when the user has no password. In SDK v3 that is NoSuchEntityException, and the script treats it as “no console access”.
Related guides
Ask your AWS account in plain English
Your first 15 runs are free, with no OpenAI key needed.
npx chatwithcloud