AWS IAM Policy Conditions For Cross-Account Resource Access Without Over-Privileging
AWS IAM Policy Conditions for Cross-Account Resource Access Without Over-Privileging
Cross-account resource access in AWS is like giving someone the keys to your house—you want them to get in, but only through the right door, at the right time, and for the right reasons. After spending countless late nights troubleshooting cross-account permission failures and cleaning up over-privileged policies, I've learned that IAM policy conditions are your best friend for implementing secure, granular cross-account access.
Most organizations start with overly permissive cross-account policies because they're easier to implement. But trust me, when you're dealing with a security incident at 2 AM because someone accessed resources they shouldn't have, you'll wish you'd spent the extra time on proper conditions from the beginning.
Understanding the Cross-Account Challenge
Cross-account access patterns are everywhere in modern AWS architectures. You might have:
- Centralized logging accounts that need to read CloudTrail logs from multiple production accounts
- CI/CD pipelines deploying applications across dev, staging, and production accounts
- Monitoring systems collecting metrics and logs from various business unit accounts
- Data analytics platforms aggregating data from operational accounts
The challenge isn't just enabling access—it's doing so without creating security holes that could be exploited later. Every cross-account trust relationship is a potential attack vector if not properly constrained.
The Foundation: Trust Policies and Resource-Based Policies
Before diving into conditions, let's establish the two primary mechanisms for cross-account access:
Trust Policies (AssumeRole)
Trust policies define who can assume a role. Here's a basic example:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::TRUSTED-ACCOUNT-ID:root"
},
"Action": "sts:AssumeRole"
}
]
}
This is dangerous because it allows anyone in the trusted account to assume the role. We need conditions to restrict this.
Resource-Based Policies
Resource-based policies are attached directly to resources like S3 buckets or KMS keys:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::TRUSTED-ACCOUNT-ID:root"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-bucket/*"
}
]
}
Again, this is overly permissive without proper conditions.
Essential IAM Policy Conditions for Cross-Account Access
Let's dive into the specific conditions that will help you implement secure cross-account access without over-privileging.
1. Time-Based Conditions
Time-based restrictions are crucial for operational access patterns. Your CI/CD pipeline probably shouldn't be deploying to production at 3 AM on a Sunday.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/DeploymentRole"
},
"Action": "sts:AssumeRole",
"Condition": {
"DateGreaterThan": {
"aws:CurrentTime": "2023-01-01T00:00:00Z"
},
"DateLessThan": {
"aws:CurrentTime": "2024-12-31T23:59:59Z"
},
"ForAllValues:StringLike": {
"aws:RequestedRegion": ["us-east-1", "us-west-2"]
},
"IpAddress": {
"aws:SourceIp": ["203.0.113.0/24", "198.51.100.0/24"]
}
}
}
]
}
For more granular time control, use business hours restrictions:
{
"Condition": {
"ForAllValues:NumericBetween": {
"aws:TokenIssueTime": ["32400", "64800"]
},
"ForAllValues:StringLike": {
"aws:userid": ["AIDACKCEVSQ6C2EXAMPLE:deployment-user"]
}
}
}
The aws:TokenIssueTime represents seconds since midnight UTC. This example allows access between 9 AM and 6 PM UTC.
2. Source IP and VPC Endpoint Restrictions
Network-based conditions ensure access only comes from expected locations:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/DataProcessorRole"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::sensitive-data-bucket/*",
"Condition": {
"StringEquals": {
"aws:SourceVpce": "vpce-12345678"
}
}
},
{
"Effect": "Deny",
"Principal": "*",
"Action": "*",
"Resource": "arn:aws:s3:::sensitive-data-bucket/*",
"Condition": {
"StringNotEquals": {
"aws:SourceVpce": "vpce-12345678"
}
}
}
]
}
This ensures all access goes through a specific VPC endpoint, preventing data exfiltration through unexpected network paths.
For IP-based restrictions with fallback options:
{
"Condition": {
"IpAddressIfExists": {
"aws:SourceIp": [
"203.0.113.0/24",
"198.51.100.0/24"
]
},
"Bool": {
"aws:ViaAWSService": "false"
}
}
}
The IfExists operator allows the condition to pass if the key doesn't exist, which is useful for service-to-service calls.
3. MFA and Strong Authentication Requirements
For sensitive operations, require multi-factor authentication:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/AdminRole"
},
"Action": "sts:AssumeRole",
"Condition": {
"Bool": {
"aws:MultiFactorAuthPresent": "true"
},
"NumericLessThan": {
"aws:MultiFactorAuthAge": "3600"
}
}
}
]
}
This requires MFA and ensures the MFA token is less than one hour old. For external identity providers, you might want:
{
"Condition": {
"StringEquals": {
"saml:aud": "https://signin.aws.amazon.com/saml"
},
"ForAnyValue:StringLike": {
"saml:groups": ["AdminGroup", "PowerUserGroup"]
},
"StringLike": {
"saml:cn": "*@company.com"
}
}
}
4. Request Context Conditions
These conditions examine the context of the API request itself:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/BackupRole"
},
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::backup-bucket",
"arn:aws:s3:::backup-bucket/*"
],
"Condition": {
"StringEquals": {
"s3:x-amz-server-side-encryption": "AES256"
},
"Bool": {
"s3:SecureTransport": "true"
},
"StringLike": {
"s3:prefix": "backups/production/*"
}
}
}
]
}
This ensures that cross-account backup operations only work with encrypted data over HTTPS and only access specific prefixes.
5. Service-Specific Conditions
Different AWS services have their own condition keys. Here's how to restrict cross-account RDS access:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/DatabaseMonitoringRole"
},
"Action": [
"rds:DescribeDBInstances",
"rds:DescribeDBClusters"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"rds:DatabaseEngine": ["mysql", "postgres"]
},
"ForAllValues:StringLike": {
"rds:db-tag/Environment": ["production", "staging"]
}
}
}
]
}
For Lambda cross-account invocation with restrictions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:root"
},
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:ACCOUNT:function:data-processor",
"Condition": {
"StringEquals": {
"lambda:InvocationType": "RequestResponse"
},
"ForAllValues:StringLike": {
"aws:userid": ["AIDACKCEVSQ6C2EXAMPLE:*"]
}
}
}
]
}
Advanced Condition Patterns
Combining Multiple Conditions
Complex scenarios require combining multiple condition types. Here's a policy for a cross-account CI/CD role that can only deploy during business hours from specific IP ranges:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/CICDRole"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "unique-external-id-12345"
},
"IpAddress": {
"aws:SourceIp": [
"203.0.113.0/24",
"198.51.100.0/24"
]
},
"DateGreaterThan": {
"aws:TokenIssueTime": "2023-01-01T00:00:00Z"
},
"NumericBetween": {
"aws:TokenIssueTime": ["32400", "64800"]
},
"StringLike": {
"aws:userid": "AIDACKCEVSQ6C2EXAMPLE:ci-cd-*"
}
}
}
]
}
Dynamic Conditions with Tags
Use resource tags to create dynamic access patterns:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/DataAnalystRole"
},
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::analytics-data",
"arn:aws:s3:::analytics-data/*"
],
"Condition": {
"StringEquals": {
"s3:ExistingObjectTag/AccessLevel": "public",
"s3:ExistingObjectTag/Department": "${aws:PrincipalTag/Department}"
},
"ForAllValues:StringEquals": {
"aws:PrincipalTag/Project": ["analytics", "reporting"]
}
}
}
]
}
This policy allows access only to objects tagged with the same department as the principal's department tag, and only for users tagged with specific projects.
Real-World Implementation Scenarios
Scenario 1: Centralized Logging Architecture
In this scenario, multiple application accounts need to send logs to a centralized logging account:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCrossAccountLogDelivery",
"Effect": "Allow",
"Principal": {
"AWS": [
"arn:aws:iam::111122223333:root",
"arn:aws:iam::444455556666:root",
"arn:aws:iam::777788889999:root"
]
},
"Action": [
"s3:PutObject",
"s3:PutObjectAcl"
],
"Resource": "arn:aws:s3:::central-logs-bucket/*",
"Condition": {
"StringEquals": {
"s3:x-amz-server-side-encryption": "aws:kms",
"s3:x-amz-server-side-encryption-aws-kms-key-id": "arn:aws:kms:us-east-1:LOGGING-ACCOUNT:key/12345678-1234-1234-1234-123456789012"
},
"StringLike": {
"s3:x-amz-metadata-directive": "REPLACE"
},
"ForAllValues:StringEquals": {
"s3:ExistingObjectTag/LogSource": ["cloudtrail", "application", "vpc-flow"]
}
}
}
]
}
Scenario 2: Cross-Account Database Access
For a scenario where an analytics account needs read-only access to production databases:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/AnalyticsReadRole"
},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {
"sts:ExternalId": "analytics-read-only-2023"
},
"Bool": {
"aws:SecureTransport": "true"
},
"DateGreaterThan": {
"aws:TokenIssueTime": "2023-01-01T00:00:00Z"
},
"DateLessThan": {
"aws:TokenIssueTime": "2023-12-31T23:59:59Z"
},
"IpAddress": {
"aws:SourceIp": "10.0.0.0/8"
}
}
}
]
}
Combined with a permission policy that restricts database operations:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"rds:DescribeDBInstances",
"rds:DescribeDBClusters",
"rds-data:ExecuteStatement"
],
"Resource": "*",
"Condition": {
"StringEquals": {
"rds:DatabaseEngine": ["mysql", "postgres"]
},
"ForAllValues:StringLike": {
"rds:db-tag/Environment": "production"
},
"StringLike": {
"rds-data:StatementType": "SELECT"
}
}
}
]
}
Scenario 3: Emergency Break-Glass Access
Sometimes you need emergency access mechanisms with strong audit trails:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EmergencyAccess",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::123456789012:role/EmergencyResponseRole"
},
"Action": "sts:AssumeRole",
"Condition": {
"Bool": {
"aws:MultiFactorAuthPresent": "true"
},
"NumericLessThan": {
"aws:MultiFactorAuthAge": "1800"
},
"StringEquals": {
"sts:ExternalId": "${aws:userid}"
},
"IpAddress": {
"aws:SourceIp": ["203.0.113.0/24"]
}
}
}
]
}
This requires MFA within the last 30 minutes, uses the caller's user ID as an external ID for additional verification, and restricts source IP.
Monitoring and Alerting for Cross-Account Access
Having proper policies is only half the battle. You need monitoring to ensure they're working correctly and to detect potential abuse.
CloudTrail Monitoring
Set up CloudTrail events to monitor cross-account AssumeRole operations:
{
"eventVersion": "1.05",
"userIdentity": {
"type": "AssumedRole",
"principalId": "AIDACKCEVSQ6C2EXAMPLE:cross-account-user",
"arn": "arn:aws:sts::123456789012:assumed-role/CrossAccountRole/cross-account-user",
"accountId": "123456789012"
},
"eventTime": "2023-11-20T15:30:00Z",
"eventSource": "sts.amazonaws.com",
"eventName": "AssumeRole",
"sourceIPAddress": "203.0.113.1",
"resources": [
{
"accountId": "987654321098",
"type": "AWS::STS::Role",
"ARN": "arn:aws:iam::987654321098:role/TargetRole"
}
]
}
Create CloudWatch alarms for unexpected cross-account access patterns:
aws logs put-metric-filter \
--log-group-name CloudTrail/CrossAccountAccess \
--filter-name UnexpectedCrossAccountAssumeRole \
--filter-pattern '{ ($.eventName = AssumeRole) && ($.sourceIPAddress != 203.0.113.*) }' \
--metric-transformations \
metricName=UnexpectedCrossAccountAccess,\
metricNamespace=Security,\
metricValue=1
Access Analyzer
Use AWS IAM Access Analyzer to identify overly permissive cross-account policies:
aws accessanalyzer create-analyzer \
--analyzer-name cross-account-analyzer \
--type ACCOUNT \
--tags Key=Purpose,Value=CrossAccountSecurity
Access Analyzer will flag external access and help you identify where your policies might be too permissive.
Common Pitfalls and How to Avoid Them
1. The "Works on My Machine" Syndrome
Problem: Policies work in development but fail in production due to different IP ranges, time zones, or network configurations.
Solution: Use policy simulation and test across all environments:
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/TestRole \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::test-bucket/test-object \
--context-entries ContextKeyName=aws:SourceIp,ContextKeyValues=203.0.113.1,ContextKeyType=ip
2. Time Zone Confusion
Problem: Time-based conditions fail because of UTC vs. local time confusion.
Solution: Always use UTC in policies and document time restrictions clearly:
{
"Comment": "Allow access during US business hours (9 AM - 6 PM EST = 14:00 - 23:00 UTC)",
"Condition": {
"ForAllValues:NumericBetween": {
"aws:TokenIssueTime": ["50400", "82800"]
}
}
}
3. Condition Key Confusion
Problem: Using the wrong condition keys or operators for specific scenarios.
Solution: Create a reference document mapping your use cases to appropriate condition keys:
| Use Case | Condition Key | Operator |
|---|---|---|
| Restrict by source IP | aws:SourceIp | IpAddress |
| Require encryption in transit | aws:SecureTransport | Bool |
| Time-based access | aws:CurrentTime | DateGreaterThan/DateLessThan |
| MFA requirement | aws:MultiFactorAuthPresent | Bool |
| Resource tagging | aws:ResourceTag/TagKey | StringEquals |
4. External ID Mismanagement
Problem: Hardcoding external IDs or using predictable values.
Solution: Use unique, unpredictable external IDs and rotate them regularly:
import secrets
import string
def generate_external_id():
"""Generate a cryptographically secure external ID"""
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for i in range(32))
external_id = generate_external_id()
print(f"Use this external ID: {external_id}")
Testing and Validation
Policy Simulation
Use the IAM policy simulator to test your conditions before deployment:
# Test S3 access from specific IP
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::123456789012:role/CrossAccountRole \
--action-names s3:GetObject \
--resource-arns arn:aws:s3:::my-bucket/my-object \
--context-entries \
ContextKeyName=aws:SourceIp,ContextKeyValues=203.0.113.1,ContextKeyType=ip \
ContextKeyName=s3:SecureTransport,ContextKeyValues=true,ContextKeyType=boolean
Integration Testing
Create automated tests for your cross-account access patterns:
import boto3
import pytest
from moto import mock_sts, mock_iam
@mock_sts
@mock_iam
def test_cross_account_assume_role_with_conditions():
# Setup
sts_client = boto3.client('sts', region_name='us-east-1')
# Test assuming role with valid conditions
try:
response = sts_client.assume_role(
RoleArn='arn:aws:iam::987654321098:role/TestRole',
RoleSessionName='test-session',
ExternalId='valid-external-id'
)
assert response['Credentials']['AccessKeyId']
except Exception as e:
pytest.fail(f"Valid assume role failed: {e}")
# Test assuming role with invalid external ID
with pytest.raises(Exception):
sts_client.assume_role(
RoleArn='arn:aws:iam::987654321098:role/TestRole',
RoleSessionName='test-session',
ExternalId='invalid-external-id'
)
Performance and Scale Considerations
Policy Evaluation Performance
IAM policy evaluation has performance implications, especially with complex conditions:
- Condition Complexity: Each condition adds evaluation overhead
- Policy Size: Larger policies take longer to evaluate
- Number of Policies: Multiple policies attached to a principal increase evaluation time
Best Practice: Use specific conditions rather than complex logic:
// Good - specific and fast
{
"Condition": {
"StringEquals": {
"aws:RequestedRegion": "us-east-1"
}
}
}
// Avoid - complex pattern matching
{
"Condition": {
"StringLike": {
"aws:RequestedRegion": "us-*-[0-9]"
}
}
}
Scaling Cross-Account Access
When managing hundreds of accounts, consider policy templates and automation:
import json
import boto3
def create_cross_account_policy(trusted_accounts, resource_arn, conditions=None):
"""Generate standardized cross-account policy"""
policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"AWS": [f"arn:aws:iam::{account}:root" for account in trusted_accounts]
},
"Action": "sts:AssumeRole"
}
]
}
if conditions:
policy["Statement"][0]["Condition"] = conditions
return json.dumps(policy, indent=2)
# Standard conditions for production access
production_conditions = {
"StringEquals": {
"sts:ExternalId": "production-access-2023"
},
"Bool": {
"aws:SecureTransport": "true"
},
"IpAddress": {
"aws:SourceIp": ["10.0.0.0/8", "172.16.0.0/12"]
}
}
policy = create_cross_account_policy(
trusted_accounts=["123456789012", "234567890123"],
resource_arn="arn:aws:iam::987654321098:role/ProductionAccess",
conditions=production_conditions
)
Conclusion
Implementing secure cross-account access without over-privileging requires a deep understanding of IAM policy conditions and careful planning. The key principles are:
- Start with least privilege: Only grant the minimum permissions needed
- Layer your conditions: Combine multiple condition types for defense in depth
- Monitor continuously: Use CloudTrail and Access Analyzer to detect issues
- Test thoroughly: Use policy simulation and integration testing
- Document everything: Make your security decisions auditable and repeatable
Remember, security is not a one-time configuration—it's an ongoing process. Review your cross-account policies regularly, monitor for anomalies, and update conditions as your architecture evolves.
The examples and patterns in this article provide a solid foundation, but every organization's needs are different. Adapt these patterns to your specific use cases, and always err on the side of caution. It's better to have a user contact you about access issues than to have an attacker gain unauthorized access to your resources.
When you're implementing these policies, start with a staging environment, test thoroughly, and roll out changes gradually. And yes, you'll probably get paged at 3 AM when someone can't access something they need—but you'll also sleep better knowing your cross-account access is properly secured.
Was this article helpful?
SRE & Observability Engineer
If it's not measured, it doesn't exist. SLO-driven, metrics-obsessed, and the person who gets paged at 3 AM so you don't have to. Observability isn't optional.
Related Articles
AWS IAM Least Privilege: Policies That Won't Lock You Out
Build AWS IAM policies using least privilege without locking yourself out — practical patterns for roles, service accounts, and permission boundaries.
SSL Certificate Verification Failed In Curl And Python Requests: Diagnosing And Fixing CERTIFICATE_VERIFY_FAILED Errors
SSL verification errors are one of those issues that bite you at the worst possible moment — usually in production, usually when you're on-call. Here's you...
Kubernetes Security Hardening for Production: The Complete Guide
Harden Kubernetes clusters for production with RBAC, network policies, pod security standards, secrets management, and admission controllers.
Security Headers & Configs: Cheat Sheet
Security headers and configuration reference — copy-paste snippets for Nginx, Kubernetes Ingress, Cloudflare, and Helmet.js.
Automated Dependency Vulnerability Scanning in CI: Stop Shipping Known CVEs
Add automated dependency vulnerability scanning to your CI pipeline using Trivy and Grype. Catch known CVEs before they hit production.
OPA Gatekeeper: Enforcing Kubernetes Admission Control Policies That Actually Stop Misconfigurations
Deploy OPA Gatekeeper to enforce Kubernetes admission policies — block privileged containers, enforce labels, and prevent misconfigurations.
More in Security
View all →Mozilla SOPS: Encrypted Secrets in Git for GitOps Workflows That Don't Leak
Use Mozilla SOPS to encrypt secrets in Git for secure GitOps workflows. Covers AGE, AWS KMS, and ArgoCD integration with real examples.
Container Image Scanning with Trivy: Complete Setup Guide
Set up Trivy for container image vulnerability scanning — from local development to CI/CD pipeline integration with actionable remediation.
Kubernetes RBAC: A Practical Guide to Least-Privilege Access Control
Implement least-privilege RBAC in Kubernetes to prevent lateral movement and privilege escalation — with real threat models and pipeline-ready examples.
HashiCorp Vault and Kubernetes: Secrets Management That Actually Works
Integrate HashiCorp Vault with Kubernetes to eliminate static secrets from your cluster — with working manifests, threat models, and pipeline automation.