AWS / Guide 4
Security groups and network ACLs
How security groups and NACLs decide, referencing groups instead of IP addresses, risky rules, and checking what's allowed.
AWS has two network filters inside a VPC. Security groups sit on each network interface and do almost all of the work. Network ACLs sit on subnets and are a coarse second layer. Most exposure problems come from one security group rule that allows SSH or RDP from 0.0.0.0/0, added "just for today" and never removed.
Security groups and NACLs compared
| Security group | Network ACL | |
|---|---|---|
| Applies to | Network interfaces (instances, load balancers, RDS, Lambda in a VPC...) | Every resource in the subnets it's associated with |
| Rules | Allow only | Allow and deny |
| Evaluation | All rules together: if any rule allows, traffic is allowed | In number order; first match wins |
| State | Stateful: replies are allowed automatically | Stateless: return traffic needs its own rule |
| Default | New group: no inbound, all outbound | Default NACL: allow everything both ways |
| Can reference | CIDRs, prefix lists and other security groups | CIDRs only |
| Quotas (default) | 60 inbound and 60 outbound rules; 5 groups per interface | 20 rules each way |
Compared with Azure: a security group is closer to an NSG on a NIC combined with an application security group, and there's no priority number to manage. A NACL is closer to an NSG on a subnet, but stateless.
How security groups decide
- There are no deny rules. Anything not allowed is denied. To block one address inside an allowed range, you need a NACL (or AWS Network Firewall).
- Rules add up. An interface can have several security groups; the effective rules are the union of all of them. A permissive group attached "temporarily" overrides every careful one.
- Changes apply immediately to new connections. Existing tracked connections usually continue until they close.
- Outbound is open by default. New security groups allow all outbound traffic. Tighten it for sensitive tiers such as databases, which rarely need to start connections to anything.
- The default security group in each VPC allows all traffic between its members. Don't use it: remove its rules (CIS benchmark item) and create named groups instead.
Reference security groups, not IP addresses
A security group rule can use another security group as its source. That's the AWS equivalent of Azure's application security groups, and it's the most useful habit to build:
| Group | Inbound rule | Source |
|---|---|---|
sg-alb | TCP 443 | 0.0.0.0/0 (or CloudFront's managed prefix list) |
sg-web | TCP 8443 | sg-alb |
sg-app | TCP 8080 | sg-web |
sg-db | TCP 5432 | sg-app |
Scale the web tier from two instances to twenty and no rule changes: anything with sg-web attached is allowed. Only the load balancer faces the internet; the instances don't need public IPs at all. Referencing works across peered VPCs in the same region and across VPCs attached to a Transit Gateway (when security group referencing is turned on for the attachment).
For groups of addresses outside AWS (office ranges, a partner), use a customer-managed prefix list: one list, referenced from many groups, updated in one place. AWS also publishes managed prefix lists for S3, DynamoDB and CloudFront origins.
$vpcId = (Get-EC2Vpc -Filter @{ Name = 'tag:Name'; Values = 'vpc-notes-test' }).VpcId
# The groups
$web = New-EC2SecurityGroup -VpcId $vpcId -GroupName 'sg-web' -Description 'Web tier'
$app = New-EC2SecurityGroup -VpcId $vpcId -GroupName 'sg-app' -Description 'App tier'
# sg-app accepts 8080 only from members of sg-web
$fromWeb = New-Object Amazon.EC2.Model.UserIdGroupPair -Property @{ GroupId = $web }
$rule = New-Object Amazon.EC2.Model.IpPermission -Property @{
IpProtocol = 'tcp'; FromPort = 8080; ToPort = 8080; UserIdGroupPairs = @($fromWeb) }
Grant-EC2SecurityGroupIngress -GroupId $app -IpPermission $rule
# Attach sg-web to an instance's network interface (replaces the list, so include any it should keep)
$instance = (Get-EC2Instance -Filter @{ Name = 'tag:Name'; Values = 'ec2-notes-test-01' }).Instances[0]
Edit-EC2InstanceAttribute -InstanceId $instance.InstanceId -Group @($web)
Network ACLs as a second layer
Most VPCs run fine with the default allow-all NACL and good security groups. Use custom NACLs where a coarse, subnet-wide rule helps:
- Blocking a specific address range quickly during an incident (security groups can't deny).
- Separating tiers as a backstop: the data subnets only accept traffic from the app subnets, whatever the security groups say.
Because NACLs are stateless, every allow needs a matching rule for the reply, usually the ephemeral ports 1024–65535. Forgetting the outbound ephemeral rule is the classic cause of "the security group is right but it still doesn't work". Number rules in steps of 10 or 100 so you can insert one later.
Rules that cause trouble
These are the findings that come up in almost every review. Get-AwsSecurityGroupReport.ps1 looks for all of them across every region.
- SSH (22) or RDP (3389) open to
0.0.0.0/0or::/0. Use Systems Manager Session Manager instead, which needs no inbound port at all (see Guide 5). - Database and remote-management ports open to the internet: 1433, 3306, 5432, 6379, 27017, 5985/5986 (WinRM) and similar.
- All traffic (protocol
-1) from anywhere. Usually added while troubleshooting. It turns the group off. - Forgotten IPv6. A rule tightened for
0.0.0.0/0with a twin still open for::/0. - Wide port ranges, such as
0-65535or3000-4000, which quietly include 3389. - Unused groups that nobody dares delete.
Find-AwsOrphanedResources.ps1lists groups not attached to any interface.
AWS Config's managed rules restricted-ssh, restricted-common-ports and vpc-default-security-group-closed, and the matching Security Hub controls, catch these continuously.
A naming and description convention
- Name groups by role, not by instance:
sg-<app>-<tier>, for examplesg-payroll-web. A group named after one server ends up attached to ten. - Fill in the rule description on every rule (who asked, which ticket). It's the only place to record why a rule exists, and it shows in the console and in reports.
- Manage groups in CloudFormation or Terraform. Drift detection then shows rules added by hand.
Check what is actually allowed
When a connection fails, don't read the rules and guess. Ask AWS:
- Reachability Analyzer (VPC console) traces the path between two resources, through security groups, NACLs, route tables and gateways, and names the component that blocks it.
- Network Access Analyzer finds unintended paths, for example "anything reachable from the internet gateway on port 22".
- VPC flow logs record accepted and rejected connections for a VPC, subnet or interface. Send them to CloudWatch Logs for troubleshooting or to S3 for long-term, cheaper storage.
# Security groups on an instance, and their inbound rules
$instance.SecurityGroups | ForEach-Object { Get-EC2SecurityGroup -GroupId $_.GroupId } | ForEach-Object {
$g = $_
$g.IpPermissions | Select-Object @{n='Group';e={$g.GroupName}}, IpProtocol, FromPort, ToPort,
@{n='From';e={ (@($_.Ipv4Ranges.CidrIp) + @($_.Ipv6Ranges.CidrIpv6) + @($_.UserIdGroupPairs.GroupId)) -join ', ' }}
}
# Turn on flow logs for the VPC, to CloudWatch Logs
New-EC2FlowLog -ResourceId $vpcId -ResourceType VPC -TrafficType ALL -LogDestinationType cloud-watch-logs `
-LogGroupName '/vpc/vpc-notes-test/flow' -DeliverLogsPermissionArn 'arn:aws:iam::111122223333:role/vpc-flow-logs'
Examples to try
From aws.ravindran.in: VPC security groups, network ACLs, VPC basics and VPC peering.