Harden Your VPC with NACLs
Learn to harden your AWS VPC using NACLs and security groups. This hands-on tutorial shows you how to layer stateless and stateful filters to control traffic, with practical examples and troubleshooting tips.
Focus: harden your vpc with nacls and security groups
You've built a VPC, launched EC2 instances, and maybe even deployed a Python web app — but how confident are you that your network is actually locked down? Many developers rely solely on security groups and assume their VPC is safe, only to discover later that a misconfigured network ACL (NACL) left a database exposed to the internet. In this lesson, you'll learn how to harden your VPC with NACLs and security groups — two complementary layers of defense that, when used together, give you granular control over inbound and outbound traffic. By the end, you'll be able to design a defense-in-depth strategy that keeps your AWS resources secure.
The problem this lesson solves
Imagine you're running a Python Flask application on an EC2 instance, and your PostgreSQL database is on another instance in the same VPC. You've attached a security group to allow traffic on port 5432 from your app server. All seems fine — until a security audit reveals that your database is reachable from the entire internet. How? Because you forgot to also update the network ACL, which by default allows all traffic.
Security groups are stateful — they automatically allow return traffic — but they are not the only filter at the network edge. Network ACLs (NACLs) are stateless, meaning you must explicitly allow both inbound and outbound traffic. If you only configure one, you leave gaps. Worse, many developers don't realize that NACLs apply at the subnet level, so every instance in a subnet shares the same rules. A single misconfigured NACL can expose hundreds of resources.
The pain is real: - Data breaches from overly permissive rules - Application downtime caused by blocked traffic - Compliance failures (e.g., PCI, HIPAA) due to lack of network segmentation
This lesson solves that by teaching you a clear, repeatable process to harden your VPC using both security groups and NACLs, so you can confidently control traffic at every layer.
Core concept / mental model
Think of your VPC as a castle with two walls: - Security groups are the guards at each door (instance level). They check IDs and decide who enters and exits. They are stateful — if a guest walks in, they automatically can walk out. - NACLs are the outer city wall that surrounds entire neighborhoods (subnets). They are stateless — every packet must have a return ticket, and you must explicitly allow both directions.
In AWS, traffic flows like this: 1. A packet enters the VPC from the internet. 2. It first hits the NACL associated with the subnet — the outer wall. 3. If the NACL allows it, the packet reaches the instance. 4. Then the security group associated with the instance's ENI filters it — the inner guard.
This is a classic defense-in-depth model: even if one layer fails, the other still protects you.
Key definitions: - Stateful firewall: Automatically allows return traffic from an allowed request (e.g., if you allow inbound on port 80, outbound responses are allowed without an explicit rule). - Stateless firewall: Does NOT track connection state; you must allow both inbound and outbound traffic separately. - Default NACL: Allows all inbound and outbound traffic — dangerous out of the box. - Custom NACL: Starts as deny-all; you explicitly allow what you need.
Why both matter: Security groups are great for instance-level control, but they don't filter between subnets. NACLs excel at subnet-level segmentation — for example, allowing only your web subnet to talk to your database subnet on port 3306. Combined, they give you overlapping layers of security.
How it works step by step
The process of hardening your VPC involves designing, implementing, and verifying two layers of filters. Here's a logical flow:
Step 1: Map your traffic flows
Before touching the console or code, write down: - What services are you running? (web app, database, cache, etc.) - Who needs to access what? (e.g., users → web server, web server → database, admin → SSH) - What ports/protocols are used? (80/443 for HTTP/S, 22 for SSH, 3306/5432 for DBs)
Step 2: Design security group rules
Create one security group per role (e.g., web-sg, db-sg). Use references to other security groups to allow traffic between specific resources, not IP ranges.
Step 3: Design NACL rules
Assign a custom NACL to each subnet. Start with a deny-all baseline, then add explicit allow rules for required traffic. Remember: rules are evaluated in number order, lowest number first.
Step 4: Implement with code (Python + boto3)
Use Python with boto3 to automate the creation and updating of NACLs and security groups. This makes your configuration repeatable and version-controlled.
Step 5: Test and verify
Use tools like nc (netcat) or telnet to test connectivity. Check VPC Flow Logs to see allowed/denied traffic.
Step 6: Monitor and audit
Regularly review rules, remove unused ones, and use AWS Trusted Advisor to find over-permissive rules.
Hands-on walkthrough
Let's get our hands dirty. We'll use Python with boto3 to create a hardened setup: a web instance and a database instance, with security groups and NACLs that enforce a least-privilege model.
Prerequisites
- AWS account with credentials configured (
aws configure) - Python 3.10+,
boto3installed (pip install boto3) - An existing VPC with two subnets:
public-subnetandprivate-subnet
1. Create the security groups
We'll create two security groups: web-sg (allows HTTP/S from anywhere) and db-sg (allows MySQL from the web-sg only).
import boto3
ec2 = boto3.client('ec2')
vpc_id = 'vpc-0abc123xyz' # replace with your VPC ID
# Create web security group
web_sg = ec2.create_security_group(
GroupName='web-sg',
Description='Allow web traffic to web server',
VpcId=vpc_id
)
web_sg_id = web_sg['GroupId']
# Create db security group
db_sg = ec2.create_security_group(
GroupName='db-sg',
Description='Allow DB traffic only from web security group',
VpcId=vpc_id
)
db_sg_id = db_sg['GroupId']
# Authorize inbound HTTP/S for web_sg
for port in [80, 443]:
ec2.authorize_security_group_ingress(
GroupId=web_sg_id,
IpPermissions=[
{
'IpProtocol': 'tcp',
'FromPort': port,
'ToPort': port,
'IpRanges': [{'CidrIp': '0.0.0.0/0'}]
}
]
)
# Authorize inbound MySQL from web_sg only (not CIDR!)
ec2.authorize_security_group_ingress(
GroupId=db_sg_id,
IpPermissions=[
{
'IpProtocol': 'tcp',
'FromPort': 3306,
'ToPort': 3306,
'UserIdGroupPairs': [{'GroupId': web_sg_id}]
}
]
)
print(f'Web SG: {web_sg_id}, DB SG: {db_sg_id}')
Expected output:
Web SG: sg-0a1b2c3d, DB SG: sg-0e4f5g6h
2. Create custom NACLs and attach to subnets
Now we create a custom NACL for the public subnet (allow HTTP/S and SSH) and another for the private subnet (allow only MySQL from the public subnet).
# For the public subnet NACL
def create_nacl(vpc_id, subnet_id, name, rules):
nacl = ec2.create_network_acl(VpcId=vpc_id)
nacl_id = nacl['NetworkAcl']['NetworkAclId']
ec2.create_tags(Resources=[nacl_id], Tags=[{'Key': 'Name', 'Value': name}])
ec2.associate_network_acl(NetworkAclId=nacl_id, SubnetId=subnet_id)
# Delete default deny-all rule (rule 32767) is already there; we add allows below it.
for rule in rules:
ec2.create_network_acl_entry(
NetworkAclId=nacl_id,
RuleNumber=rule['rule_number'],
Protocol=rule['protocol'], # e.g., '6' for TCP
RuleAction='allow',
Egress=rule['egress'],
CidrBlock=rule['cidr'],
PortRange={
'From': rule['port'],
'To': rule['port']
}
)
return nacl_id
public_subnet_id = 'subnet-0aaa111' # replace
private_subnet_id = 'subnet-0bbb222' # replace
# Rules for public NACL: allow 80, 443, 22 inbound and corresponding outbound
public_rules = [
{'rule_number': 100, 'protocol': '6', 'egress': False, 'cidr': '0.0.0.0/0', 'port': 80},
{'rule_number': 110, 'protocol': '6', 'egress': False, 'cidr': '0.0.0.0/0', 'port': 443},
{'rule_number': 120, 'protocol': '6', 'egress': False, 'cidr': '0.0.0.0/0', 'port': 22},
# Outbound: allow return traffic on ephemeral ports (1024-65535) and web traffic
{'rule_number': 200, 'protocol': '6', 'egress': True, 'cidr': '0.0.0.0/0', 'port': 1024},
# Actually you need range, but the API allows a range; we'll simplify with port 1024-65535
]
nacl_public_id = create_nacl(vpc_id, public_subnet_id, 'public-nacl', public_rules)
print(f'Public NACL: {nacl_public_id}')
Pro tip: In the real world, use ranges like
From: 1024, To: 65535for ephemeral ports. The code above is simplified for brevity.
3. Test your hardening
Launch EC2 instances in each subnet with the appropriate security groups. Then try to connect:
# From your local machine, test web server (should succeed if SG allows)
curl http://<web-public-ip>
# From web instance, try to connect to DB (should succeed)
ssh -i key.pem ec2-user@<web-ip>
nc -zv <db-private-ip> 3306
# From internet, try to connect to DB (should fail)
nc -zv <db-private-ip> 3306 # from internet
The first two should succeed; the third should time out or be refused.
Compare options / when to choose what
| Feature | Security Group | Network ACL |
|---|---|---|
| Statefulness | Stateful | Stateless |
| Evaluation | All rules are evaluated together (union) | Rules evaluated in order, lowest number first |
| Scope | Instance (ENI) level | Subnet level |
| Default | Deny all inbound, allow all outbound | Allow all inbound and outbound (default NACL) |
| Use cases | Fine-grained control per instance, references to other SGs | Subnet-level segmentation, protecting entire subnets, blocking malicious IPs |
| When to use | Always use as primary filter | Use for defense-in-depth, especially between subnets |
Variation 1: Use AWS Network Firewall for deep packet inspection and stateful filtering at the VPC level—more advanced than NACLs.
Variation 2: Use Terraform to define your VPC infrastructure as code instead of boto3 scripts—better for team collaboration.
Variation 3: For serverless workloads, rely on security groups only (since Lambda doesn't use subnets in the same way), but still apply NACLs to any VPC-attached resources.
Real-world use cases: - PCI-compliant e-commerce: Block all outbound traffic except to known payment gateways using NACLs. - Microservices architecture: Use NACLs to isolate different tiers (web, app, db) from each other. - Remote work security: Restrict SSH access to a specific IP range via NACL, while security groups handle granular user control.
Troubleshooting & edge cases
Common mistakes and fixes
- Mistake 1: Forgetting to add outbound rules to NACL for return traffic. Because NACLs are stateless, you must allow ephemeral ports. Fix: Add outbound rule for ports 1024-65535.
- Mistake 2: Having overlapping rules in NACL with wrong order. Fix: Remember lower rule numbers take precedence; plan your numbering carefully.
- Mistake 3: Using CIDR blocks instead of security group references for inter-instance communication. Fix: Use
UserIdGroupPairsto reference security groups for better scalability. - Mistake 4: Assuming the default NACL is safe. Fix: Always create custom NACLs and attach them to your subnets.
- Mistake 5: Confusing allow/deny logic — NACLs are evaluated in order, and the first matching rule wins; a deny rule can override a later allow.
Common error scenarios
- Symptom:
Connection timed outwhen accessing web server. - Check SG: Ensure inbound port 80/443 is allowed.
-
Check NACL: Ensure NACL allows inbound and outbound on those ports.
-
Symptom: Web instance can't connect to database.
- Check SG on DB: Ensure it allows inbound from web SG.
-
Check NACL on private subnet: Ensure it allows inbound from public subnet CIDR.
-
Symptom: All traffic blocked after creating a custom NACL.
-
Likely forgot to add outbound rules. Remember your NACL is deny-all by default; add both inbound and outbound.
-
Symptom:
NotAuthorizedForPortwhen usingauthorize_security_group_ingress. - Ensure your IAM policy includes
ec2:AuthorizeSecurityGroupIngress.
Edge case: Default VPC
Default VPCs have a default NACL that allows everything. If you're using the default VPC for production, you absolutely need to replace that with a custom NACL.
What you learned & what's next
You've now mastered the core of VPC security: using both security groups (stateful) and NACLs (stateless) to harden your network. You understand the differences, how to implement them with Python/boto3, and how to troubleshoot common issues. You can now apply defense-in-depth to any VPC, ensuring your resources are protected from unauthorized access.
As you continue your AWS journey, the next logical step is to explore VPC Flow Logs to monitor traffic and detect anomalies, or dive into AWS Network Firewall for even deeper inspection. Keep building secure infrastructure!
Practice recap
Try this: Write a Python script that creates a custom NACL for your existing VPC with rules that deny all inbound traffic except SSH from your IP, and attach it to your public subnet. Then verify that you can still SSH in but cannot connect to other ports. This hands-on exercise will cement your understanding of stateless filtering.
Common mistakes
- Forgetting outbound rules on NACLs for return traffic — always allow ephemeral ports (1024-65535).
- Using CIDR blocks instead of security group references for inter-instance communication, which breaks when IPs change.
- Leaving the default NACL (which allows all traffic) attached to production subnets.
- Misunderstanding NACL rule order — the first matching rule wins, so a deny rule can block traffic even if a later rule allows it.
Variations
- Use AWS Network Firewall for stateful, application-aware filtering beyond layer 3/4.
- Use infrastructure as code (Terraform/CloudFormation) to define NACLs and security groups for repeatable deployments.
- For serverless workloads, rely primarily on security groups and use VPC endpoints to avoid NAT complexities.
Real-world use cases
- Isolating a public-facing web tier from a private database tier using subnet-level NACLs.
- Blocking outbound traffic from a subnet to the internet except via a known proxy or NAT gateway.
- Restricting SSH access to a management bastion host to only your office's public IP range using NACLs.
Key takeaways
- Security groups are stateful and act at the instance level; NACLs are stateless and act at the subnet level.
- Always create custom NACLs and attach them to your subnets—don't rely on the default allow-all NACL.
- Use security group references (not CIDR) to allow traffic between trusted resources.
- NACL rules are evaluated in order (lowest number first) — plan numbering carefully to avoid accidental blocks.
- Layered defenses (NACL + SG) provide defense-in-depth and reduce the blast radius of a misconfiguration.
- Automate VPC hardening with Python/boto3 to keep your infrastructure consistent and auditable.
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.