A production web stack on EC2 needs surprisingly few security group rules: the load balancer accepts 80/443 from the internet, the app tier accepts its port from the load balancer’s security group only, and the database accepts its port from the app tier’s security group only. The pattern that makes this work is referencing security groups instead of IP ranges — membership follows the instance, so rules keep working when instances scale, replace, or change IPs. Everything beyond that core is either a specific, documented exception or a hole someone forgot to close.
What’s happening
A security group is a stateful firewall attached to an elastic network interface. Stateful means return traffic is allowed automatically — you never write a rule for responses, only for who may initiate. All rules are allow rules; anything not allowed is dropped. Multiple groups on one instance merge into a single effective permit list. (For where security groups end and network ACLs begin, see security groups vs NACLs.)
The design mistake behind most messy accounts is writing rules against IP addresses. Instance IPs change on every replacement; CIDR-based rules rot into a list nobody dares delete. Referencing a group ID (sg-...) as the source means “any interface that wears that group,” which is exactly the intent: “the app servers, whichever they are today, may reach the database.”
Here is the core cookbook for an ALB-fronted web stack:
sg-alb— inbound: TCP 443 from0.0.0.0/0(and TCP 80 from0.0.0.0/0only if you redirect to HTTPS at the listener). Nothing else.sg-app— inbound: the app port (say TCP 8080) from sourcesg-alb. If instances talk to each other (clustering, gossip), add the needed port from sourcesg-appitself.sg-db— inbound: TCP 5432 (or 3306) from sourcesg-app. Nothing from anywhere else, ever — not even the office IP.sg-redis— inbound: TCP 6379 fromsg-app, if you run ElastiCache or a cache host.- No SSH rule anywhere. Interactive access goes through SSM Session Manager, which needs no inbound port at all. If you must keep 22, scope it to a bastion’s group or a VPN CIDR — never
0.0.0.0/0.
Outbound: the default allow-all is acceptable for most app tiers, but the database group is a good place to tighten egress since a database has no business initiating connections to the internet.
What to check first
- Anything open to
0.0.0.0/0besides the load balancer’s 80/443? Port 22, 3306, 5432, 6379, 9200, or 3389 open to the world is the finding that matters most. - Rules with CIDR sources inside the VPC where a group reference should be — a sign the pattern was never adopted.
- Unused groups and the default group in use. The VPC default group allows all traffic from itself, which silently flattens your tiers if instances wear it.
- Rule descriptions. A rule without a description is a rule nobody will ever feel safe deleting.
- Groups near the per-group rule quota (60 inbound rules per group by default, adjustable) — a hint that one group is doing five jobs.
How to verify
Find every rule open to the world across the account:
aws ec2 describe-security-group-rules \
--filters Name=cidr-ipv4,Values=0.0.0.0/0 \
--query 'SecurityGroupRules[?IsEgress==`false`].[GroupId,IpProtocol,FromPort,ToPort]' \
--output table
Dump a specific group’s full rule set:
aws ec2 describe-security-groups --group-ids sg-0123456789abcdef0 \
--query 'SecurityGroups[].IpPermissions'
Find groups attached to nothing (candidates for deletion):
aws ec2 describe-network-interfaces \
--query 'NetworkInterfaces[].Groups[].GroupId' --output text | tr '\t' '\n' | sort -u > /tmp/used.txt
aws ec2 describe-security-groups --query 'SecurityGroups[].GroupId' --output text | tr '\t' '\n' | sort -u > /tmp/all.txt
comm -13 /tmp/used.txt /tmp/all.txt
In the console, EC2 > Security Groups > (group) > Inbound rules shows sources; VPC > Security > Security groups shows the same data VPC-side. To test reality rather than intent, VPC > Reachability Analyzer confirms whether a path from the ALB to an instance actually exists. The EC2 documentation covers rule semantics in full.
The approach
- Create one group per role, named for the role (
web-prod-alb,web-prod-app,web-prod-db), not per instance and not one mega-group for everything. - Wire tiers by group reference. Source
sg-albon the app port, sourcesg-appon the database port. Delete the CIDR-based equivalents once traffic confirms the references work. - Remove SSH from the internet the same day. Enable Session Manager, verify a session opens, then delete the port-22 rule. This single change removes the most-scanned attack surface an EC2 fleet has.
- Write a description on every rule — “ALB health checks + traffic”, “app to Postgres”, with a ticket reference. Descriptions are a native field on rules; use them.
- Evict the default security group from workloads. Keep it empty of rules so accidental attachment grants nothing.
- Tighten egress on the data tier. Databases and caches get outbound only where they demonstrably need it (OS repos via a proxy, or nothing).
- Put the groups in IaC so a rule change is a reviewed diff, and drift from console edits is detectable.
When this is the wrong move
Group-reference wiring assumes everything lives in the same VPC or across peered VPCs in the same Region (cross-VPC group references work over peering, not over Transit Gateway). If traffic arrives through a NAT or a third-party firewall appliance, the source group is invisible and you are back to CIDRs — document why. And do not chase micro-segmentation past the point of usefulness: fifteen groups for a three-tier app adds audit surface, not security.
Common mistakes
- Opening the app port to
0.0.0.0/0“temporarily” to debug the ALB and never closing it — the ALB group reference was misconfigured, and the world became the workaround. - Allowing the database port from the whole VPC CIDR, which quietly includes bastions, CI runners, and anything an attacker lands on.
- Forgetting instance-to-instance rules for clustered apps, which surfaces as flaky node discovery rather than an obvious block.
- Confusing stateful groups with stateless NACLs and adding pointless ephemeral-port inbound rules to security groups.
Prevention
- A scheduled audit (AWS Config managed rules such as
restricted-ssh, or a weekly script) that flags any world-open rule outside the ALB group. - Alert on
AuthorizeSecurityGroupIngressCloudTrail events touching production groups. - Quarterly cleanup of unused groups — fewer groups, fewer places to hide a bad rule.
- A naming convention enforced in IaC review, so a group’s purpose is legible from its name.
A tight security group layout is one of the highest-value afternoons you can spend on an AWS account: three tiers, group references, no SSH from the internet, descriptions on everything. If nobody on your team truly owns the AWS environment end to end, that is the root of most of these problems — Stack Harbor runs AWS accounts daily under AWS Operations, and you can book an AWS review.