Imagine this: It's 3 AM, and you're getting alerts that someone just deleted something important in your production infrastructure. After some investigation, you found that an engineer has created an IAM role with AdministratorAccess policy for a "quick fix" and accidentally gave some Lambda or EC2 all permissions. AdministratorAccess Sound familiar? If you've worked with AWS some time, you've probably had this nightmare scenario cross your mind. The principle of least privilege is AWS security 101, but here's the secret nobody talks about: even when you follow it religiously, large organizations with delegated administration can still end up in permission hell. You want to give your teams autonomy, but you also want to sleep at night. Enter IAM Permissions Boundaries - the unsung hero of AWS security that acts like a safety net for your permissions. Think of them as the maximum speed limit for IAM entities. They don't give anyone new powers, but they are sure to prevent the maximum level of permission issues in the future What Are Permissions Boundaries (And Why Should You Care)? A permissions boundary is AWS's way of saying, "you can have permissions, but not THOSE permissions." It's an advanced IAM feature that sets a hard ceiling on what an IAM role or user can ever do, regardless of what policies you attach to them. Here's how AWS makes the decision when someone tries to do something: 1. Does the IAM policy say "yes"? 2. Does the permissions boundary say "yes"? 3. Only if both agree, the action happens Think of it like this: - IAM Policy: "Here's what you can do" - Permissions Boundary: "But you can NEVER do more than this, even if the opposite is defined in IAM policy". Deny effect in the boundary will always have priority over the IAM policy Real-World Scenarios Where This Matters The Delegated Admin Dilemma: You want your DevOps team to create roles for their applications, but you don't want them accidentally creating a role that can nuke your entire AWS bill or have an impact on your security. The Contractor Conundrum: That freelance developer needs access to work on the project, but you'd rather not give them the keys to the kingdom "just in case." The Self-Service Trap: Your teams want autonomy (good!), but autonomy without guardrails is just chaos with extra steps. Hands-On Demo: Testing Permissions Boundaries Step 1: Create Your Safety Net Step 1 Here's a permissions boundary that blocks S3 bucket deletion, but yes, under the hood - its a usual IAM policy with Deny effects: { "Version": "2012-10-17", "Statement": [ { "Action": [ "s3:DeleteBucket" ], "Effect": "Deny", "Resource": "*" } ] } { "Version": "2012-10-17", "Statement": [ { "Action": [ "s3:DeleteBucket" ], "Effect": "Deny", "Resource": "*" } ] } But this boundary doesn't allow anything else. We need to define Allow's. For example: { "Version": "2012-10-17", "Statement": [ { "Action": [ "s3:DeleteBucket" ], "Effect": "Deny", "Resource": "*" }, { "Action": [ "s3:*" ], "Effect": "Allow", "Resource": "*" } ] } { "Version": "2012-10-17", "Statement": [ { "Action": [ "s3:DeleteBucket" ], "Effect": "Deny", "Resource": "*" }, { "Action": [ "s3:*" ], "Effect": "Allow", "Resource": "*" } ] } It means if the policy itself allows "s3:PutObject" and it will be matched with the permission boundary, you will be allowed to write objects into the bucket. Save this as a JSON file and create a policy using AWS CLI $ aws iam create-policy \ --policy-name S3Boundary \ --policy-document file://boundary.json $ aws iam create-policy \ --policy-name S3Boundary \ --policy-document file://boundary.json This is your boundary - nobody with this boundary can do anything outside of S3. In my example, this is a role from a lambda function Step 2: Attach It to a Role Step 2 $ aws iam put-role-permissions-boundary \ --role-name MyLambdaRole \ --permissions-boundary arn:aws:iam::123456789012:policy/S3Boundary $ aws iam put-role-permissions-boundary \ --role-name MyLambdaRole \ --permissions-boundary arn:aws:iam::123456789012:policy/S3Boundary Now, `MyLambdaRole` is locked into the S3 sandbox, no matter what other policies you attach to it. In the AWS console, you can find the Permissions boundary if you open the role and scroll down a bit Permissions boundary Let's try a demo with a simple Python lambda function to list all S3 buckets: import json import boto3 def lambda_handler(event, context): s3 = boto3.client('s3') try: response = s3.list_buckets() bucket_names = [bucket['Name'] for bucket in response.get('Buckets', [])] return { 'statusCode': 200, 'body': json.dumps({ 'buckets': bucket_names }) } except Exception as e: return { 'statusCode': 500, 'body': json.dumps({'error': str(e)}) } import json import boto3 def lambda_handler(event, context): s3 = boto3.client('s3') try: response = s3.list_buckets() bucket_names = [bucket['Name'] for bucket in response.get('Buckets', [])] return { 'statusCode': 200, 'body': json.dumps({ 'buckets': bucket_names }) } except Exception as e: return { 'statusCode': 500, 'body': json.dumps({'error': str(e)}) } This function has these permissions. I will skip default CloudWatch permissions, as it's not important in this example: { "Effect": "Allow", "Action": "s3:*", "Resource": "*" } { "Effect": "Allow", "Action": "s3:*", "Resource": "*" } And, obviously, if I run this function, it will be able to list all buckets. $ aws lambda invoke \ --function-name boundary \ /dev/stdout {"statusCode": 200, "body": "{\"buckets\": [\"bucket1\", \"bucket2\", \"bucket3\", \"bucket4\"]}"} $ aws lambda invoke \ --function-name boundary \ /dev/stdout {"statusCode": 200, "body": "{\"buckets\": [\"bucket1\", \"bucket2\", \"bucket3\", \"bucket4\"]}"} Now, let's modify our lambda function to delete s3 bucket named "bucket1". To have a proper demo of the permissions boundary import json import boto3 def lambda_handler(event, context): s3 = boto3.client('s3') bucket_name = "bucket1" try: s3.delete_bucket(Bucket=bucket_name) return { 'statusCode': 200, 'body': json.dumps({ 'deleted_bucket': bucket_name }) } except Exception as e: return { 'statusCode': 500, 'body': json.dumps({'error': str(e)}) } import json import boto3 def lambda_handler(event, context): s3 = boto3.client('s3') bucket_name = "bucket1" try: s3.delete_bucket(Bucket=bucket_name) return { 'statusCode': 200, 'body': json.dumps({ 'deleted_bucket': bucket_name }) } except Exception as e: return { 'statusCode': 500, 'body': json.dumps({'error': str(e)}) } And invoke this lambda function: $ aws lambda invoke \ --function-name boundary \ /dev/stdout {"statusCode": 500, "body": "{\"error\": \"An error occurred (AccessDenied) when calling the DeleteBucket operation: User: arn:aws:sts::123456789012:assumed-role/MyLambdaRole/MyLambdaFunction is not authorized to perform: s3:DeleteBucket on resource: \\\"arn:aws:s3:::bucket1\\\" because no identity-based policy allows the s3:DeleteBucket action\"}"} $ aws lambda invoke \ --function-name boundary \ /dev/stdout {"statusCode": 500, "body": "{\"error\": \"An error occurred (AccessDenied) when calling the DeleteBucket operation: User: arn:aws:sts::123456789012:assumed-role/MyLambdaRole/MyLambdaFunction is not authorized to perform: s3:DeleteBucket on resource: \\\"arn:aws:s3:::bucket1\\\" because no identity-based policy allows the s3:DeleteBucket action\"}"} I can't do this action because it's restricted within the boundary, even if the IAM policy explicitly allows "s3:*" In the permission boundary, you can combine any deny and allow statements, for example, to make it very restrictive and forbid s3, ec2, lambda, IAM deletion: { "Version": "2012-10-17", "Statement": [ { "Sid": "DenyS3Deletes", "Effect": "Deny", "Action": [ "s3:DeleteBucket" ], "Resource": "*" }, { "Sid": "DenyKMSDeletes", "Effect": "Deny", "Action": [ "kms:ScheduleKeyDeletion", "kms:CancelKeyDeletion", "kms:DeleteAlias" ], "Resource": "*" }, { "Sid": "DenyEC2Deletes", "Effect": "Deny", "Action": [ "ec2:TerminateInstances", "ec2:DeleteVolume", "ec2:DeleteSnapshot", "ec2:DeleteSecurityGroup" ], "Resource": "*" }, { "Sid": "DenyIAMRoleDeletes", "Effect": "Deny", "Action": [ "iam:DeleteRole", "iam:DeleteRolePolicy", "iam:DetachRolePolicy" ], "Resource": "*" }, { "Sid": "DenyLambdaDeletes", "Effect": "Deny", "Action": [ "lambda:DeleteFunction", "lambda:DeleteAlias" ], "Resource": "*" }, { "Sid": "AllowEverythingElse", "Effect": "Allow", "Action": "*", "Resource": "*" } ] } { "Version": "2012-10-17", "Statement": [ { "Sid": "DenyS3Deletes", "Effect": "Deny", "Action": [ "s3:DeleteBucket" ], "Resource": "*" }, { "Sid": "DenyKMSDeletes", "Effect": "Deny", "Action": [ "kms:ScheduleKeyDeletion", "kms:CancelKeyDeletion", "kms:DeleteAlias" ], "Resource": "*" }, { "Sid": "DenyEC2Deletes", "Effect": "Deny", "Action": [ "ec2:TerminateInstances", "ec2:DeleteVolume", "ec2:DeleteSnapshot", "ec2:DeleteSecurityGroup" ], "Resource": "*" }, { "Sid": "DenyIAMRoleDeletes", "Effect": "Deny", "Action": [ "iam:DeleteRole", "iam:DeleteRolePolicy", "iam:DetachRolePolicy" ], "Resource": "*" }, { "Sid": "DenyLambdaDeletes", "Effect": "Deny", "Action": [ "lambda:DeleteFunction", "lambda:DeleteAlias" ], "Resource": "*" }, { "Sid": "AllowEverythingElse", "Effect": "Allow", "Action": "*", "Resource": "*" } ] } And the main question is how to force everyone to attach a permission boundary when they create a role? Just add this extra statement to the existing users: { "Sid": "DenyCreateRoleWithoutBoundary", "Effect": "Deny", "Action": "iam:CreateRole", "Resource": "*", "Condition": { "StringNotEquals": { "iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/S3Boundary" } } } { "Sid": "DenyCreateRoleWithoutBoundary", "Effect": "Deny", "Action": "iam:CreateRole", "Resource": "*", "Condition": { "StringNotEquals": { "iam:PermissionsBoundary": "arn:aws:iam::123456789012:policy/S3Boundary" } } } For example: Long story short - permissions boundaries can be attached to resource and identity-based roles, and remember - in the world of cloud security, paranoia isn't a bug — it's a feature.