The standard - S3 IAM Policies

In the previous post, we explored S3 Access Control Lists (ACLs) and learned why AWS recommends disabling them for most modern use cases. Now it's time to dive into the proper way of securing your S3 buckets: IAM policies and bucket policies.
Unlike ACLs, which are considered legacy and can become operationally chaotic, IAM policies offer centralized, scalable, and auditable access management. They're the foundation of modern AWS security architecture and the tool you should reach for when securing your S3 resources.
Today we'll focus on IAM policies - the backbone of AWS access control that actually makes sense.
IAM Policies vs Bucket Policies - What's the Difference?
Before we dive deep, let's clarify the two main policy types for S3:
IAM Policies are attached to IAM users, groups, or roles and define what actions those identities can perform across AWS services. They're identity-centric - "what can this user/role do?"
Bucket Policies are attached directly to S3 buckets and define who can access that specific bucket and what they can do with it. They're resource-centric - "who can access this bucket?"
Both use the same JSON policy language, but they serve different purposes and are evaluated together by AWS when determining access. Think of them as complementary layers of security rather than competing mechanisms.
Understanding IAM Policy Structure
IAM policies follow a standardized JSON structure that's both powerful and relatively straightforward once you understand the components:
1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Sid": "AllowS3ReadAccess",
6 "Effect": "Allow",
7 "Action": [
8 "s3:GetObject",
9 "s3:ListBucket"
10 ],
11 "Resource": [
12 "arn:aws:s3:::my-secure-bucket",
13 "arn:aws:s3:::my-secure-bucket/*"
14 ],
15 "Condition": {
16 "IpAddress": {
17 "aws:SourceIp": "203.0.113.0/24"
18 }
19 }
20 }
21 ]
22}
Let's break this down:
- Version: Currently, '2012-10-17' is the recommended and most up-to-date policy language version. It's best practice to use this version for all new policies.
- Statement: An array of individual permission statements
- Sid: Optional statement identifier for easier management
- Effect: Either "Allow" or "Deny"
- Action: The specific AWS API actions being granted/denied
- Resource: The AWS resources the policy applies to
- Condition: Optional conditions that must be met for the policy to apply
Essential S3 Actions and Resource Best Practices
Understanding the right S3 actions is crucial for creating effective policies. Here are the most commonly used ones:
Bucket-Level Actions
1{
2 "Action": [
3 "s3:ListBucket", // List objects in bucket
4 "s3:GetBucketLocation", // Get bucket region
5 "s3:GetBucketVersioning", // Check versioning status
6 "s3:ListBucketVersions" // List object versions
7 ],
8 "Resource": "arn:aws:s3:::my-bucket"
9}
Object-Level Actions
1{
2 "Action": [
3 "s3:GetObject", // Download objects
4 "s3:PutObject", // Upload objects
5 "s3:DeleteObject", // Delete objects
6 "s3:GetObjectVersion", // Get specific object versions
7 "s3:DeleteObjectVersion" // Delete specific versions
8 ],
9 "Resource": "arn:aws:s3:::my-bucket/*"
10}
Notice how bucket-level actions use the bucket ARN (arn:aws:s3:::my-bucket
)
while object-level actions use the object ARN pattern (arn:aws:s3:::my-bucket/*
).
Practical IAM Policy Examples
Read-Only Access Policy
Perfect for analytics tools or monitoring systems that need to read data but shouldn't modify anything:
1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Effect": "Allow",
6 "Action": [
7 "s3:GetObject",
8 "s3:ListBucket"
9 ],
10 "Resource": [
11 "${aws_s3_bucket.analytics_data.arn}",
12 "${aws_s3_bucket.analytics_data.arn}/*"
13 ]
14 }
15 ]
16}
In Terraform, always use resource references:
1# Good - uses Terraform resource reference
2resource "aws_iam_policy" "analytics_read" {
3 name = "AnalyticsReadAccess"
4
5 policy = jsonencode({
6 Version = "2012-10-17"
7 Statement = [
8 {
9 Effect = "Allow"
10 Action = [
11 "s3:GetObject",
12 "s3:ListBucket"
13 ]
14 Resource = [
15 aws_s3_bucket.analytics_data.arn,
16 "${aws_s3_bucket.analytics_data.arn}/*"
17 ]
18 }
19 ]
20 })
21}
22
23# Bad - hardcoded bucket name
24resource "aws_iam_policy" "analytics_read_bad" {
25 policy = jsonencode({
26 Resource = [
27 "arn:aws:s3:::company-analytics-data", # Don't do this!
28 "arn:aws:s3:::company-analytics-data/*"
29 ]
30 })
31}
Application Upload Policy
For applications that need to upload files but shouldn't be able to delete or list existing content:
1# Define the policy using Terraform data source
2data "aws_iam_policy_document" "app_upload" {
3 statement {
4 effect = "Allow"
5 actions = [
6 "s3:PutObject"
7 # Note: Removed s3:PutObjectAcl since we're moving away from ACLs
8 ]
9 resources = ["${aws_s3_bucket.app_uploads.arn}/*"]
10
11 # Require KMS encryption instead of default S3 encryption
12 condition {
13 test = "StringEquals"
14 variable = "s3:x-amz-server-side-encryption"
15 values = ["aws:kms"]
16 }
17
18 # Optionally require specific KMS key
19 condition {
20 test = "StringEquals"
21 variable = "s3:x-amz-server-side-encryption-aws-kms-key-id"
22 values = [aws_kms_key.s3_key.arn]
23 }
24 }
25}
26
27resource "aws_iam_policy" "app_upload" {
28 name = "AppUploadPolicy"
29 policy = data.aws_iam_policy_document.app_upload.json
30}
This approach ensures your policy always references the correct bucket, regardless of environment or bucket naming changes.
Time-Based Access Policy
Sometimes you need to restrict access to specific time windows:
1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Effect": "Allow",
6 "Action": [
7 "s3:GetObject",
8 ],
9 "Resource": [
10 "arn:aws:s3:::business-hours-data/*"
11 ],
12 "Condition": {
13 "DateGreaterThan": {
14 "aws:CurrentTime": "08:00:00Z"
15 },
16 "DateLessThan": {
17 "aws:CurrentTime": "18:00:00Z"
18 }
19 }
20 }
21 ]
Common Pitfalls and Security Considerations
The Wildcard Trap
One of the most dangerous mistakes is using overly broad wildcards as part of IAM policy:
1// DON'T DO THIS
2{
3 "Version": "2012-10-17",
4 "Statement": [
5 {
6 "Effect": "Allow",
7 "Action": "s3:*",
8 "Resource": "*"
9 }
10 ]
11}
This grants unlimited S3 access across your entire AWS account. Always be specific about actions and resources.
Or in case of bucket policy:
1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Sid": "AllowUserAccess",
6 "Effect": "Allow",
7 "Principal": {
8 "AWS": [
9 "arn:aws:iam::123456789012:user/alice",
10 "arn:aws:iam::123456789012:user/bob"
11 ]
12 },
13 "Action": "s3:*",
14 "Resource": [
15 "arn:aws:s3:::my-bucket",
16 "arn:aws:s3:::my-bucket/*"
17 ]
18 }
19 ]
20}
Where we're granting full control over bucket and it's content to
users bob
and alice
.
Resource ARN Best Practices
Avoid hardcoded bucket names in your policies! Using fixed bucket names like
arn:aws:s3:::my-bucket
creates several problems:
- Policies become environment-specific and hard to reuse
- Risk of accidentally granting access to wrong buckets
- Makes bucket renaming nearly impossible
- Creates maintenance nightmares across multiple environments
Instead, use variables and references.
As it's overview blog-post, sometime I used harcoded buckets name, in sake of simplicity.
Missing Bucket vs Object Permissions
A common source of confusion is forgetting that listing bucket contents and reading objects require different permissions on different resources:
1// Correct approach
2{
3 "Statement": [
4 {
5 "Effect": "Allow",
6 "Action": "s3:ListBucket",
7 "Resource": "arn:aws:s3:::my-bucket" // Note: no /*
8 },
9 {
10 "Effect": "Allow",
11 "Action": "s3:GetObject",
12 "Resource": "arn:aws:s3:::my-bucket/*" // Note: with /*
13 }
14 ]
15}
Cross-Account Access Considerations
When granting cross-account access, always use explicit conditions to prevent unauthorized access:
For IAM policies (attached to principals), use ResourceAccount
conditions
to limit which account's resources your principals can access:
1{
2 "Effect": "Allow",
3 "Action": "s3:GetObject",
4 "Resource": "arn:aws:s3:::shared-bucket/*",
5 "Condition": {
6 "StringEquals": {
7 "aws:ResourceAccount": ["123456789012", "987654321098"]
8 }
9 }
10}
For bucket policies (resource-based policies), use PrincipalAccount or PrincipalOrgID to limit who can access your bucket:
1{
2 "Version": "2012-10-17",
3 "Statement": [
4 {
5 "Effect": "Allow",
6 "Principal": {
7 "AWS": "*"
8 },
9 "Action": "s3:GetObject",
10 "Resource": "arn:aws:s3:::my-bucket/*",
11 "Condition": {
12 "StringEquals": {
13 "aws:PrincipalAccount": ["123456789012", "987654321098"]
14 }
15 }
16 }
17 ]
18}
Implementing S3 IAM Policies with Terraform
Terraform makes managing IAM policies much more maintainable than clicking through the AWS console. Here's how to implement the policies we discussed:
Basic Policy Attachment
1# Create the IAM policy
2resource "aws_iam_policy" "s3_read_only" {
3 name = "S3ReadOnlyAccess"
4 description = "Read-only access to specific S3 bucket"
5
6 policy = jsonencode({
7 Version = "2012-10-17"
8 Statement = [
9 {
10 Effect = "Allow"
11 Action = [
12 "s3:GetObject",
13 "s3:ListBucket"
14 ]
15 Resource = [
16 aws_s3_bucket.app_data.arn,
17 "${aws_s3_bucket.app_data.arn}/*"
18 ]
19 }
20 ]
21 })
22}
23
24# Attach to a role
25resource "aws_iam_role_policy_attachment" "s3_read_only" {
26 role = aws_iam_role.app_role.name
27 policy_arn = aws_iam_policy.s3_read_only.arn
28}
Using Data Sources for Flexibility
For more complex scenarios, you can use Terraform data sources to make your policies more dynamic:
1data "aws_iam_policy_document" "s3_upload_policy" {
2 statement {
3 effect = "Allow"
4
5 actions = [
6 "s3:PutObject",
7 ]
8
9 resources = [
10 "${aws_s3_bucket.uploads.arn}/uploads/${var.environment}/*"
11 ]
12
13 condition {
14 test = "StringEquals"
15 variable = "s3:x-amz-server-side-encryption"
16 values = ["aws:kms"] # Require KMS encryption
17 }
18
19 condition {
20 test = "StringEquals"
21 variable = "s3:x-amz-server-side-encryption-aws-kms-key-id"
22 values = [aws_kms_key.uploads_key.arn] # Require specific KMS key
23 }
24
25 condition {
26 test = "IpAddress"
27 variable = "aws:SourceIp"
28 values = var.allowed_ip_ranges
29 }
30 }
31}
32
33resource "aws_iam_policy" "s3_upload" {
34 name = "S3UploadPolicy-${var.environment}"
35 policy = data.aws_iam_policy_document.s3_upload_policy.json
36}
Policy Validation and Testing
Always validate your policies before applying them. Terraform can help catch syntax errors, but logic errors require testing:
1# Use locals for policy validation
2locals {
3 # Validate bucket ARN format
4 bucket_arn_pattern = "^arn:aws:s3:::[a-z0-9][a-z0-9\\-]*[a-z0-9]$"
5
6 # Ensure bucket name follows naming conventions
7 valid_bucket_name = can(regex("^[a-z0-9][a-z0-9\\-]*[a-z0-9]$", var.bucket_name))
8}
9
10# Use validation blocks
11variable "bucket_name" {
12 type = string
13 description = "S3 bucket name"
14
15 validation {
16 condition = can(regex("^[a-z0-9][a-z0-9\\-]*[a-z0-9]$", var.bucket_name))
17 error_message = "Bucket name must contain only lowercase letters, numbers, and hyphens."
18 }
19}
Policy Testing and Validation
The AWS IAM Policy Simulator is your best friend for testing policies before deployment. It allows you to simulate API calls and see whether they would be allowed or denied by your policies.
For Terraform users, consider using the aws_iam_policy_document
data source
instead of hardcoded JSON - it provides better syntax validation and
makes policies more readable.
Summary
IAM policies are the modern, scalable way to secure S3 resources. Unlike ACLs, they provide centralized management, powerful conditions, and excellent auditability. Key takeaways:
- Use specific actions and resources - avoid wildcards
- Remember bucket-level vs object-level permissions require different resource ARNs
- Leverage conditions for additional security controls
- Test policies thoroughly before production deployment
- Use Terraform for maintainable, version-controlled policy management
The combination of IAM policies and bucket policies gives you fine-grained control over who can access your S3 resources and what they can do with them. In the next article, we'll explore bucket policies and how they complement IAM policies to create a comprehensive S3 security strategy.
As always, even with a two-month-old keeping me busy, I'm committed to
sharing practical AWS security knowledge. The NixOS
experiment is currently
stopped, however I starting thinking about re-activation of my newsletter.
Who knows, for sure the format will be diffrent, as link aggregation is just
boring!