DEV Community

Cover image for AWS IAM Roles with Terraform: Security Best Practices

AWS IAM Roles with Terraform: Security Best Practices

πŸ‘‹ Hey there, tech enthusiasts!

I'm Sarvar, a Cloud Architect with a passion for transforming complex technological challenges into elegant solutions. With extensive experience spanning Cloud Operations (AWS & Azure), Data Operations, Analytics, DevOps, and Generative AI, I've had the privilege of architecting solutions for global enterprises that drive real business impact. Through this article series, I'm excited to share practical insights, best practices, and hands-on experiences from my journey in the tech world. Whether you're a seasoned professional or just starting out, I aim to break down complex concepts into digestible pieces that you can apply in your projects.


AWS IAM Roles with Terraform: Security Best Practices

"Access keys are like house keys lose them once and you have to change all the locks. IAM roles are like a doorman who knows who you are."

🎯 Welcome Back!

Remember in Article 8 when you deployed Lambda with API Gateway? You used IAM roles to let Lambda access S3 and CloudWatch. That was just the beginning.

Here's the reality: Every AWS service needs permissions. And there are two ways to grant them:

  • Access Keys - Like giving everyone a copy of your house key
  • IAM Roles - Like having a smart lock that knows who should enter

Most developers start with access keys because they're simple. Then they face:

  • Keys leaked in Git repositories
  • Keys stolen from compromised servers
  • Keys that never expire
  • No idea who's using which keys
  • Nightmare rotation process

That's where IAM roles save you.

By the end of this article, you'll:

  • βœ… Create IAM roles and policies with Terraform
  • βœ… Implement least privilege access
  • βœ… Use instance profiles for EC2
  • βœ… Attach AWS managed policies
  • βœ… Create custom IAM policies
  • βœ… Configure S3 bucket policies
  • βœ… Never use access keys again

Time Required: 45 minutes (25 min read + 20 min practice)

Cost: $0 (IAM is free, only pay for resources used)

Difficulty: Intermediate

Let's secure your infrastructure properly! πŸš€


πŸ’” The Problem: Access Key Disasters

The Nightmare Scenario

It's 3 AM. Your phone explodes with alerts:

AWS BILLING ALERT
Unusual Activity Detected
Current Charges: $47,000 and climbing
Services: 500 EC2 instances in 12 regions
Cryptocurrency mining detected
Estimated Time: Started 6 hours ago
Enter fullscreen mode Exit fullscreen mode

What happened:

  1. Developer put AWS access keys in code
  2. Committed to public GitHub repo
  3. Bot scraped keys within 15 minutes
  4. Attacker launched mining operation
  5. Your bill: $47,000 in one night

This is a real scenario that happens daily.

Common Access Key Mistakes:

❌ Hardcoded in Application:

# app.py
AWS_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCY"
Enter fullscreen mode Exit fullscreen mode

❌ In Environment Variables (on EC2):

export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG"
Enter fullscreen mode Exit fullscreen mode

❌ In Docker Images:

ENV AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
ENV AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG
Enter fullscreen mode Exit fullscreen mode

❌ In Configuration Files:

# config.yml
aws:
  access_key: AKIAIOSFODNN7EXAMPLE
  secret_key: wJalrXUtnFEMI/K7MDENG
Enter fullscreen mode Exit fullscreen mode

❌ Shared in Slack/Email:

"Hey, use these keys for the dev environment:
Access Key: AKIAIOSFODNN7EXAMPLE
Secret: wJalrXUtnFEMI/K7MDENG"
Enter fullscreen mode Exit fullscreen mode

The Real Cost:

  • πŸ’° Financial: $47K average for crypto mining attacks
  • πŸ”’ Security: Data breaches, compliance violations
  • ⏰ Time: Days to investigate and remediate
  • 😰 Stress: Explaining to management why this happened
  • πŸ“‰ Reputation: Customer trust damaged

Sound familiar? Let's fix this permanently.


🌟 What are IAM Roles? (Quick Theory)

Simple Definition

IAM Role = Temporary credentials that AWS services can assume. Think of it as:

  • A badge that proves who you are
  • Automatically renewed every hour
  • Can't be stolen because it's not stored anywhere
  • Specific to one service or resource

IAM Policy = List of permissions. What you're allowed to do.

Think of it like this:

Access Keys (Bad):

Developer β†’ Creates keys β†’ Puts in code β†’ Git β†’ 
Everyone has keys β†’ πŸ’₯ Leaked β†’ Attacker uses them
Enter fullscreen mode Exit fullscreen mode

IAM Roles (Good):

EC2 Instance β†’ Assumes role β†’ AWS gives temporary credentials β†’
Uses them for 1 hour β†’ Automatically renewed β†’ 
Can't be stolen (not stored anywhere) βœ…
Enter fullscreen mode Exit fullscreen mode

Why You Need This

Scenario 1: Developer Leaves Company

  • Access Keys: They still have keys, can access everything
  • IAM Roles: Revoke their IAM access, all roles stop working instantly
  • Result: Zero risk

Scenario 2: Server Compromised

  • Access Keys: Attacker steals keys, uses them forever
  • IAM Roles: Temporary credentials expire in 1 hour, limited damage
  • Result: Contained breach

Scenario 3: Compliance Audit

  • Access Keys: "Who used this key?" β†’ No idea
  • IAM Roles: CloudTrail shows exactly who did what, when
  • Result: Pass audit

IAM Components

1. Role - Identity that can be assumed
2. Policy - Permissions (what you can do)
3. Trust Policy - Who can assume the role
4. Instance Profile - Attaches role to EC2

Example:

EC2 Instance β†’ Instance Profile β†’ IAM Role β†’ IAM Policy β†’ S3 Access βœ…
Enter fullscreen mode Exit fullscreen mode
EC2 Instance β†’ Instance Profile β†’ IAM Role β†’ IAM Policy β†’ S3 Access βœ…
Enter fullscreen mode Exit fullscreen mode

πŸ“‹ Prerequisites

Before starting, make sure you have:

  • βœ… Completed Article 8: Lambda Serverless
  • βœ… Terraform installed (v1.0+)
  • βœ… AWS CLI configured
  • βœ… Basic understanding of AWS permissions
  • βœ… SSH key pair in AWS

πŸ—οΈ What We're Building

EC2 Instance
    ↓ (IAM Role)
    β”œβ”€β†’ S3 Bucket (Read/Write)
    β”œβ”€β†’ CloudWatch (Metrics/Logs)
    └─→ SSM (Session Manager)

Lambda Function
    ↓ (IAM Role)
    └─→ S3 Bucket (Read Only)
Enter fullscreen mode Exit fullscreen mode

Components:

  1. IAM Roles - Identity for services
  2. IAM Policies - Permissions
  3. Instance Profile - Attaches role to EC2
  4. S3 Bucket Policy - Resource-based access
  5. Managed Policies - AWS pre-built policies
  6. Custom Policies - Your specific permissions

πŸ“ Project Structure

09-iam-security/
β”œβ”€β”€ main.tf
β”œβ”€β”€ variables.tf
β”œβ”€β”€ outputs.tf
└── terraform.tfvars
Enter fullscreen mode Exit fullscreen mode

πŸ”§ Step 1: Define Variables

Create variables.tf:

variable "aws_region" {
  description = "AWS region"
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "Project name"
  type        = string
  default     = "terraform-iam"
}

variable "environment" {
  description = "Environment"
  type        = string
  default     = "dev"
}

variable "s3_bucket_name" {
  description = "S3 bucket name (must be globally unique)"
  type        = string
  default     = "terraform-iam-demo-bucket-2026"
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t2.micro"
}

variable "key_name" {
  description = "SSH key pair name"
  type        = string
  default     = "my-key"
}

variable "my_ip" {
  description = "Your IP for SSH"
  type        = string
  default     = "0.0.0.0/0"
}
Enter fullscreen mode Exit fullscreen mode

πŸͺ£ Step 2: Create S3 Bucket with Policy

Add to main.tf:

terraform {
  required_version = ">= 1.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

# S3 Bucket
resource "aws_s3_bucket" "app_bucket" {
  bucket        = var.s3_bucket_name
  force_destroy = true

  tags = {
    Name        = var.s3_bucket_name
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

resource "aws_s3_bucket_versioning" "app_bucket" {
  bucket = aws_s3_bucket.app_bucket.id
  versioning_configuration {
    status = "Enabled"
  }
}

# S3 Bucket Policy
resource "aws_s3_bucket_policy" "app_bucket" {
  bucket = aws_s3_bucket.app_bucket.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AllowEC2RoleAccess"
        Effect = "Allow"
        Principal = {
          AWS = aws_iam_role.ec2_role.arn
        }
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:ListBucket"
        ]
        Resource = [
          aws_s3_bucket.app_bucket.arn,
          "${aws_s3_bucket.app_bucket.arn}/*"
        ]
      },
      {
        Sid    = "DenyUnencryptedUploads"
        Effect = "Deny"
        Principal = "*"
        Action = "s3:PutObject"
        Resource = "${aws_s3_bucket.app_bucket.arn}/*"
        Condition = {
          StringNotEquals = {
            "s3:x-amz-server-side-encryption" = "AES256"
          }
        }
      }
    ]
  })
}
Enter fullscreen mode Exit fullscreen mode

Understanding Bucket Policy:

Statement 1: Allow EC2 Role

{
  "Principal": { "AWS": "arn:aws:iam::123456789012:role/ec2-role" },
  "Action": ["s3:GetObject", "s3:PutObject"],
  "Resource": ["arn:aws:s3:::bucket/*"]
}
Enter fullscreen mode Exit fullscreen mode

Allows our EC2 role to read/write objects.

Statement 2: Enforce Encryption

{
  "Effect": "Deny",
  "Condition": {
    "StringNotEquals": {
      "s3:x-amz-server-side-encryption": "AES256"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Blocks unencrypted uploads. Security best practice!


πŸ‘€ Step 3: Create IAM Role for EC2

Add to main.tf:

# IAM Role for EC2
resource "aws_iam_role" "ec2_role" {
  name = "${var.project_name}-ec2-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "ec2.amazonaws.com"
      }
    }]
  })

  tags = {
    Name = "${var.project_name}-ec2-role"
  }
}
Enter fullscreen mode Exit fullscreen mode

Assume Role Policy:

{
  "Principal": {
    "Service": "ec2.amazonaws.com"
  }
}
Enter fullscreen mode Exit fullscreen mode

This says: "EC2 service can assume this role"

Key Concepts:

  • Role = Identity (who)
  • Assume Role Policy = Trust policy (who can use this role)
  • Permissions Policy = What the role can do

πŸ“œ Step 4: Create Custom IAM Policy

Add to main.tf:

# Custom IAM Policy for S3 Access
resource "aws_iam_policy" "s3_access" {
  name        = "${var.project_name}-s3-access"
  description = "Allow S3 access for application"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:DeleteObject",
          "s3:ListBucket"
        ]
        Resource = [
          aws_s3_bucket.app_bucket.arn,
          "${aws_s3_bucket.app_bucket.arn}/*"
        ]
      }
    ]
  })
}

# Attach S3 Policy to EC2 Role
resource "aws_iam_role_policy_attachment" "ec2_s3" {
  role       = aws_iam_role.ec2_role.name
  policy_arn = aws_iam_policy.s3_access.arn
}
Enter fullscreen mode Exit fullscreen mode

Policy Structure:

{
  "Effect": "Allow",           // Allow or Deny
  "Action": ["s3:GetObject"],  // What actions
  "Resource": ["arn:..."]      // On which resources
}
Enter fullscreen mode Exit fullscreen mode

Resource ARN Patterns:

  • arn:aws:s3:::bucket - The bucket itself
  • arn:aws:s3:::bucket/* - Objects in the bucket

Least Privilege:

  • Only allows specific actions
  • Only on specific bucket
  • No wildcard permissions

πŸ”— Step 5: Attach AWS Managed Policies

Add to main.tf:

# Attach CloudWatch Policy
resource "aws_iam_role_policy_attachment" "ec2_cloudwatch" {
  role       = aws_iam_role.ec2_role.name
  policy_arn = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy"
}

# Attach SSM Policy (for Session Manager)
resource "aws_iam_role_policy_attachment" "ec2_ssm" {
  role       = aws_iam_role.ec2_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# Instance Profile
resource "aws_iam_instance_profile" "ec2_profile" {
  name = "${var.project_name}-ec2-profile"
  role = aws_iam_role.ec2_role.name
}
Enter fullscreen mode Exit fullscreen mode

AWS Managed Policies:

  • Pre-built by AWS
  • Maintained and updated by AWS
  • Follow best practices
  • Use when possible!

Common Managed Policies:

  • CloudWatchAgentServerPolicy - Send metrics/logs
  • AmazonSSMManagedInstanceCore - Session Manager access
  • AmazonS3ReadOnlyAccess - Read S3
  • AWSLambdaBasicExecutionRole - Lambda logging

Instance Profile:
Attaches IAM role to EC2 instance. Required!


πŸ” Step 6: Create Lambda IAM Role

Add to main.tf:

# IAM Role for Lambda
resource "aws_iam_role" "lambda_role" {
  name = "${var.project_name}-lambda-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })

  tags = {
    Name = "${var.project_name}-lambda-role"
  }
}

# Lambda Basic Execution Policy
resource "aws_iam_role_policy_attachment" "lambda_basic" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# Custom Lambda Policy for S3 Read-Only
resource "aws_iam_policy" "lambda_s3_read" {
  name        = "${var.project_name}-lambda-s3-read"
  description = "Allow Lambda to read from S3"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "s3:GetObject",
        "s3:ListBucket"
      ]
      Resource = [
        aws_s3_bucket.app_bucket.arn,
        "${aws_s3_bucket.app_bucket.arn}/*"
      ]
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_s3" {
  role       = aws_iam_role.lambda_role.name
  policy_arn = aws_iam_policy.lambda_s3_read.arn
}
Enter fullscreen mode Exit fullscreen mode

Different Assume Role Policy:

{
  "Principal": {
    "Service": "lambda.amazonaws.com"  // Lambda, not EC2!
  }
}
Enter fullscreen mode Exit fullscreen mode

Least Privilege for Lambda:

  • Only GetObject and ListBucket
  • No PutObject or DeleteObject
  • Read-only access

πŸ–₯️ Step 7: Deploy EC2 with IAM Role

Add to main.tf:

# Security Group for EC2
resource "aws_security_group" "ec2" {
  name        = "${var.project_name}-ec2-sg"
  description = "Security group for EC2"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = [var.my_ip]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.project_name}-ec2-sg"
  }
}

# EC2 Instance
resource "aws_instance" "app" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = var.instance_type
  key_name      = var.key_name

  iam_instance_profile   = aws_iam_instance_profile.ec2_profile.name
  vpc_security_group_ids = [aws_security_group.ec2.id]

  user_data = <<-EOF
              #!/bin/bash
              yum update -y
              yum install -y aws-cli

              # Create test file
              echo "Hello from Terraform IAM Demo" > /home/ec2-user/test.txt
              chown ec2-user:ec2-user /home/ec2-user/test.txt

              # Create helper scripts
              cat > /home/ec2-user/test-s3.sh <<'SCRIPT'
              #!/bin/bash
              echo "Testing S3 Access..."

              # List bucket
              echo "1. Listing bucket contents:"
              aws s3 ls s3://${var.s3_bucket_name}/

              # Upload file
              echo "2. Uploading test file:"
              aws s3 cp /home/ec2-user/test.txt s3://${var.s3_bucket_name}/test.txt

              # Download file
              echo "3. Downloading file:"
              aws s3 cp s3://${var.s3_bucket_name}/test.txt /home/ec2-user/downloaded.txt

              echo "S3 test complete!"
SCRIPT

              chmod +x /home/ec2-user/test-s3.sh
              chown ec2-user:ec2-user /home/ec2-user/test-s3.sh

              # Create IAM info script
              cat > /home/ec2-user/show-iam-info.sh <<'SCRIPT'
              #!/bin/bash
              echo "=== IAM Role Information ==="
              echo "Instance Profile:"
              curl -s http://169.254.169.254/latest/meta-data/iam/info | jq .

              echo -e "\n=== Current IAM Identity ==="
              aws sts get-caller-identity

              echo -e "\n=== Attached Policies ==="
              ROLE_NAME=$(aws sts get-caller-identity --query Arn --output text | cut -d'/' -f2)
              aws iam list-attached-role-policies --role-name $ROLE_NAME 2>/dev/null || echo "Cannot list policies (expected)"
SCRIPT

              chmod +x /home/ec2-user/show-iam-info.sh
              chown ec2-user:ec2-user /home/ec2-user/show-iam-info.sh
              EOF

  tags = {
    Name        = "${var.project_name}-instance"
    Environment = var.environment
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Line:

iam_instance_profile = aws_iam_instance_profile.ec2_profile.name
Enter fullscreen mode Exit fullscreen mode

This attaches the IAM role to EC2!

User Data Scripts:

  1. test-s3.sh - Test S3 permissions
  2. show-iam-info.sh - Show IAM role info

πŸ“€ Step 8: Define Outputs

Create outputs.tf:

# S3 Bucket Output
output "s3_bucket_name" {
  description = "S3 bucket name"
  value       = aws_s3_bucket.app_bucket.id
}

output "s3_bucket_arn" {
  description = "S3 bucket ARN"
  value       = aws_s3_bucket.app_bucket.arn
}

# IAM Role Outputs
output "ec2_role_name" {
  description = "EC2 IAM role name"
  value       = aws_iam_role.ec2_role.name
}

output "ec2_role_arn" {
  description = "EC2 IAM role ARN"
  value       = aws_iam_role.ec2_role.arn
}

output "lambda_role_name" {
  description = "Lambda IAM role name"
  value       = aws_iam_role.lambda_role.name
}

# EC2 Output
output "ec2_public_ip" {
  description = "EC2 public IP"
  value       = aws_instance.app.public_ip
}

output "ec2_instance_id" {
  description = "EC2 instance ID"
  value       = aws_instance.app.id
}

# Test Commands
output "test_commands" {
  description = "Commands to test IAM permissions"
  value = <<-EOT
    SSH to EC2:
    ssh -i ${var.key_name}.pem ec2-user@${aws_instance.app.public_ip}

    Test S3 access:
    aws s3 ls s3://${aws_s3_bucket.app_bucket.id}
    aws s3 cp test.txt s3://${aws_s3_bucket.app_bucket.id}/

    Test CloudWatch:
    aws cloudwatch put-metric-data --namespace TestApp --metric-name TestMetric --value 1
  EOT
}
Enter fullscreen mode Exit fullscreen mode

βš™οΈ Step 9: Set Variable Values

Create terraform.tfvars:

aws_region = "us-east-1"
project_name = "terraform-iam"
environment = "dev"

# Change this to a unique bucket name
s3_bucket_name = "terraform-iam-demo-bucket-2026"

instance_type = "t2.micro"
key_name = "my-key"
my_ip = "0.0.0.0/0"
Enter fullscreen mode Exit fullscreen mode

Important: Change s3_bucket_name to something unique!


πŸš€ Step 10: Deploy Infrastructure

1. Initialize Terraform

terraform init
Enter fullscreen mode Exit fullscreen mode

2. Review Plan

terraform plan
Enter fullscreen mode Exit fullscreen mode

You should see:

Plan: 13 to add, 0 to change, 0 to destroy.
Enter fullscreen mode Exit fullscreen mode

3. Apply Configuration

terraform apply
Enter fullscreen mode Exit fullscreen mode

Type yes when prompted.


βœ… Step 11: Test IAM Permissions

1. Get EC2 IP

EC2_IP=$(terraform output -raw ec2_public_ip)
echo $EC2_IP
Enter fullscreen mode Exit fullscreen mode

2. SSH to EC2

ssh -i my-key.pem ec2-user@$EC2_IP
Enter fullscreen mode Exit fullscreen mode

3. Check IAM Role

./show-iam-info.sh
Enter fullscreen mode Exit fullscreen mode

Output:

{
  "Code": "Success",
  "InstanceProfileArn": "arn:aws:iam::123456789012:instance-profile/terraform-iam-ec2-profile",
  "InstanceProfileId": "AIPAI...",
  "LastUpdated": "2026-03-24T16:00:00Z"
}

{
  "UserId": "AROAI...:i-0123456789abcdef0",
  "Account": "123456789012",
  "Arn": "arn:aws:sts::123456789012:assumed-role/terraform-iam-ec2-role/i-0123456789abcdef0"
}
Enter fullscreen mode Exit fullscreen mode

Notice: No access keys! Using IAM role.

4. Test S3 Access

./test-s3.sh
Enter fullscreen mode Exit fullscreen mode

Output:

Testing S3 Access...
1. Listing bucket contents:
(empty or shows files)

2. Uploading test file:
upload: ./test.txt to s3://terraform-iam-demo-bucket-2026/test.txt

3. Downloading file:
download: s3://terraform-iam-demo-bucket-2026/test.txt to ./downloaded.txt

S3 test complete!
Enter fullscreen mode Exit fullscreen mode

5. Test CloudWatch Access

aws cloudwatch put-metric-data \
  --namespace TestApp \
  --metric-name TestMetric \
  --value 1
Enter fullscreen mode Exit fullscreen mode

Should succeed (no output = success).

6. Test Denied Action

# Try to list all S3 buckets (should fail)
aws s3 ls
Enter fullscreen mode Exit fullscreen mode

Output:

An error occurred (AccessDenied) when calling the ListBuckets operation
Enter fullscreen mode Exit fullscreen mode

Perfect! Least privilege working - can only access our specific bucket.


πŸ” Understanding IAM Components

IAM Role vs IAM User

IAM User:

  • Permanent identity
  • Has access keys
  • For humans or long-term apps

IAM Role:

  • Temporary identity
  • No access keys
  • For services (EC2, Lambda)
  • Credentials rotate automatically

Trust Policy vs Permissions Policy

Trust Policy (Assume Role):

{
  "Principal": { "Service": "ec2.amazonaws.com" }
}
Enter fullscreen mode Exit fullscreen mode

WHO can use this role.

Permissions Policy:

{
  "Action": ["s3:GetObject"],
  "Resource": ["arn:aws:s3:::bucket/*"]
}
Enter fullscreen mode Exit fullscreen mode

WHAT the role can do.

Identity-Based vs Resource-Based

Identity-Based (IAM Policy):

resource "aws_iam_policy" "s3_access" {
  policy = jsonencode({
    Action   = ["s3:GetObject"]
    Resource = ["arn:aws:s3:::bucket/*"]
  })
}
Enter fullscreen mode Exit fullscreen mode

Attached to identity (role/user).

Resource-Based (Bucket Policy):

resource "aws_s3_bucket_policy" "bucket" {
  policy = jsonencode({
    Principal = { AWS = "arn:aws:iam::123:role/my-role" }
    Action    = ["s3:GetObject"]
  })
}
Enter fullscreen mode Exit fullscreen mode

Attached to resource (bucket).

When to use which:

  • Identity-based: Default choice
  • Resource-based: Cross-account access, specific resource control

πŸ§ͺ Advanced Testing

Test Policy Evaluation

# SSH to EC2
ssh -i my-key.pem ec2-user@$EC2_IP

# Test allowed actions
aws s3 cp test.txt s3://YOUR-BUCKET/test.txt  # βœ… Should work
aws s3 ls s3://YOUR-BUCKET/                   # βœ… Should work

# Test denied actions
aws s3 ls                                     # ❌ Should fail
aws s3 rm s3://OTHER-BUCKET/file.txt         # ❌ Should fail
aws ec2 describe-instances                    # ❌ Should fail
Enter fullscreen mode Exit fullscreen mode

View CloudTrail Logs

# From local machine
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=Username,AttributeValue=terraform-iam-ec2-role \
  --max-results 10
Enter fullscreen mode Exit fullscreen mode

Shows all actions performed by the role!

Test Session Manager (No SSH Keys!)

# From local machine
aws ssm start-session --target $(terraform output -raw ec2_instance_id)
Enter fullscreen mode Exit fullscreen mode

Connect without SSH keys - using IAM role!


πŸ› Troubleshooting

Issue 1: "Access Denied" on S3

Cause: IAM policy or bucket policy issue.

Solution:

# Check role is attached
aws ec2 describe-instances \
  --instance-ids <INSTANCE_ID> \
  --query 'Reservations[0].Instances[0].IamInstanceProfile'

# Check policies attached to role
aws iam list-attached-role-policies \
  --role-name terraform-iam-ec2-role
Enter fullscreen mode Exit fullscreen mode

Issue 2: "No credentials found"

Cause: Instance profile not attached.

Solution:

# Ensure this line exists in aws_instance
iam_instance_profile = aws_iam_instance_profile.ec2_profile.name
Enter fullscreen mode Exit fullscreen mode

Issue 3: Bucket policy conflicts

Error:

Error: error putting S3 Bucket Policy: MalformedPolicy
Enter fullscreen mode Exit fullscreen mode

Solution: Check JSON syntax:

# Validate JSON
echo '{"Version":"2012-10-17"}' | jq .
Enter fullscreen mode Exit fullscreen mode

Issue 4: Can't assume role

Error:

User: arn:aws:sts::123:assumed-role/role/i-xxx is not authorized to perform: sts:AssumeRole
Enter fullscreen mode Exit fullscreen mode

Solution: Check assume role policy allows the service:

{
  "Principal": {
    "Service": "ec2.amazonaws.com"  // Must match service
  }
}
Enter fullscreen mode Exit fullscreen mode

πŸ’° Cost Breakdown

Monthly Costs:

Resource Cost
IAM Roles $0.00 (Free)
IAM Policies $0.00 (Free)
S3 Bucket $0.00 (Free tier)
t2.micro EC2 $8.50 or Free tier
Total ~$0-8.50/month

IAM is FREE!

  • No charge for roles
  • No charge for policies
  • No charge for users (up to limits)

πŸ” Security Best Practices

βœ… What We Did Right

  1. No Access Keys

    • Used IAM roles instead
    • Automatic credential rotation
  2. Least Privilege

    • Only necessary permissions
    • Specific resources only
  3. Separate Roles

    • Different roles for EC2 and Lambda
    • Each with minimum permissions
  4. Resource-Based Policies

    • Bucket policy enforces encryption
    • Defense in depth
  5. Managed Policies

    • Used AWS-maintained policies
    • Automatically updated

πŸ”’ Additional Recommendations

1. Use Policy Conditions:

policy = jsonencode({
  Statement = [{
    Condition = {
      IpAddress = {
        "aws:SourceIp" = "203.0.113.0/24"
      }
    }
  }]
})
Enter fullscreen mode Exit fullscreen mode

2. Enable MFA for Sensitive Actions:

Condition = {
  BoolIfExists = {
    "aws:MultiFactorAuthPresent" = "true"
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Use Permission Boundaries:

resource "aws_iam_role" "limited" {
  permissions_boundary = aws_iam_policy.boundary.arn
}
Enter fullscreen mode Exit fullscreen mode

4. Regular Audits:

# List unused roles
aws iam get-credential-report

# Check last used
aws iam get-role --role-name my-role
Enter fullscreen mode Exit fullscreen mode

5. Use SCPs (Service Control Policies):
Organization-level restrictions.


🧹 Cleanup

terraform destroy
Enter fullscreen mode Exit fullscreen mode

Type yes when prompted.

What gets deleted:

  • βœ… EC2 Instance
  • βœ… S3 Bucket (if empty)
  • βœ… IAM Roles
  • βœ… IAM Policies
  • βœ… Security Group

Note: If S3 bucket has objects, delete them first:

aws s3 rm s3://YOUR-BUCKET --recursive
Enter fullscreen mode Exit fullscreen mode

πŸ“š Key Concepts Learned

1. IAM Roles

resource "aws_iam_role" "role" {
  assume_role_policy = jsonencode({...})
}
Enter fullscreen mode Exit fullscreen mode

2. IAM Policies

resource "aws_iam_policy" "policy" {
  policy = jsonencode({...})
}
Enter fullscreen mode Exit fullscreen mode

3. Policy Attachments

resource "aws_iam_role_policy_attachment" "attach" {
  role       = aws_iam_role.role.name
  policy_arn = aws_iam_policy.policy.arn
}
Enter fullscreen mode Exit fullscreen mode

4. Instance Profiles

resource "aws_iam_instance_profile" "profile" {
  role = aws_iam_role.role.name
}
Enter fullscreen mode Exit fullscreen mode

5. Bucket Policies

resource "aws_s3_bucket_policy" "policy" {
  policy = jsonencode({...})
}
Enter fullscreen mode Exit fullscreen mode

🎯 Real-World Applications

1. Web Applications

  • EC2 with S3 access for uploads
  • RDS access for database
  • CloudWatch for logging

2. Data Processing

  • Lambda reading from S3
  • Writing to DynamoDB
  • SNS notifications

3. CI/CD Pipelines

  • CodeBuild with ECR access
  • CodeDeploy with EC2 access
  • S3 for artifacts

4. Microservices

  • Each service with own role
  • Least privilege per service
  • Service-to-service auth

πŸš€ What's Next?

In the next article, we'll add:

  • RDS Database - Managed PostgreSQL/MySQL
  • Secrets Manager - Secure credential storage
  • Private Subnets - Database isolation
  • Security Groups - Network-level access control

Coming Up: Article 10: RDS + Secrets Manager with Terraform


πŸ“– Additional Resources


πŸ“‚ Source Code

The complete source code and Terraform configuration used in this article can be found on GitHub:

πŸš€ Terraform By Sarvar

Complete Terraform tutorial series from basics to advanced concepts

Terraform AWS License dev.to

πŸ“– Read the Series β€’ 🌐 Portfolio β€’ πŸ’Ό LinkedIn


πŸ“š Series Overview

This repository contains ONLY Terraform code examples for the Terraform By Sarvar tutorial series.

⚠️ IMPORTANT: This repo contains only .tf files and infrastructure code. Articles are published on dev.to, not stored here.

πŸ“– Read the series on dev.to: https://dev.to/sarvar_04/series/36963

🎯 What You'll Learn

  • βœ… Infrastructure as Code (IaC) fundamentals
  • βœ… Terraform basics to advanced concepts
  • βœ… AWS resource provisioning
  • βœ… Best practices and real-world patterns
  • βœ… Production-ready configurations

πŸ“Š Series Progress

Progress Status

πŸ“– Article Series

πŸ“˜ Foundation (Articles 1-5) βœ… Complete

  1. Introduction to Terraform & IaC - βœ… Published
  2. Installation & Setup - βœ… Published
  3. Your First AWS Resource - βœ… Published
  4. Understanding Terraform State - βœ… Published
  5. Variables and Outputs - βœ… Published

πŸ“— Real Infrastructure (Articles 6-10)

  1. Building a VPC from Scratch…

Getting Started

Follow these steps to run the project on your local machine:

  1. Clone the repository:
git clone https://github.com/simplynadaf/terraform-by-sarvar.git
Enter fullscreen mode Exit fullscreen mode
  1. Navigate to the project directory:
cd terraform-by-sarvar/articles/09-iam-security
Enter fullscreen mode Exit fullscreen mode

βœ… Summary

Today you learned:

  • βœ… Create IAM roles and policies
  • βœ… Implement least privilege access
  • βœ… Use instance profiles for EC2
  • βœ… Attach managed and custom policies
  • βœ… Configure bucket policies
  • βœ… Avoid access keys completely
  • βœ… Test and verify permissions
  • βœ… Follow security best practices

You now understand AWS IAM security! πŸŽ‰


πŸ”— Connect & Share

If you found this helpful:

Next Article: Secure Database Deployment with RDS + Secrets Manager


Written by: Sarvar Nadaf

Series: Terraform By Sarvar

Part: 9 of 20

Tags: #Terraform #AWS #DevOps #IaC #IAM #Security #CloudComputing


πŸ“Œ Wrapping Up

Thank you for reading. I hope this article provided practical insights and a clearer understanding of the topic.

If you found this useful:

  • ❀️ Like if it added value
  • πŸ¦„ Unicorn if you’re applying it today
  • πŸ’Ύ Save it for your next optimization session
  • πŸ”„ Share it with your team

πŸ’‘ What’s Next

More deep dives are coming soon on:

  • Cloud Operations
  • GenAI & Agentic AI
  • DevOps Automation
  • Data & Platform Engineering

Follow along for weekly insights and hands-on guides.


🌐 Portfolio & Work

You can explore my full body of work, certifications, architecture projects, and technical articles here:

πŸ‘‰ Visit My Website


πŸ› οΈ Services I Offer

If you're looking for hands-on guidance or collaboration, I provide:

  • Cloud Architecture Consulting (AWS / Azure)
  • DevSecOps & Automation Design
  • FinOps Optimization Reviews
  • Technical Writing (Cloud, DevOps, GenAI)
  • Product & Architecture Reviews
  • Mentorship & 1:1 Technical Guidance

🀝 Let’s Connect

I’d love to hear your thoughts. Feel free to drop a comment or connect with me on:

πŸ”— LinkedIn

For collaborations, consulting, or technical discussions, reach out at:

πŸ“§ simplynadaf@gmail.com


Found this helpful? Share it with your team.

⭐ Star the repo β€’ πŸ“– Follow the series β€’ πŸ’¬ Ask questions

Made by Sarvar Nadaf

🌐 https://sarvarnadaf.com


Top comments (0)