JSTGTECH
← Back to blog

Scoping IAM Policies with Tag, IP, and MFA Conditions

5 min read

Most IAM policies I inherit from a team are scoped by resource ARN and nothing else: “this role can ec2:* on these instances.” That’s a start, but it leaves gaps a determined or careless caller can walk through — an engineer’s laptop credentials reaching production from a coffee shop, a role that can touch every EC2 instance in the account regardless of which team owns it, a sensitive action that only checks “are you authenticated” rather than “did you actually use MFA to get here.” IAM’s condition keys close those gaps without adding a second system to manage. They live right inside the policy document, they’re evaluated by the same engine, and once you know the handful that matter, they cover almost every real-world scoping requirement. Here’s how I use them in practice, on real production policies.

Tag-based scoping: aws:ResourceTag and aws:RequestTag

If your account has more than one team’s resources in it, tag-based scoping is the highest-leverage condition you can add. Instead of enumerating ARNs (which breaks the moment someone launches a new instance), you scope by tag and let your tagging discipline do the work:

{
  "Effect": "Allow",
  "Action": ["ec2:StartInstances", "ec2:StopInstances", "ec2:RebootInstances"],
  "Resource": "arn:aws:ec2:*:123456789012:instance/*",
  "Condition": {
    "StringEquals": {
      "aws:ResourceTag/team": "platform"
    }
  }
}

aws:ResourceTag checks the tag already on the resource being acted on — use it for read/modify/delete actions. For actions that create a resource, there’s no tag on it yet at evaluation time, so you need aws:RequestTag instead, checking the tag the caller is trying to apply:

{
  "Effect": "Allow",
  "Action": "ec2:RunInstances",
  "Resource": "arn:aws:ec2:*:123456789012:instance/*",
  "Condition": {
    "StringEquals": {
      "aws:RequestTag/team": "platform"
    }
  }
}

Gotcha: this only stops people who use the console or API correctly — it does nothing if the tag can simply be omitted or changed later. Pair it with a Deny that blocks untagged creation and, separately, blocks ec2:DeleteTags / ec2:CreateTags on the team key for anyone outside your platform-admin role. Otherwise “scoped by tag” degrades into “scoped by tag until someone removes the tag,” which isn’t a security boundary at all — it’s a labeling convention people are trusting each other to respect.

Network scoping: aws:SourceIp and aws:VpcSourceIp

aws:SourceIp restricts API calls to a CIDR range — your office IP, your VPN egress, or (less usefully) 0.0.0.0/0. It’s most valuable on IAM users or roles that still authenticate outside of AWS-managed network paths (a CI runner with a static egress IP, a break-glass admin user):

{
  "Effect": "Deny",
  "NotAction": ["iam:ChangePassword", "iam:GetUser"],
  "Resource": "*",
  "Condition": {
    "NotIpAddress": {
      "aws:SourceIp": ["203.0.113.0/24", "198.51.100.0/24"]
    },
    "Bool": {
      "aws:ViaAWSService": "false"
    }
  }
}

Two things worth calling out. First, aws:SourceIp looks at the caller’s public IP for calls made directly to AWS, but for calls made from inside a VPC through a VPC endpoint, you want aws:VpcSourceIp instead — it’s the private IP inside the VPC, and it only populates when the call actually transits a VPC endpoint. Mixing the two up is a common reason a network-scoped policy silently fails for endpoint traffic: the condition key you checked simply isn’t present on that request, so it evaluates as “condition not met” and the whole statement is skipped.

Second, that aws:ViaAWSService: false condition isn’t decoration — without it, this Deny also blocks AWS services calling APIs on your behalf (e.g., CloudFormation invoking IAM during a stack operation), because those calls don’t carry your IP at all and would otherwise fail the NotIpAddress check and get denied. This is the single most common way I’ve seen an IP-restriction policy break someone’s CI pipeline the day after it ships.

Enforcing MFA: aws:MultiFactorAuthPresent and aws:MultiFactorAuthAge

For your most sensitive actions — deleting a CloudTrail trail, deactivating GuardDuty, changing another user’s credentials — require not just “authenticated” but “authenticated with MFA, recently”:

{
  "Effect": "Deny",
  "Action": [
    "iam:DeleteUser",
    "iam:DeleteRole",
    "iam:UpdateAccessKey",
    "cloudtrail:StopLogging",
    "guardduty:DeleteDetector"
  ],
  "Resource": "*",
  "Condition": {
    "BoolIfExists": {
      "aws:MultiFactorAuthPresent": "false"
    }
  }
}

Use BoolIfExists rather than a plain Bool here. aws:MultiFactorAuthPresent is simply absent from the request context for some call types (notably calls made with temporary credentials from certain federation flows, or service-to-service calls), and a plain Bool condition against a missing key evaluates as false — which, counterintuitively, means the Deny condition ("aws:MultiFactorAuthPresent": "false") matches and the action gets denied even for legitimate non-interactive callers. BoolIfExists only evaluates the condition when the key is present, so it doesn’t misfire on requests where MFA presence genuinely isn’t reportable.

Add aws:MultiFactorAuthAge (in seconds) if you want to force re-authentication for stale sessions rather than trusting an MFA check from ten hours ago:

"Condition": {
  "NumericGreaterThan": {
    "aws:MultiFactorAuthAge": "3600"
  }
}

Combining conditions safely: the Deny/NotAction trap

The pattern above — Effect: Deny plus NotAction — is powerful but easy to get backwards. NotAction in a Deny statement means “deny everything except these actions,” so the actions you list are the ones exempted from the deny, not the ones targeted by it. I’ve seen policies where someone wanted to restrict a set of dangerous actions and reached for NotAction, accidentally exempting exactly the actions they meant to lock down while denying everything else in the account. If your intent is “deny these specific actions unless X,” use a plain Action list. Reserve NotAction for “deny everything except these few things” — the safe subset you’re carving out, like the two IAM self-service calls in the aws:SourceIp example above, which a locked-out user still needs to fix their own password.

The other non-obvious trap: explicit Deny always wins, but a missing condition key doesn’t always mean “deny.” Whether an absent key trips the condition depends entirely on which condition operator you used (Bool vs BoolIfExists, StringEquals vs StringEqualsIfExists). IAM doesn’t warn you when a policy’s condition silently never matches because the key isn’t in the request context — it just evaluates false and moves on, and the action proceeds under whatever your next-most-permissive statement allows. Test conditional Deny policies with the IAM Policy Simulator against the actual principal and a realistic set of request parameters before you rely on them, not just against a policy you’re reading and assuming is correct.

Rolling this out

Don’t attach a new condition-scoped Deny directly to a broad group and walk away. Roll it out the way I roll out any access-tightening change: attach it to a single test principal first, run the actual workflows that principal needs (including CI and automation, not just interactive console use), check CloudTrail for errorCode: AccessDenied events you didn’t expect, and only then widen the attachment to the group or account-wide SCP. Tag-based and MFA-based conditions are cheap to write and expensive to debug in production if you get the IfExists variant wrong — the fifteen minutes in the policy simulator is worth it.

Related posts