Scoping IAM Policies with Tag, IP, and MFA Conditions
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.
Join the discussion
Comments for this post live on social — reply to the thread.
Related posts
Cloud roundup: macOS Screen Sharing bug now under attack
A patched macOS Screen Sharing flaw is being exploited to plant crypto miners, a Windows Defender bypass has no fix yet, and EC2 gets built-in app health checks.
Cutting NAT gateway costs with VPC endpoints that actually help
How gateway and interface VPC endpoints replace NAT gateway traffic for AWS API calls, what they cost instead, and which traffic still has to go through NAT.
Cloud roundup: S3 finally names the policy that denied you
AWS S3 access-denied errors now name the exact policy ARN, Client VPN gets a scriptable CLI, and OpenAI ships authorized offensive-security models on Bedrock.