IAM administration

Require MFA for sensitive actions

Deny selected destructive and credential-changing actions unless an IAM user's session used MFA, and test the condition without running them.

Published · Updated .

The problem

Some IAM users still have long-term access keys: a break-glass administrator, or an engineer whose legacy tool cannot use IAM Identity Center. Whoever gets a leaked key can do everything the user can. A common mitigation is a Deny that leaves everyday work alone but blocks a short list of high-impact actions unless the session was authenticated with an MFA code.

The key involved, aws:MultiFactorAuthPresent, is easy to get wrong because of when it is present. AWS documents that it is included only for requests signed with temporary credentials: console sessions, and credentials from GetSessionToken or AssumeRole. It is absent for requests signed directly with long-term access keys, and AWS states it is not present for federated identities, because AWS cannot tell whether an external identity provider used MFA. It is present but false when a service such as CloudFormation or Athena makes a follow-on request for an IAM user who used long-term keys.

The policy below handles each case deliberately, and the checks test it with the IAM policy simulator, so you never run a destructive call to prove it works.

The policy

Deny sensitive actions without MFA

IAM identity-based policy. Attach to: An IAM group (or individual IAM users) whose members have TOTP MFA devices, alongside the policies that grant these actions

Deny sensitive actions without MFA · IAM identity-based policyOpen in Inline Policy Generator
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenySensitiveActionsWithoutMfa",
      "Effect": "Deny",
      "Action": [
        "ec2:TerminateInstances",
        "rds:DeleteDBInstance",
        "rds:DeleteDBCluster",
        "s3:DeleteBucket",
        "iam:DeleteRole",
        "iam:DeleteUser"
      ],
      "Resource": "*",
      "Condition": {
        "BoolIfExists": {
          "aws:MultiFactorAuthPresent": "false"
        }
      }
    },
    {
      "Sid": "DenyCredentialChangesWithoutMfa",
      "Effect": "Deny",
      "Action": [
        "iam:CreateAccessKey",
        "iam:UpdateAccessKey",
        "iam:DeactivateMFADevice",
        "iam:DeleteVirtualMFADevice",
        "iam:CreateLoginProfile",
        "iam:UpdateLoginProfile",
        "iam:AttachUserPolicy",
        "iam:PutUserPolicy",
        "iam:AddUserToGroup"
      ],
      "Resource": "*",
      "Condition": {
        "BoolIfExists": {
          "aws:MultiFactorAuthPresent": "false"
        }
      }
    }
  ]
}

Statement by statement

  • DenySensitiveActionsWithoutMfa uses BoolIfExists with "false", which matches when the key is false and when it is absent. It therefore denies sessions created without MFA (false) and requests signed directly with access keys (absent). AWS recommends this form and warns against plain Bool, which matches only a present false key and so lets direct long-term-key requests through: exactly the leaked-key case. The action list is illustrative; list your own irreversible actions.
  • DenyCredentialChangesWithoutMfa stops a leaked key from making itself permanent: no new or reactivated access keys, no removing the MFA device, no console password, no extra permissions. Enrollment actions such as iam:CreateVirtualMFADevice and iam:EnableMFADevice are left off so new users can still register a device.

Before you start

  • Each affected user has a virtual or hardware TOTP device. AWS states that U2F security keys cannot be used for MFA-protected API access, so a user with only a FIDO key can satisfy this policy in the console but not from the CLI.
  • Other policies grant the actions listed. This policy only takes permissions away.
  • Permission to run iam:SimulateCustomPolicy and iam:SimulatePrincipalPolicy for the checks.

Deploy it step by step

  1. Find each user's MFA device ARN. The SerialNumber returned is what you pass to GetSessionToken; for a virtual device it is an ARN.
    Commands (replace <placeholders>)
    aws iam list-mfa-devices --user-name <user-name>
  2. Create the policy and attach it to the group. Attach it to a group of IAM users. Do not attach it to roles used by federated users (see pitfalls).
    Commands (replace <placeholders>)
    aws iam create-policy --policy-name RequireMfaForSensitiveActions --policy-document file://require-mfa.json
    aws iam attach-group-policy --group-name <group-name> --policy-arn arn:aws:iam::111122223333:policy/RequireMfaForSensitiveActions
  3. Get an MFA-authenticated session for CLI work. Call GetSessionToken with the device ARN and a current code. IAM user sessions last 900 seconds to 36 hours (default 12); ask for less. These credentials can call IAM APIs only if MFA was included, and no STS APIs except AssumeRole and GetCallerIdentity. Export the returned access key ID, secret access key and session token in the standard CLI environment variables, or a temporary profile.
    Commands (replace <placeholders>)
    aws sts get-session-token --serial-number arn:aws:iam::111122223333:mfa/<device-name> --token-code <123456> --duration-seconds 3600 --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken,Expiration]' --output text
  4. For roles, require MFA in the trust policy instead. Where IAM users assume an administrative role, require aws:MultiFactorAuthPresent true (Bool) in its trust policy and pass the MFA device and code when assuming it. AWS's MFA-protected API guide says MFA is checked at assumption, no further checks follow, and AssumeRole credentials carry no MFA information for per-call checks. Other AWS pages describe role sessions differently, so test before relying on a per-action MFA condition inside one.
    Commands (replace <placeholders>)
    aws sts assume-role --role-arn arn:aws:iam::111122223333:role/<admin-role> --role-session-name <your-name> --serial-number arn:aws:iam::111122223333:mfa/<device-name> --token-code <123456>

Verify it without changing anything

These checks only read state, simulate, or use dry runs, so they are safe to run in any account. Exact error wording varies by service and over time.

Simulate the three request contexts

Save the policy as require-mfa.json and a file allow-sensitive.json that allows the same actions, then simulate with the key absent, false and true.

Commands (replace <placeholders>)
aws iam simulate-custom-policy --policy-input-list file://require-mfa.json file://allow-sensitive.json --action-names ec2:TerminateInstances iam:CreateAccessKey --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output table
aws iam simulate-custom-policy --policy-input-list file://require-mfa.json file://allow-sensitive.json --action-names ec2:TerminateInstances iam:CreateAccessKey --context-entries ContextKeyName=aws:MultiFactorAuthPresent,ContextKeyValues=false,ContextKeyType=boolean --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output table
aws iam simulate-custom-policy --policy-input-list file://require-mfa.json file://allow-sensitive.json --action-names ec2:TerminateInstances iam:CreateAccessKey --context-entries ContextKeyName=aws:MultiFactorAuthPresent,ContextKeyValues=true,ContextKeyType=boolean --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output table

Expected: explicitDeny for both actions when no context is supplied (the long-term key case) and when the key is false. allowed when it is true. If the true run shows implicitDeny, allow-sensitive.json does not grant the action.

See why plain Bool is the wrong operator

Copy the policy to require-mfa-bool.json, change BoolIfExists to Bool, and run the no-context simulation again.

Commands (replace <placeholders>)
aws iam simulate-custom-policy --policy-input-list file://require-mfa-bool.json file://allow-sensitive.json --action-names ec2:TerminateInstances --query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output table

Expected: allowed. With the key absent, Bool does not match and the Deny never applies, just as for a request signed with a leaked access key.

Simulate the real user

This includes the user's group policies. Supply the MFA context explicitly; the simulator does not take it from your session.

Commands (replace <placeholders>)
aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::111122223333:user/<user-name> --action-names rds:DeleteDBInstance --context-entries ContextKeyName=aws:MultiFactorAuthPresent,ContextKeyValues=false,ContextKeyType=boolean --query 'EvaluationResults[].EvalDecision'
aws iam simulate-principal-policy --policy-source-arn arn:aws:iam::111122223333:user/<user-name> --action-names rds:DeleteDBInstance --context-entries ContextKeyName=aws:MultiFactorAuthPresent,ContextKeyValues=true,ContextKeyType=boolean --query 'EvaluationResults[].EvalDecision'

Expected: explicitDeny with false. With true, allowed if the user's other policies grant the action, otherwise implicitDeny. If false also returns allowed, the policy is not attached to this user or to any of the user's groups.

Confirm the CLI session really has MFA

With the credentials from step 3 loaded, make two harmless calls.

Commands (replace <placeholders>)
aws sts get-caller-identity
aws iam get-user

Expected: get-caller-identity shows arn:aws:iam::111122223333:user/<user-name>; GetSessionToken sessions report the IAM user, not an assumed role. get-user succeeds. If get-user is denied even though the user's policies allow iam:GetUser, the session was created without MFA: GetSessionToken credentials can call IAM APIs only when MFA was included.

Common mistakes and troubleshooting

Attaching the policy to federated identities

The key is absent for federated identities, and BoolIfExists denies when it is absent. On a role used through SAML, OIDC or IAM Identity Center, this policy blocks the listed actions for everyone, whatever the identity provider enforced. AWS suggests passing the authentication method as a session tag, or as an Identity Center attribute for access control, and conditioning on that.

Breaking CloudFormation and other services that act for users

Follow-on calls a service makes for a long-term-key user carry the key as false, so a stack deletion that terminates instances is denied partway through. Give the stack a CloudFormation service role so the role makes those calls. Likewise, move scripts that use access keys to roles before attaching this policy.

Denying MFA enrollment

If you add iam:CreateVirtualMFADevice or iam:EnableMFADevice to the Deny, a user without MFA can never get MFA. Leave enrollment out of the Deny.

Limitations and production considerations

  • MFA is checked once, when the session is created; whoever steals the temporary credentials inherits the flag for up to 36 hours. A separate Deny with NumericGreaterThan on aws:MultiFactorAuthAge can limit sensitive actions to, say, the first hour after MFA.
  • It is a deny list: actions you left out, and new destructive APIs, are not covered.
  • It cannot constrain the root user, and AWS states MFA-protected API access cannot be used with root credentials.
  • Simulator results are a model; AWS notes they can differ from your live environment.

Official AWS documentation

Account IDs, bucket and organization names are AWS documentation placeholders. Test in a non-production account before use (see the methodology).