Understanding EC2 Security: IAM Policies and Network Security
When you launch an EC2 instance in AWS, you're not just spinning up a virtual machine—you're placing a critical asset into a complex cloud environment where threats can come from anywhere. Securing your EC2 instances requires mastering two complementary security layers: IAM Policies (who can do what) and Network Security (who can reach what). Together, these form a defense-in-depth strategy that protects both the control plane and the data plane of your infrastructure.
The Two Pillars of EC2 Security
Think of EC2 security as two concentric rings of protection. The outer ring is network security—security groups, network ACLs, and VPC design—which controls network-level access to your instances. The inner ring is IAM policies, which govern what actions users, roles, and services can perform on your EC2 resources. A properly secured EC2 environment requires both rings to be hardened. Network security alone won't stop someone with overly permissive IAM credentials from deleting an instance via the AWS CLI. Conversely, perfect IAM policies won't block an attacker from exploiting an open port if your security group allows traffic from 0.0.0.0/0.
IAM Policies for EC2: What They Are
IAM (Identity and Access Management) policies are JSON documents that define permissions for identities (users, groups, roles) or resources in AWS. For EC2 specifically, IAM policies control:
- Who can manage instances — Launch, terminate, start, stop, reboot EC2 instances
- Who can modify infrastructure — Create/modify security groups, key pairs, AMIs, volumes, snapshots
- What API calls are allowed — Describe instances, create tags, attach volumes
- What the instance itself can do — Via instance profiles attached to the instance, allowing it to access S3, DynamoDB, SQS, etc.
Why IAM Policies Matter for EC2
Without strict IAM policies, your AWS environment is vulnerable to privilege escalation, accidental resource deletion, and credential misuse. Consider a scenario: a developer has overly broad permissions and accidentally terminates a production instance, or a compromised CI/CD pipeline with full EC2 access is used to launch crypto-mining instances. Proper IAM policies implement the principle of least privilege, ensuring every identity has exactly the permissions it needs—nothing more. This limits the blast radius of any security incident and makes your environment auditable and compliant.
How to Use IAM Policies with EC2
There are two primary ways IAM policies interact with EC2: identity-based policies attached to users/roles that control what they can do to EC2, and resource-based policies or instance profiles that grant the EC2 instance itself permissions to access other AWS services.
Example 1: Restricting a Developer to Specific EC2 Actions
The following IAM policy allows a developer to list, start, and stop instances—but only those tagged with Environment: Development. They cannot terminate instances or modify production resources.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowEC2ListAll",
"Effect": "Allow",
"Action": "ec2:Describe*",
"Resource": "*"
},
{
"Sid": "AllowStartStopDevInstances",
"Effect": "Allow",
"Action": [
"ec2:StartInstances",
"ec2:StopInstances",
"ec2:RebootInstances"
],
"Resource": "arn:aws:ec2:*:*:instance/*",
"Condition": {
"StringEquals": {
"ec2:ResourceTag/Environment": "Development"
}
}
},
{
"Sid": "DenyTerminateAny",
"Effect": "Deny",
"Action": "ec2:TerminateInstances",
"Resource": "*"
}
]
}
Notice the Deny statement explicitly blocks termination—this overrides any other allow rules. The Condition block uses tag-based access control, a powerful pattern for segmenting permissions by environment, team, or project.
Example 2: Instance Profile for EC2 to Access S3
When your EC2 instance needs to read from an S3 bucket, you attach an instance profile (which wraps an IAM role) to the instance. The following policy grants read-only access to a specific bucket. The application running on the instance uses the AWS SDK and automatically receives temporary credentials via the instance metadata service.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::my-app-bucket",
"arn:aws:s3:::my-app-bucket/*"
]
}
]
}
To attach this role to an existing instance using the AWS CLI:
# Create the instance profile (if not already created)
aws iam create-instance-profile --instance-profile-name MyAppInstanceProfile
# Add the role to the instance profile
aws iam add-role-to-instance-profile \
--instance-profile-name MyAppInstanceProfile \
--role-name MyAppS3ReadOnlyRole
# Attach the instance profile to an existing EC2 instance
aws ec2 associate-iam-instance-profile \
--instance-id i-0abc123def4567890 \
--iam-instance-profile Name=MyAppInstanceProfile
Example 3: Enforcing MFA for Destructive Actions
This policy requires multi-factor authentication before allowing instance termination, adding an extra layer of protection against accidental or compromised-credential deletions.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "ec2:*",
"Resource": "*"
},
{
"Effect": "Deny",
"Action": "ec2:TerminateInstances",
"Resource": "*",
"Condition": {
"BoolIfExists": {
"aws:MultiFactorAuthPresent": "false"
}
}
}
]
}
Network Security for EC2: What It Is
Network security in AWS EC2 operates at multiple layers within your VPC (Virtual Private Cloud). The two primary mechanisms are Security Groups (stateful firewalls at the instance/ENI level) and Network ACLs (stateless firewalls at the subnet level). Together with proper VPC design—public/private subnets, NAT gateways, and route tables—these controls define which network traffic can reach your instances and which traffic can leave them.
Security Groups act as virtual firewalls for your EC2 instances. They are stateful, meaning if you allow inbound traffic on port 80, the corresponding outbound response traffic is automatically allowed regardless of outbound rules. They only support allow rules—you cannot explicitly deny traffic with security groups. Every rule specifies a protocol, port range, and source (CIDR block, another security group, or a prefix list).
Network ACLs (NACLs) are subnet-level, stateless firewalls that support both allow and deny rules. Because they are stateless, you must explicitly allow both inbound and outbound traffic for a connection to work (e.g., allow inbound on port 80 and outbound on ephemeral ports). NACLs are evaluated in rule-number order, and the first matching rule wins.
Why Network Security Matters
Network security is your first line of defense against unauthorized access, port scanning, DDoS attacks, and lateral movement within your VPC. A misconfigured security group exposing SSH (port 22) to 0.0.0.0/0 is one of the most common causes of EC2 compromise—bots constantly scan for open SSH ports and attempt brute-force attacks. Beyond external threats, proper network segmentation ensures that even if one instance is compromised, attackers cannot easily reach your database or other internal services. Network security also enables compliance with frameworks like PCI DSS, HIPAA, and SOC 2, all of which require strict network isolation and access controls.
How to Configure Network Security
Security Groups: Practical Examples
Example 1: Web Server Security Group — Allows HTTP/HTTPS from anywhere, SSH only from a specific office IP, and all outbound traffic.
# Create a security group for web servers
aws ec2 create-security-group \
--group-name WebServerSG \
--description "Security group for public web servers" \
--vpc-id vpc-0abcd1234efgh5678
# Allow inbound HTTP from anywhere
aws ec2 authorize-security-group-ingress \
--group-id sg-0abc123def456789 \
--protocol tcp --port 80 --cidr 0.0.0.0/0
# Allow inbound HTTPS from anywhere
aws ec2 authorize-security-group-ingress \
--group-id sg-0abc123def456789 \
--protocol tcp --port 443 --cidr 0.0.0.0/0
# Allow inbound SSH only from a trusted IP range
aws ec2 authorize-security-group-ingress \
--group-id sg-0abc123def456789 \
--protocol tcp --port 22 --cidr 203.0.113.0/24
# (Optional) Add a description tag for auditing
aws ec2 create-tags \
--resources sg-0abc123def456789 \
--tags Key=Purpose,Value=PublicWebTraffic
Example 2: Database Security Group Referencing Another Security Group — One of the most powerful features: instead of specifying IP addresses, you reference another security group. This allows any instance in the referenced group to access your database, regardless of their IP.
# Allow MySQL access from any instance that has the WebServerSG security group
aws ec2 authorize-security-group-ingress \
--group-id sg-0def456abc789012 \
--protocol tcp --port 3306 \
--source-group sg-0abc123def456789
# This means: "if traffic comes from an instance with sg-0abc123def456789,
# allow it on port 3306" — no IP addresses needed
In Terraform, this pattern looks like:
resource "aws_security_group" "web_sg" {
name = "WebServerSG"
description = "Web server security group"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["203.0.113.0/24"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "db_sg" {
name = "DatabaseSG"
description = "Database security group"
vpc_id = aws_vpc.main.id
ingress {
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.web_sg.id]
# Only instances with web_sg can connect to MySQL
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
Network ACLs: Adding Stateless Subnet-Level Rules
While security groups handle most use cases, NACLs add a subnet-level barrier useful for explicit deny rules, compliance requirements, or trapping misconfigured instances. Here's a NACL configuration for a public-facing subnet that explicitly blocks SSH from outside while allowing HTTP/HTTPS:
# Create a Network ACL
aws ec2 create-network-acl --vpc-id vpc-0abcd1234efgh5678
# Inbound rules (rule number determines evaluation order)
# Rule 100: Allow HTTP from anywhere
aws ec2 create-network-acl-entry \
--network-acl-id acl-0abc123def456 \
--rule-number 100 \
--protocol tcp --port-range From=80,To=80 \
--rule-action allow \
--cidr-block 0.0.0.0/0 \
--ingress
# Rule 110: Allow HTTPS from anywhere
aws ec2 create-network-acl-entry \
--network-acl-id acl-0abc123def456 \
--rule-number 110 \
--protocol tcp --port-range From=443,To=443 \
--rule-action allow \
--cidr-block 0.0.0.0/0 \
--ingress
# Rule 120: Allow ephemeral ports for return traffic (32768-65535)
aws ec2 create-network-acl-entry \
--network-acl-id acl-0abc123def456 \
--rule-number 120 \
--protocol tcp --port-range From=32768,To=65535 \
--rule-action allow \
--cidr-block 0.0.0.0/0 \
--ingress
# Rule 200: Explicitly DENY SSH from everywhere
aws ec2 create-network-acl-entry \
--network-acl-id acl-0abc123def456 \
--rule-number 200 \
--protocol tcp --port-range From=22,To=22 \
--rule-action deny \
--cidr-block 0.0.0.0/0 \
--ingress
# Rule * (asterisk): Catch-all deny for unmatched traffic (default rule)
# Automatically exists — no need to create, but good to know it's there
# Outbound rules: Allow all outbound traffic
aws ec2 create-network-acl-entry \
--network-acl-id acl-0abc123def456 \
--rule-number 100 \
--protocol -1 --port-range From=1,To=65535 \
--rule-action allow \
--cidr-block 0.0.0.0/0 \
--egress
# Associate NACL with subnet
aws ec2 associate-network-acl \
--network-acl-id acl-0abc123def456 \
--subnet-id subnet-0sub12345net67890
Because NACLs are stateless, even with an allow inbound rule, the outbound response must also be explicitly allowed. The inbound ephemeral ports rule (120) ensures that responses to outbound-initiated connections can return. The explicit SSH deny rule (200) acts as a backstop—even if someone accidentally adds SSH to a security group, the NACL blocks it at the subnet boundary.
Combining IAM and Network Security: A Real-World Architecture
Let's walk through a production-ready architecture that layers IAM and network controls. Imagine a three-tier application with web servers, application servers, and a database, all running on EC2 within a VPC.
Network Layer:
- Web servers live in a public subnet with a security group allowing 80/443 from the internet, 22 only from a bastion host security group
- Application servers are in a private subnet. Their security group allows traffic on port 8080 only from the web server security group (source-group reference)
- Database servers are in a separate private subnet. Their security group allows port 3306 only from the application server security group
- NACLs on the database subnet explicitly deny all inbound traffic from the internet (0.0.0.0/0) as a subnet-level failsafe
- Outbound internet access for private subnets goes through a NAT Gateway, preventing direct inbound connections
IAM Layer:
- The web server instances have an instance profile granting read-only access to an S3 bucket for static assets
- The application server instances have an instance profile allowing
sqs:SendMessageandsqs:ReceiveMessagefor job queue processing - Developers have IAM roles with permissions to deploy to development instances (tag-based), but cannot access production instances
- Operations staff can start/stop production instances but require MFA for termination
- CI/CD pipeline uses an IAM role with narrowly scoped permissions: describe instances, create tags, and initiate deployments—nothing else
Best Practices for EC2 Security
IAM Policy Best Practices
- Apply least privilege relentlessly — Start with zero permissions and add only what's needed. Use the
Access AdvisorandIAM Policy Simulatorto validate policies before deploying. - Use tag-based conditions — Instead of hardcoding instance IDs in policies, use
ec2:ResourceTagconditions to dynamically control access based on tags like Environment, CostCenter, or Team. - Require MFA for destructive actions — Always add a
Denystatement with aBoolIfExists: aws:MultiFactorAuthPresent: falsecondition for actions likeec2:TerminateInstances,ec2:DeleteSecurityGroup, orec2:DeleteSnapshot. - Use instance profiles instead of hardcoded credentials — Never store AWS access keys on an EC2 instance. Always use instance profiles/IAM roles, which automatically rotate credentials via the instance metadata service.
- Audit with AWS CloudTrail — Enable CloudTrail logging to capture all EC2 API calls. Set up alerts for sensitive actions like
TerminateInstancesorAuthorizeSecurityGroupIngresswith open CIDR ranges. - Separate permissions by environment — Use different AWS accounts (via AWS Organizations) or at minimum different IAM roles/policies for development, staging, and production.
Network Security Best Practices
- Never allow 0.0.0.0/0 on sensitive ports — SSH (22), RDP (3389), MySQL (3306), PostgreSQL (5432), and any administrative ports should never be open to the entire internet. Restrict to specific IPs or use a bastion host.
- Use security group referencing instead of IPs — Whenever possible, reference other security groups rather than IP addresses. This is more maintainable and automatically adapts as instances scale in/out.
- Apply NACLs as defense-in-depth — Even with tight security groups, add NACL deny rules at the subnet level for ports that should never receive internet traffic. This catches misconfigured security groups.
- Minimize open ports — Only open the exact ports your application needs. Avoid broad port ranges. Use
--port-range From=80,To=80instead of opening entire ranges. - Regularly audit security groups — Use AWS Config rules or tools like
aws-nuke, ScoutSuite, or Prowler to detect overly permissive security groups (e.g., 0.0.0.0/0 on port 22). - Place instances in private subnets by default — Unless an instance must serve public traffic directly, put it in a private subnet. Use load balancers, API Gateway, or CloudFront for public exposure.
- Monitor VPC Flow Logs — Enable VPC Flow Logs to capture network traffic metadata. Analyze for unexpected traffic patterns, rejected connections, or data exfiltration attempts.
- Restrict outbound traffic — Don't allow unrestricted outbound access (
0.0.0.0/0on all ports). Limit outbound rules to only the destinations and ports your instances genuinely need to reach. This prevents compromised instances from calling out to command-and-control servers or exfiltrating data.
Operational Security Best Practices
- Treat security groups as immutable infrastructure — Define them in Terraform/CloudFormation/CDK, not via the AWS Console. Changes should go through code review and CI/CD.
- Use AWS Systems Manager Session Manager instead of SSH — Session Manager provides auditable, IAM-controlled shell access to instances without opening port 22 at all. No bastion hosts, no SSH keys to manage.
- Enable IMDSv2 (Instance Metadata Service v2) — IMDSv2 requires a session token and prevents SSRF attacks that could leak IAM credentials. Enforce it with the
HttpTokens=requiredparameter on instance launch. - Regularly rotate and audit IAM credentials — Use IAM Access Analyzer to find overly permissive policies. Rotate any long-lived access keys and prefer temporary credentials via IAM roles.
- Implement AWS Organizations SCPs (Service Control Policies) — At the organization level, deny actions like creating instances without mandatory security group tags or disabling CloudTrail. SCPs are a hard boundary that even administrator users cannot override.
Automated Security Group Audit Script
Here's a practical Python script using boto3 to audit your security groups for overly permissive rules. Run this regularly in your CI/CD pipeline or as a scheduled Lambda function.
import boto3
def audit_security_groups():
ec2 = boto3.client('ec2')
security_groups = ec2.describe_security_groups()['SecurityGroups']
violations = []
for sg in security_groups:
for rule in sg.get('IpPermissions', []):
for ip_range in rule.get('IpRanges', []):
cidr = ip_range.get('CidrIp', '')
port_range = f"{rule.get('FromPort', 'ALL')}-{rule.get('ToPort', 'ALL')}"
# Check for dangerous open ports to the world
dangerous_ports = {22: 'SSH', 3389: 'RDP', 3306: 'MySQL',
5432: 'PostgreSQL', 27017: 'MongoDB'}
from_port = rule.get('FromPort', 0)
if cidr == '0.0.0.0/0' and from_port in dangerous_ports:
violations.append({
'SecurityGroupId': sg['GroupId'],
'SecurityGroupName': sg['GroupName'],
'Port': from_port,
'Service': dangerous_ports[from_port],
'Cidr': cidr,
'Severity': 'CRITICAL'
})
# Check for any port open to the world
if cidr == '0.0.0.0/0' and from_port not in [80, 443, 0]:
violations.append({
'SecurityGroupId': sg['GroupId'],
'SecurityGroupName': sg['GroupName'],
'Port': from_port,
'Service': 'Custom',
'Cidr': cidr,
'Severity': 'HIGH'
})
# Report violations
if violations:
print(f"Found {len(violations)} security group violations:")
for v in violations:
print(f"[{v['Severity']}] SG: {v['SecurityGroupName']} ({v['SecurityGroupId']}) - "
f"Port {v['Port']} ({v['Service']}) open to {v['Cidr']}")
else:
print("No security group violations found.")
return violations
if __name__ == '__main__':
audit_security_groups()
Enforcing IMDSv2 on Instance Launch
When launching instances via the CLI or SDK, always enforce IMDSv2 to protect against SSRF attacks that could leak instance profile credentials:
aws ec2 run-instances \
--image-id ami-0abcdef1234567890 \
--instance-type t3.micro \
--security-group-ids sg-0abc123def456789 \
--subnet-id subnet-0sub12345net67890 \
--iam-instance-profile Name=MyAppInstanceProfile \
--metadata-options HttpTokens=required,HttpPutResponseHopLimit=2,HttpEndpoint=enabled \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Environment,Value=Production}]' \
--user-data file://bootstrap.sh
The critical option is HttpTokens=required, which forces the instance metadata service to require a session token (v2), rendering simple SSRF attacks ineffective. The HttpPutResponseHopLimit=2 limits how far the token can travel, preventing container escape scenarios in Docker/Kubernetes environments.
Conclusion
EC2 security is not a single checkbox—it's a continuous practice built on the dual foundations of IAM policies and network security. IAM ensures that only the right identities can perform the right actions on your instances, while security groups and NACLs ensure that only the right network traffic can reach them. By combining tag-based IAM conditions, instance profiles instead of hardcoded credentials, security group referencing, explicit NACL deny rules, and operational practices like IMDSv2 enforcement and automated auditing, you create a defense-in-depth posture that protects your EC2 workloads against both external attackers and internal misconfigurations. Start with least privilege, enforce MFA for destructive actions, close every unnecessary port, and audit relentlessly—because in the cloud, security is not a destination but an ongoing journey of hardening and vigilance.