As the previous lesson closed, one uncomfortable sentence was left hanging: the network of
vpc-mercadofresco is well designed, but it filters nothing. The local route AWS puts in every
route table means any instance in the VPC can try to open a connection to port 5432 of
mercadofresco-pedidos, and the public subnet accepts whatever arrives through the internet gateway.
Addressing decides where a packet may go; what is missing is deciding whether it may pass.
AWS offers two firewalls for that, and they differ in almost everything: security groups, which protect the network interface of each resource, and network access control lists (NACLs), which protect the perimeter of each subnet. They do not compete: they complement each other, and a packet entering the VPC crosses both, in a specific order worth committing to memory.
In this lesson Marta configures MercadoFresco's complete filtering by applying the pattern that separates a well-built network from a network full of hand-copied IP ranges: referencing security groups across layers. By the end, the orders database will accept connections only from the shop's security group, and not even Marta from her own laptop will be able to reach it directly.
Contents
- The two filtering layers of a VPC
- Security groups: stateful and allow-only
- Anatomy of a rule: protocol, port and source
- The key pattern: referencing security groups across layers
- MercadoFresco's three layers with their security groups
- Security group limits and quotas
- NACLs: stateless, numbered and able to deny
- The ephemeral-port trap
- Comparison table: security groups versus NACLs
- The complete path of a packet
- Case study: opening port 22 to the office only (and why not to do it)
- Case study: blocking an abusive IP with a NACL
- Debugging: which error you see when each thing fails
- Confirming it with VPC Flow Logs
- Creation and auditing from the CLI
- A different layer altogether: identity policies
The two filtering layers of a VPC
First of all, the mental map. When a packet enters the VPC from the internet, it goes through this:
flowchart LR
NET["Internet"]
NACL{{"Subnet NACL<br/>(stateless, perimeter)"}}
SG{{"Security group<br/>(stateful, on the ENI)"}}
ENI["Instance<br/>(its ENI)"]
NET --> NACL --> SG --> ENI
- The NACL acts at the subnet boundary. A packet going from one instance to another within the same subnet never crosses it.
- The security group acts on the ENI, that is, right up against the resource. All traffic aimed at an instance crosses it, wherever it comes from.
The practical consequence is that 95 % of the daily work is done with security groups, and NACLs are kept for coarse subnet-wide rules, above all denials, which is the one thing security groups cannot do.
Security groups: stateful and allow-only
A security group is a virtual firewall attached to an ENI. It has three properties you need to understand properly, because they explain almost all of its behaviour:
1. It only accepts allow rules. There is no "deny" rule. Anything not explicitly permitted is denied by default. This simplifies the reasoning enormously —there is no rule order to think about— but it means that with a security group you cannot block a specific IP: that is what NACLs are for.
2. It is stateful. If you allow an inbound connection, the reply goes back out automatically, even if no outbound rule covers it. And the other way round: if an instance starts an outbound connection, the reply comes in even if there is no inbound rule. The security group remembers established connections.
This is what makes an instance with these rules work perfectly:
| Direction | Protocol | Port | Source/Destination |
|---|---|---|---|
| Inbound | TCP | 443 | 0.0.0.0/0 |
| Outbound | (no special rule, just the default one) |
A browser connects from ephemeral port 51234 to port 443 on the instance; the reply goes out from 443 to 51234 without needing a rule, because the security group knows that conversation was already open.
3. All rules are evaluated together, with no ordering. If an ENI has three security groups attached, the union of all their rules is evaluated. It is enough for one rule in any of them to allow the traffic. There are no priorities and no "first match wins".
By default, a newly created security group has:
- Inbound: no rules (nothing comes in).
- Outbound:
0.0.0.0/0on every protocol and port (everything goes out).
Anatomy of a rule: protocol, port and source
Every rule has four fields:
| Field | Values | Notes |
|---|---|---|
| Type/Protocol | TCP, UDP, ICMP, or -1 (all) |
The console offers shortcuts: "HTTPS", "PostgreSQL" |
| Port range | A single port (443) or a range (1024-65535) |
ICMP uses type and code rather than ports |
| Source (inbound) / Destination (outbound) | CIDR, another security group, or prefix list | The most important field |
| Description | Free text | Optional but decisive for maintenance |
The three possible source types:
| Source type | Example | When to use it |
|---|---|---|
| IPv4/IPv6 CIDR | 0.0.0.0/0, 81.45.20.7/32 |
Internet traffic or one specific external IP |
| Another security group | sg-mercadofresco-alb |
Whenever the source is another resource in the VPC |
| Prefix list | pl-6da54004 (S3), or one of your own |
Managed or reusable sets of ranges |
About the description: it is optional, but a rule with no description is a rule nobody will dare delete a year from now. Marta imposes the standard that every rule must carry one explaining why it exists, not what it does: "payment provider access", not "port 443 open".
One detail about /32: it is the mask that designates a single IP address. 81.45.20.7/32 means
exactly that IP and no other. It is the correct way of expressing "only this machine".
The key pattern: referencing security groups across layers
Here is the most important idea in the lesson. Compare two ways of writing the same intent, "the database only accepts connections from the shop's instances":
The naive way, with IP ranges:
The correct way, with a security group reference:
The differences are substantive, not stylistic:
| With IP ranges | With a security group reference | |
|---|---|---|
| Real scope | The whole subnet, including any future machine | Only the resources carrying that group |
| When adding an AZ or subnet | Every rule has to be edited | Nothing needs touching |
| When the ASG scales | It works, but by accident | It works by design |
| If an unrelated instance is launched in the subnet | It can reach the database | It cannot |
| Readability | "10.0.32.0/20" says nothing | "from the shop's group" reads by itself |
The case that makes it obvious: if Luis launches a test instance in snet-mercadofresco-app-a with a
different security group, with the naive approach that instance can connect to the production
database, because it sits inside the permitted range. With the group-to-group reference it cannot,
because it does not carry sg-mercadofresco-tienda.
How it works underneath: when a rule references sg-mercadofresco-tienda, AWS dynamically resolves
the set of private IPs of every ENI carrying that group, and keeps it up to date in real time.
When the ASG launches instance number 3 and number 4 on Friday at 17:00, they are authorised the
instant they receive their IP.
Two subtleties that catch people out:
- The reference resolves to private IPs. If the traffic arrived via the public IP, the rule would not match. Inside the VPC that does not happen, but it explains why you should always use internal DNS names.
- A group can reference itself (
sg-xallows inbound fromsg-x). That is the pattern for clusters whose nodes talk to each other, like the one ElastiCache will need in 06-05.
MercadoFresco's three layers with their security groups
flowchart TB
USER["Internet users"]
subgraph VPC["vpc-mercadofresco"]
subgraph PUB["Public subnets (10.0.0.0/20, 10.0.16.0/20)"]
ALB["Shop load balancer<br/><b>sg-mercadofresco-alb</b><br/>inbound 80 and 443 from 0.0.0.0/0"]
end
subgraph APP["Application subnets (10.0.32.0/20, 10.0.48.0/20)"]
EC2["ASG instances<br/><b>sg-mercadofresco-tienda</b><br/>inbound 443 from sg-mercadofresco-alb"]
end
subgraph DAT["Data subnets (10.0.64.0/20, 10.0.80.0/20)"]
RDS[("mercadofresco-pedidos<br/><b>sg-mercadofresco-basedatos</b><br/>inbound 5432 from sg-mercadofresco-tienda")]
EFS["efs-mercadofresco-fotos<br/><b>sg-efs-mercadofresco</b><br/>inbound 2049 from sg-mercadofresco-tienda"]
end
end
USER -->|"443"| ALB
ALB -->|"443"| EC2
EC2 -->|"5432"| RDS
EC2 -->|"2049 NFS"| EFS
The chain of references is the elegant part: nobody writes a single private IP range. Only the
load balancer's group, which is the front door, mentions 0.0.0.0/0.
The complete rules, group by group:
sg-mercadofresco-alb — the front door
| Dir. | Protocol | Port | Source/Destination | Description |
|---|---|---|---|---|
| Inbound | TCP | 443 | 0.0.0.0/0 |
Public HTTPS traffic for the shop |
| Inbound | TCP | 80 | 0.0.0.0/0 |
Only to redirect to HTTPS (see 03-03) |
| Outbound | TCP | 443 | sg-mercadofresco-tienda |
Forwarding to the ASG instances |
sg-mercadofresco-tienda — the application layer
| Dir. | Protocol | Port | Source/Destination | Description |
|---|---|---|---|---|
| Inbound | TCP | 443 | sg-mercadofresco-alb |
Only from the load balancer |
| Outbound | TCP | 5432 | sg-mercadofresco-basedatos |
Queries to the orders database |
| Outbound | TCP | 2049 | sg-efs-mercadofresco |
NFS mount of the file system |
| Outbound | TCP | 443 | 0.0.0.0/0 |
AWS API, payment gateway, updates |
Notice that there is no inbound rule for port 22. No office IP, nothing at all. We come back to that in the case study.
sg-mercadofresco-basedatos — the data layer
| Dir. | Protocol | Port | Source/Destination | Description |
|---|---|---|---|---|
| Inbound | TCP | 5432 | sg-mercadofresco-tienda |
Queries from the application |
| Inbound | TCP | 5432 | sg-lambda-mercadofresco |
Function mercadofresco-estado-pedido |
| Outbound | (none) | A database does not need to go out |
That "no outbound" is deliberate: the default 0.0.0.0/0 outbound rule has to be explicitly
deleted. Since the group is stateful, replies to queries still go out anyway.
sg-efs-mercadofresco — the shared file system
| Dir. | Protocol | Port | Source/Destination | Description |
|---|---|---|---|---|
| Inbound | TCP | 2049 | sg-mercadofresco-tienda |
NFS from the shop instances |
Security group limits and quotas
Numbers worth keeping in mind, because they are reached sooner than you would think:
| Limit | Default value | Adjustable |
|---|---|---|
| Security groups per VPC | 2,500 | Yes |
| Inbound rules per group | 60 | Yes, up to 1,000 |
| Outbound rules per group | 60 | Yes |
| Security groups per ENI | 5 | Yes, up to 16 |
| Rules × groups per ENI | 1,000 | Yes |
The real constraint is the last one: rules per group × groups per ENI cannot exceed 1,000. And an
accounting detail: a rule with a port range counts as one, but a rule with several CIDRs counts
as one per CIDR. A group with "443 from these 40 IPs" consumes 40 rules. That is exactly the
scenario customer-managed prefix lists exist for: you define a list with the 40 IPs and reference
it with a single rule.
NACLs: stateless, numbered and able to deny
A NACL (Network Access Control List) is a firewall at the subnet level. Each subnet is associated with exactly one NACL; if you do not associate one, it uses the VPC's default NACL, which allows absolutely everything in both directions.
Its properties are almost the opposite of a security group's:
1. It allows and denies. Every rule is either allow or deny. This is the one thing that makes
NACLs indispensable.
2. It is stateless. It remembers nothing. If a request comes in through an inbound rule, the reply needs its own outbound rule. This is the source of 90 % of the problems people have with NACLs.
3. Rules are evaluated in numerical order, and the first match wins. Rules are numbered from 1 to 32,766. AWS walks them from lowest to highest and stops at the first one that matches, ignoring everything after it.
4. There is a * rule. At the end of every NACL there is a non-editable rule, numbered *, that
denies anything which has not matched earlier. It is the final safety net.
The default NACL of a new VPC:
| Rule | Type | Protocol | Port | Source | Action |
|---|---|---|---|---|---|
| 100 | All | All | All | 0.0.0.0/0 |
ALLOW |
* |
All | All | All | 0.0.0.0/0 |
DENY |
(and the same on outbound). In other words: by default a NACL filters nothing. That is intentional, and it is why many perfectly correct architectures leave NACLs as they are and trust all filtering to security groups.
Numbering tip: use steps of 100 (100, 200, 300…). When you need to insert a rule between two, you will have room. Renumbering a NACL in production is an unpleasant operation.
The ephemeral-port trap
This deserves its own section, because it is where everybody comes unstuck.
When a browser connects to the shop, it opens the connection from a high random port —an ephemeral port— to port 443 on the server. The server replies from 443 to that ephemeral port. Since the NACL is stateless, that reply is evaluated against the outbound rules, and its destination port is not 443: it is 51234, or 62890, or whatever it happens to be.
That is why a restrictive NACL always needs an outbound rule opening the ephemeral port range. And the range depends on the client:
| Client system | Ephemeral port range |
|---|---|
| Modern Linux | 32768–60999 |
| Windows (since Vista/2008) | 49152–65535 |
| AWS load balancers (ELB/NLB) | 1024–65535 |
| Lambda, containers | 1024–65535 |
AWS's practical recommendation: open 1024–65535 outbound, because you do not know which client you will get. Yes, it is an enormous range, and it is why NACLs are a blunt instrument: filtering return traffic finely is practically impossible.
A working NACL for a public subnet looks like this:
| Rule | Dir. | Protocol | Port | Source/Destination | Action | Why |
|---|---|---|---|---|---|---|
| 100 | Inbound | TCP | 443 | 0.0.0.0/0 |
ALLOW | HTTPS requests |
| 110 | Inbound | TCP | 80 | 0.0.0.0/0 |
ALLOW | HTTP requests to be redirected |
| 120 | Inbound | TCP | 1024-65535 | 0.0.0.0/0 |
ALLOW | Replies to outbound connections |
* |
Inbound | All | All | 0.0.0.0/0 |
DENY | Implicit rule |
| 100 | Outbound | TCP | 443 | 0.0.0.0/0 |
ALLOW | Outbound HTTPS connections |
| 110 | Outbound | TCP | 1024-65535 | 0.0.0.0/0 |
ALLOW | Replies to inbound requests |
* |
Outbound | All | All | 0.0.0.0/0 |
DENY | Implicit rule |
Four of the six rules exist purely because of the lack of state. Compare that with the equivalent security group, which needed one.
Comparison table: security groups versus NACLs
| Criterion | Security group | NACL |
|---|---|---|
| Level | ENI (instance, RDS, ALB, endpoint…) | The whole subnet |
| State | Stateful: the reply is allowed by itself | Stateless: the reply needs its own rule |
| Rule types | Allow only | Allow and deny |
| Evaluation | All rules at once, with no ordering | By number, first match wins |
| How many apply | Several per ENI (up to 5), and they add up | One per subnet |
| Default | Nothing in, everything out | Everything in and everything out |
| Source by security group | Yes, the key feature | No, CIDR only |
| Traffic within the same subnet | It does filter it | It never sees it |
| When to use it | Always; it is the main tool | Coarse subnet blocks and denials |
| Typical use case | "The database only talks to the shop" | "This IP does not enter the public subnet" |
A practical rule that sums up the division of labour: security groups define the permitted architecture; NACLs block what has to be blocked.
The complete path of a packet
This is the diagram to memorise. It follows an HTTPS request from a client to the instance and its reply on the way back:
sequenceDiagram
participant C as Client<br/>(203.0.113.50:51234)
participant NE as Inbound NACL<br/>app subnet
participant SE as Inbound SG<br/>sg-mercadofresco-tienda
participant I as Instance<br/>(10.0.32.15:443)
participant SS as Outbound SG<br/>(stateful)
participant NS as Outbound NACL<br/>app subnet
C->>NE: Request → destination port 443
Note over NE: Rule 100: ALLOW TCP 443 ✔
NE->>SE: Request
Note over SE: Inbound 443 from sg-alb ✔
SE->>I: Reaches the application
I->>SS: Reply → destination port 51234
Note over SS: Connection already established:<br/>NO rule is evaluated ✔
SS->>NS: Reply
Note over NS: Rule 110: ALLOW TCP 1024-65535<br/>WITHOUT this rule the reply DIES HERE ✘
NS->>C: Reply delivered
Four checkpoints in total, and the asymmetric one is the third: the security group evaluates nothing on the way out because it remembers the connection, whereas the NACL examines it again as if it were new traffic. The symptom when rule 110 is missing is particularly cruel: the connection is established, the request arrives, the application processes it correctly… and the client sits there waiting until the timeout expires.
Case study: opening port 22 to the office only (and why not to do it)
Luis asks for SSH access to the instances so he can debug. The "correct by the book" answer would be to open port 22 only to the office's fixed IP:
aws ec2 authorize-security-group-ingress \
--profile mercadofresco-dev --region eu-west-1 \
--group-id "$SG_TIENDA" \
--ip-permissions 'IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges=[{CidrIp=81.45.20.7/32,Description="SSH from the Barcelona office"}]'Never 0.0.0.0/0. A port 22 open to the internet receives automated access attempts within
minutes.
But for MercadoFresco not even this is the right solution, for four reasons:
- The office IP changes. The day the provider renews it, nobody gets in, and somebody will "fix"
the problem by putting
0.0.0.0/0in at eleven at night. - It does not work for remote working. Marta at home has a different IP.
- There is no record of who came in. SSH with a shared key does not identify the person.
- ASG instances are ephemeral. SSHing into a machine that will be destroyed in two hours to "fix it" by hand is exactly what the ASG is trying to avoid.
The alternative, already used in 02-01, is Session Manager: the SSM agent on the instance opens an outbound connection to the AWS service, and the session travels down that tunnel.
| SSH with port 22 open | Session Manager | |
|---|---|---|
| Inbound ports required | 22 from some source | None |
| Needs a public IP or bastion | Yes | No |
| Key management | .pem files that get shared around |
None: it uses AWS credentials |
| Who came in and what they did | No reliable record | Recorded in CloudTrail and CloudWatch Logs |
| Permission control | All or nothing | Per-person IAM policies (04-01) |
| Works in a private subnet | Only with a bastion | Yes, with NAT or with interface endpoints |
That is why sg-mercadofresco-tienda has no inbound rule for port 22, and that absence is a
design decision, not an oversight.
Case study: blocking an abusive IP with a NACL
One Tuesday, Sara spots in the logs that the IP 198.51.100.77 is walking the entire catalogue at a
rate of 40 requests per second. It is not a distributed attack, it is a single IP scraping prices.
Security groups are no help: they cannot deny.
# Find the NACL associated with the public subnets
NACL_PUB=$(aws ec2 describe-network-acls \
--profile mercadofresco-dev --region eu-west-1 \
--filters "Name=association.subnet-id,Values=$PUB_A" \
--query 'NetworkAcls[0].NetworkAclId' --output text)
# Rule 50: a LOW number so it is evaluated BEFORE the general ALLOW at 100
aws ec2 create-network-acl-entry \
--profile mercadofresco-dev --region eu-west-1 \
--network-acl-id "$NACL_PUB" \
--rule-number 50 \
--protocol -1 \
--rule-action deny \
--ingress \
--cidr-block 198.51.100.77/32The number 50 is the whole lesson: had we used 150, rule 100 (the general ALLOW) would be
evaluated first, it would match, and 150 would never be read. First match wins.
To remove it once the abuse stops:
aws ec2 delete-network-acl-entry --profile mercadofresco-dev --region eu-west-1 \
--network-acl-id "$NACL_PUB" --rule-number 50 --ingressTwo honest warnings:
- Blocking IPs by hand does not scale. If tomorrow there are 300 IPs, this is unworkable and you will need the right tool: AWS WAF, covered in 04-05, with its rate-limit rules. The NACL is the emergency answer, not the solution.
- The NACL blocks traffic as it enters the subnet. If the traffic arrives through CloudFront (lesson 03-04) or a load balancer, the IP the NACL sees is the intermediary's, not the attacker's, and blocking it would shut everybody out.
Debugging: which error you see when each thing fails
The symptoms are different, and knowing how to read them saves hours:
| Symptom | Most likely cause | How to confirm it |
|---|---|---|
| Timeout (connection timed out, the request just hangs) | Security group or NACL: the packet is dropped silently | Flow Logs with REJECT, or no entry at all |
| Connection refused (connection refused, an immediate reply) | The network works: nothing is listening on that port | ss -lntp on the instance; the service is down |
| Connects and then hangs halfway | NACL with no outbound ephemeral-port rule | Flow Logs: inbound ACCEPT and outbound REJECT |
| It resolves to a public IP from inside | enableDnsHostnames or PubliclyAccessible |
dig from the instance (seen in 03-01) |
| Works from one instance and not from another | The second one does not carry the referenced security group | describe-instances and compare SecurityGroups |
Access denied (AccessDenied) from the AWS API |
It is not the network: it is an IAM policy | CloudTrail; covered in 04-01 |
The distinction between the first two rows is the most useful thing in the whole table: a firewall
drops the packet silently and the client waits; a service that is down replies immediately with a
refusal. If telnet mercadofresco-pedidos... 5432 returns "connection refused" instantly, the
problem is not in the security group.
Confirming it with VPC Flow Logs
The Flow Logs we switched on in 03-01 are the only way to be certain. Recalling the format:
2 111122223333 eni-0a1b2c3d 10.0.32.15 10.0.64.30 44321 5432 6 12 3800 1738500000 1738500060 REJECT OK
Here 10.0.32.15 (a shop instance) tried to reach 10.0.64.30 (the database) on port 5432, and the
result was REJECT. That confirms the block is happening at the network layer.
How to tell which of the two layers did the blocking:
| What you see in the Flow Logs | Interpretation |
|---|---|
Only an inbound REJECT, no reply |
The destination's security group: it dropped the request |
Inbound ACCEPT and outbound REJECT on the reply |
NACL: the ephemeral-port rule is missing |
| No entry at all | The packet never arrived: routing (03-01) or DNS problem |
ACCEPT in both directions but the application fails |
The network is fine: look at the application |
That third row is especially valuable: the absence of records is information. Analysing this data with CloudWatch Logs Insights and its queries is covered in 05-01.
Creation and auditing from the CLI
Creating the groups with cross-references
There is a chicken-and-egg problem: sg-mercadofresco-tienda references sg-mercadofresco-alb and
vice versa. The solution is to create the empty groups first and the rules afterwards.
create_sg() {
local name=$1 desc=$2 component=$3
aws ec2 create-security-group \
--profile mercadofresco-dev --region eu-west-1 \
--group-name "$name" --description "$desc" --vpc-id "$VPC_ID" \
--tag-specifications "ResourceType=security-group,Tags=[
{Key=Name,Value=$name},{Key=Proyecto,Value=mercadofresco},
{Key=Entorno,Value=produccion},{Key=Componente,Value=$component},
{Key=Propietario,Value=marta},{Key=CentroCoste,Value=operaciones}]" \
--query 'GroupId' --output text
}
SG_ALB=$(create_sg sg-mercadofresco-alb "Public load balancer of the shop" tienda)
SG_TIENDA=$(create_sg sg-mercadofresco-tienda "EC2 instances of the shop" tienda)
SG_BD=$(create_sg sg-mercadofresco-basedatos "PostgreSQL RDS for orders" pedidos)
SG_EFS=$(create_sg sg-efs-mercadofresco "File system for photos" catalogo)Now the rules. --ip-permissions in its long form lets you include the description, something the
short form --port / --cidr does not support:
# ALB: inbound from the internet
aws ec2 authorize-security-group-ingress --profile mercadofresco-dev --region eu-west-1 \
--group-id "$SG_ALB" --ip-permissions \
'IpProtocol=tcp,FromPort=443,ToPort=443,IpRanges=[{CidrIp=0.0.0.0/0,Description="Public HTTPS of the shop"}]' \
'IpProtocol=tcp,FromPort=80,ToPort=80,IpRanges=[{CidrIp=0.0.0.0/0,Description="HTTP to redirect to HTTPS"}]'
# Shop: inbound ONLY from the load balancer's group (UserIdGroupPairs, not IpRanges)
aws ec2 authorize-security-group-ingress --profile mercadofresco-dev --region eu-west-1 \
--group-id "$SG_TIENDA" --ip-permissions \
"IpProtocol=tcp,FromPort=443,ToPort=443,UserIdGroupPairs=[{GroupId=$SG_ALB,Description=\"Load balancer traffic\"}]"
# Database: inbound ONLY from the shop's group
aws ec2 authorize-security-group-ingress --profile mercadofresco-dev --region eu-west-1 \
--group-id "$SG_BD" --ip-permissions \
"IpProtocol=tcp,FromPort=5432,ToPort=5432,UserIdGroupPairs=[{GroupId=$SG_TIENDA,Description=\"Queries from the shop\"}]"
# EFS: NFS from the shop
aws ec2 authorize-security-group-ingress --profile mercadofresco-dev --region eu-west-1 \
--group-id "$SG_EFS" --ip-permissions \
"IpProtocol=tcp,FromPort=2049,ToPort=2049,UserIdGroupPairs=[{GroupId=$SG_TIENDA,Description=\"NFS mount from the shop\"}]"
# Remove the database's blanket outbound rule: it does not need to initiate anything
aws ec2 revoke-security-group-egress --profile mercadofresco-dev --region eu-west-1 \
--group-id "$SG_BD" --ip-permissions 'IpProtocol=-1,IpRanges=[{CidrIp=0.0.0.0/0}]'UserIdGroupPairs is the key to the pattern: it is the field that expresses "from this other security
group" instead of an IP range.
Auditing: finding dangerous rules
This is the command Marta runs every Monday. It looks for any security group in the account with an administration or database port open to the whole internet:
aws ec2 describe-security-groups --profile mercadofresco-dev --region eu-west-1 \
--query 'SecurityGroups[?IpPermissions[?
(FromPort==`22` || FromPort==`3389` || FromPort==`3306` || FromPort==`5432` || FromPort==`6379`)
&& IpRanges[?CidrIp==`0.0.0.0/0`]]].{
Group:GroupName, Id:GroupId, VPC:VpcId,
Ports:IpPermissions[?IpRanges[?CidrIp==`0.0.0.0/0`]].FromPort}' \
--output tableA breakdown of the JMESPath expression, which is dense but worth understanding:
SecurityGroups[? ... ]filters the list of groups.IpPermissions[? ... ]looks inside each group's inbound rules.- The condition combines two things with
&&: that the port is one of the sensitive ones (22 SSH, 3389 RDP, 3306 MySQL, 5432 PostgreSQL, 6379 Redis) and that its ranges include a0.0.0.0/0. - Backticks (
`) mark numeric and string literals in JMESPath, as we saw in 01-05.
If this returns anything, there is a problem to fix today. And a useful companion, looking for orphan groups nobody uses:
# Groups that are not attached to any network interface
comm -23 \
<(aws ec2 describe-security-groups --profile mercadofresco-dev --region eu-west-1 \
--query 'SecurityGroups[].GroupId' --output text | tr '\t' '\n' | sort) \
<(aws ec2 describe-network-interfaces --profile mercadofresco-dev --region eu-west-1 \
--query 'NetworkInterfaces[].Groups[].GroupId' --output text | tr '\t' '\n' | sort -u)And the same check from Python, handy for wiring into a pipeline (module 8):
import boto3
ec2 = boto3.client("ec2", region_name="eu-west-1")
SENSITIVE_PORTS = {22, 3389, 3306, 5432, 6379, 27017}
findings = []
paginator = ec2.get_paginator("describe_security_groups")
for page in paginator.paginate():
for group in page["SecurityGroups"]:
for rule in group.get("IpPermissions", []):
# A rule with IpProtocol == "-1" has no FromPort: it means ALL ports
first = rule.get("FromPort", 0)
last = rule.get("ToPort", 65535)
open_to_internet = any(
r["CidrIp"] == "0.0.0.0/0" for r in rule.get("IpRanges", [])
)
if not open_to_internet:
continue
affected = {p for p in SENSITIVE_PORTS if first <= p <= last}
if affected:
findings.append({
"group": group["GroupName"],
"id": group["GroupId"],
"ports": sorted(affected),
})
for f in findings:
print(f"DANGER {f['group']:35} {f['id']:22} ports {f['ports']}")
print(f"\n{len(findings)} dangerous rules found")An important detail in the code: it checks the whole range (first <= p <= last), not just the
starting port. A rule saying "TCP 1-65535 from 0.0.0.0/0" does not have FromPort == 22, but it does
include 22 and is every bit as dangerous. The simpler CLI command above would miss it.
A different layer altogether: identity policies
It is worth closing by clearing up a frequent confusion. Security groups and NACLs control network packets: who may open a TCP connection to which port. They have no opinion whatsoever about who is making the request or what action it is asking for.
When the shop instance requests an object from mercadofresco-catalogo-fotos, there are two
independent questions:
- Is there a path, and is the packet allowed? → routes (03-01), security group, NACL, VPC endpoint.
- Does that identity have permission to perform
s3:GetObjecton that object? → IAM policies, therol-mercadofresco-tiendarole, and bucket policies. That is the subject of lesson 04-01, and anAccessDeniedfrom there is never fixed by touching a security group.
Telling these two layers apart saves entire debugging sessions spent in the wrong direction. And protection against application-level attacks —injections, bots, denial of service— is a third, different layer again: Shield (04-04) and WAF (04-05).
Common Mistakes and Tips
Using IP ranges where there should be group references. It works, which is why it spreads. But it
opens the door to any future resource in those subnets and forces you to edit rules every time the
network changes. If the source is a resource in the VPC, the answer is always UserIdGroupPairs.
Opening port 22 or 3389 to 0.0.0.0/0 "just for a moment, to test". Automated scanners find the
port within minutes. If you really must, use a /32 for a specific IP; better still, Session
Manager.
Forgetting ephemeral ports in the outbound NACL. The symptom —the connection is established and then hangs— does not point at the NACL at all. If you touch a NACL, open 1024-65535 outbound.
Numbering NACL rules badly. A DENY with a higher number than an ALLOW that already matches is
a dead rule. Denials go with low numbers. And number in steps of 100 so you can insert.
Believing a NACL filters traffic between two instances in the same subnet. It does not: the NACL only acts at the subnet boundary. Only security groups see that traffic.
Leaving the 0.0.0.0/0 outbound rule on the database group. It is not an immediate risk, but if
the database is compromised, that rule is its route for exfiltrating data. Remove it: the security
group's state means replies still go out.
Confusing "timeout" with "connection refused". The first is a firewall, the second is a service that is down. Diagnosing in the wrong direction costs hours.
Not putting a description on rules. A rule with no explanation becomes permanent out of fear. Insist on a description on every rule and review the list every quarter.
The golden tip: draw the diagram of the layers with the security group arrows before writing a single rule. If the arrow is not in the drawing, the rule should not exist.
Exercises
Exercise 1: designing the security groups for an internal admin panel
MercadoFresco needs an internal administration panel on its own instances, which Sara will use from
the office. The panel must: receive traffic from the load balancer on port 443, query the
mercadofresco-pedidos-lectura read replica on 5432, and write reports to
mercadofresco-informes-analitica. It must not be able to reach the primary database.
Design the sg-mercadofresco-panel group and the changes needed in the existing groups, respecting
the referencing pattern.
Exercise 2: fixing a broken NACL
A colleague has configured this NACL on the application subnet and the shop has stopped responding:
| Rule | Dir. | Protocol | Port | Source | Action |
|---|---|---|---|---|---|
| 100 | Inbound | TCP | 443 | 10.0.0.0/16 |
ALLOW |
* |
Inbound | All | All | 0.0.0.0/0 |
DENY |
| 100 | Outbound | TCP | 5432 | 10.0.64.0/20 |
ALLOW |
| 200 | Outbound | TCP | 443 | 0.0.0.0/0 |
ALLOW |
* |
Outbound | All | All | 0.0.0.0/0 |
DENY |
Identify all the problems, explain the exact symptom of each and write the corrected NACL.
Exercise 3: auditing and fixing an inherited security group
Write a shell script that, for a given security group, prints a readable report of all its inbound
rules distinguishing the source type, flags the dangerous ones, and generates the
revoke-security-group-ingress commands needed to remove them without running them.
Solutions
Solution 1
New group sg-mercadofresco-panel:
| Dir. | Protocol | Port | Source/Destination | Description |
|---|---|---|---|---|
| Inbound | TCP | 443 | sg-mercadofresco-alb |
Panel served through the load balancer |
| Outbound | TCP | 5432 | sg-mercadofresco-lectura |
Queries to the read replica |
| Outbound | TCP | 443 | 0.0.0.0/0 |
S3 and the AWS API |
Changes to the existing groups: a new group for the replica is needed,
sg-mercadofresco-lectura, separate from sg-mercadofresco-basedatos. That is the core of the
exercise: if the replica shared a group with the primary, authorising the panel on the replica would
also authorise it on the primary, breaking the requirement.
sg-mercadofresco-lectura |
Protocol | Port | Source |
|---|---|---|---|
| Inbound | TCP | 5432 | sg-mercadofresco-panel |
| Inbound | TCP | 5432 | sg-mercadofresco-tienda |
And sg-mercadofresco-basedatos is left untouched: it still accepts only from
sg-mercadofresco-tienda and the Lambda, so the panel cannot reach the primary.
On Sara's access from the office: no port is opened for her. She comes in through the same load
balancer as the customers, and the office restriction is done by application authentication, or with
WAF filtering by IP on /admin (04-05). Adding a rule with the office IP to the panel's group would
be going back to the changing-IP problem.
On access to mercadofresco-informes-analitica: no new rule is required beyond the outbound 443,
and if the S3 gateway endpoint exists (03-01), it does not even go through the NAT. Permission to
write to the bucket is a matter for the IAM role (04-01), not the firewall.
Solution 2
There are three problems:
Problem 1 — the ephemeral port range is missing on outbound. HTTPS requests come in through rule
100 and reach the application, but the reply is addressed to the client's ephemeral port
(51234, for example). On outbound, rule 100 only covers 5432 and rule 200 only 443, so the reply
falls through to the * DENY. Symptom: the connection is established, the server processes the
request and the client hangs until the timeout expires. In the Flow Logs: inbound ACCEPT
and outbound REJECT.
Problem 2 — the ephemeral port range is missing on inbound. When the instance queries RDS (going out on 5432, allowed by outbound rule 100), RDS's reply arrives at an ephemeral port on the instance. On inbound only 443 is permitted, so the reply is denied. Symptom: database queries hang and end in a timeout. The same happens with outbound HTTPS calls to S3 or to the payment gateway.
Problem 3 — the inbound source is too restrictive in one sense and not precise enough in the
other. 10.0.0.0/16 covers the entire VPC, which is fine if the traffic only comes from the load
balancer, but it also lets any subnet in the VPC in on 443, something the NACL should not be
deciding. It breaks nothing, but it is a badly framed rule: fine-grained source filtering is the
security group's job.
Corrected NACL:
| Rule | Dir. | Protocol | Port | Source/Destination | Action | Why |
|---|---|---|---|---|---|---|
| 100 | Inbound | TCP | 443 | 10.0.0.0/16 |
ALLOW | Requests from the load balancer |
| 200 | Inbound | TCP | 1024-65535 | 0.0.0.0/0 |
ALLOW | Replies to outbound queries |
* |
Inbound | All | All | 0.0.0.0/0 |
DENY | Implicit |
| 100 | Outbound | TCP | 5432 | 10.0.64.0/20 |
ALLOW | Queries to RDS |
| 200 | Outbound | TCP | 443 | 0.0.0.0/0 |
ALLOW | S3, AWS API, payments |
| 300 | Outbound | TCP | 1024-65535 | 0.0.0.0/0 |
ALLOW | Replies to the load balancer |
* |
Outbound | All | All | 0.0.0.0/0 |
DENY | Implicit |
The moral of the exercise: if the fine-grained filtering is already being done by security groups,
this NACL could perfectly well have been left on the default blanket ALLOW without losing any real
security, and the incident would have been avoided.
Solution 3
#!/usr/bin/env bash
# audit-sg.sh — report of the inbound rules of a security group
set -euo pipefail
GROUP="${1:?Usage: $0 <sg-id>}"
PROFILE="mercadofresco-dev"
REGION="eu-west-1"
DANGEROUS="22 3389 3306 5432 6379 27017"
echo "=== Inbound rules of $GROUP ==="
RULES=$(aws ec2 describe-security-groups --profile "$PROFILE" --region "$REGION" \
--group-ids "$GROUP" --query 'SecurityGroups[0].IpPermissions' --output json)
# We walk the rules with jq, emitting one line per rule-source combination
echo "$RULES" | jq -r '
.[] |
. as $r |
(($r.IpRanges // [])[] | "CIDR|\($r.IpProtocol)|\($r.FromPort // 0)|\($r.ToPort // 65535)|\(.CidrIp)|\(.Description // "-")"),
(($r.UserIdGroupPairs // [])[] | "SG |\($r.IpProtocol)|\($r.FromPort // 0)|\($r.ToPort // 65535)|\(.GroupId)|\(.Description // "-")"),
(($r.PrefixListIds // [])[] | "PL |\($r.IpProtocol)|\($r.FromPort // 0)|\($r.ToPort // 65535)|\(.PrefixListId)|\(.Description // "-")")
' | while IFS='|' read -r kind proto first last source desc; do
mark=" "
if [[ "$source" == "0.0.0.0/0" ]]; then
for p in $DANGEROUS; do
if (( first <= p && p <= last )); then mark="!!"; break; fi
done
fi
printf "%s %-4s %-5s %6s-%-6s %-22s %s\n" \
"$mark" "$kind" "$proto" "$first" "$last" "$source" "$desc"
if [[ "$mark" == "!!" ]]; then
echo " → aws ec2 revoke-security-group-ingress --profile $PROFILE --region $REGION \\"
echo " --group-id $GROUP --protocol $proto --port $first-$last --cidr $source"
fi
done
echo
echo "Lines marked with !! expose a sensitive port to the whole internet."
echo "Review the suggested commands before running them."Design points of the script: it distinguishes the three source types (CIDR, SG, PL) because a
reference to another group is never dangerous in itself; it checks the full port range rather
than just the starting port; it uses // 0 and // 65535 in jq for the IpProtocol: "-1" case,
where FromPort and ToPort do not exist; and it runs nothing, it only prints the commands,
because revoking rules blindly in production is worse than the vulnerability.
Conclusion
MercadoFresco now has firewalls. You know that security groups act on the ENI, are stateful
—the reply to an allowed connection goes out on its own, with no rule—, allow only, and are
evaluated together with no ordering, and that by default they let nothing in and everything out. You
know the anatomy of a rule and, above all, you have mastered the pattern that defines a
well-built network: referencing security groups from other security groups with UserIdGroupPairs
instead of writing IP ranges. Thanks to that, sg-mercadofresco-basedatos accepts 5432 only from
sg-mercadofresco-tienda, and the instances the ASG launches on Friday at 17:00 are authorised the
instant they start, with nothing to edit.
You know that NACLs are the opposite in almost everything: they act at the subnet boundary, they
are stateless, they are evaluated by number and stop at the first match, they always end in the
* deny rule, and they are the only one of the two layers able to deny. You have understood why
that forces them to open the ephemeral port range 1024-65535 in the return direction, and why the
symptom of forgetting it —the connection is established and then hangs— is so hard to attribute. You
have followed the complete path of a packet through the four checkpoints and you know which one of
them is asymmetric and why.
In practice you have decided not to open port 22 even to the office IP, with four concrete
reasons and Session Manager as the alternative; you have blocked an abusive IP with a DENY rule
numbered 50, understanding that the low number is what makes it effective, and knowing that this
is an emergency answer and not a solution that scales —WAF, in 04-05, is what that is for. You can
tell a timeout (firewall) from a connection refused (service down), and confirm which of the
two layers blocked a packet by reading the ACCEPT/REJECT in the VPC Flow Logs. And you have
automated the weekly audit that looks for sensitive ports open to 0.0.0.0/0, both in JMESPath and
in boto3 checking the full port range. Finally, you are clear that identity policies are a separate
layer: an IAM AccessDenied is never fixed by touching a security group, and that is the subject
of module 4.
With the network designed and filtered, we can now put in the piece MercadoFresco has been waiting
for since the first lesson. The Auto Scaling group asg-mercadofresco-tienda knows how to launch
four instances on Friday afternoon, but nothing distributes traffic between them: today they
still hang off a single IP. In lesson 03-03, "Elastic Load Balancing", we will build the
application load balancer in the public subnets with the sg-mercadofresco-alb group we have just
created, connect it to the ASG so instances are registered and deregistered automatically, configure
health checks that do not take the application down instead of saving it, and finish solving
problem 1: the Friday outages.
AWS Course
Module 1: Introduction to AWS
- What Is AWS?
- Setting Up Your AWS Account
- AWS Global Infrastructure
- The AWS Management Console
- AWS CLI and SDKs
Module 2: Core AWS Services
Module 3: Networking and Content Delivery
Module 4: Security and Identity
- AWS Identity and Access Management (IAM)
- AWS Key Management Service (KMS)
- Secrets Manager and Parameter Store
- AWS Shield
- AWS WAF
Module 5: Monitoring and Management
Module 6: Databases
Module 7: Application Integration
- Amazon SQS
- Amazon SNS
- Amazon EventBridge
- AWS Step Functions
- Integration Patterns: Idempotency, Retries and Dead-Letter Queues
