A misconfigured AWS VPC is the starting point of most cloud security incidents. Here is how to harden every layer — subnets, Security Groups, NACLs, and IAM — for production.
A startup in Bengaluru left port 3306 open to 0.0.0.0/0 on their RDS Security Group. Not their EC2. Their RDS. The database was directly reachable from the internet. It took automated scanners 11 minutes to find it and begin brute-forcing. By the time the team noticed anomalous CloudTrail activity, 2.3 million user records had been exfiltrated.
VPC security is not a one-time setup. It is a layered architecture where each layer catches what the previous one misses. This guide walks through every layer — from subnet design to IAM conditions — the way production security teams at Razorpay and Zerodha implement it.
The AWS Shared Responsibility Model puts network configuration entirely in your hands. AWS secures the physical infrastructure. You secure what runs on it.
The most common VPC mistakes that lead to breaches:
None of these are difficult to fix. All of them are frequently found in production.
The foundational rule: application servers and databases must never be in public subnets. The only resources in public subnets are load balancers, NAT Gateways, and Bastion Hosts.
Correct production VPC layout:VPC: 10.0.0.0/16 Public subnets (internet-facing): 10.0.1.0/24 — ap-south-1a — ALB, NAT Gateway 10.0.2.0/24 — ap-south-1b — ALB, NAT Gateway Private subnets (application layer): 10.0.10.0/24 — ap-south-1a — EC2 app servers, ECS tasks 10.0.11.0/24 — ap-south-1b — EC2 app servers, ECS tasks Private subnets (data layer): 10.0.20.0/24 — ap-south-1a — RDS primary, ElastiCache 10.0.21.0/24 — ap-south-1b — RDS standby, ElastiCache replicaThree tiers of private subnets give you defence in depth. Even if an attacker reaches the application layer, they cannot directly access the data layer — different Security Groups, different subnets, different route tables.
Disable auto-assign public IP on every private subnet. An EC2 instance accidentally launched in a private subnet with a public IP is a security incident waiting to happen.
Security Groups are stateful allow-only firewalls attached to individual resources. They are the most frequently misconfigured element in AWS environments.
The Security Group chain pattern — enforce it everywhere:
Internet ↓ allow 80, 443 from 0.0.0.0/0ALB Security Group (sg-alb-prod) ↓ allow 8080 from sg-alb-prod onlyApp EC2 Security Group (sg-app-prod) ↓ allow 5432 from sg-app-prod onlyRDS Security Group (sg-rds-prod)Source is always a Security Group ID — never an IP address or a CIDR range. IPs change when instances restart. Security Group IDs never change. Using SG IDs as sources means your rules stay correct even as your fleet scales up and down.
The rules that must never exist in production:
## These configurations should never be in production## Port 22 open to the worldaws ec2 authorize-security-group-ingress \ --group-id sg-xxxxxxxx \ --protocol tcp \ --port 22 \ --cidr 0.0.0.0/0 ## NEVER do this ## Database port open to the worldaws ec2 authorize-security-group-ingress \ --group-id sg-rds-prod \ --protocol tcp \ --port 3306 \ --cidr 0.0.0.0/0 ## NEVER do this ## Correct: allow only from the app Security Groupaws ec2 authorize-security-group-ingress \ --group-id sg-rds-prod \ --protocol tcp \ --port 3306 \ --source-group sg-app-prod \ --region ap-south-1Network ACLs are stateless subnet-level firewalls. Unlike Security Groups, they support both allow and deny rules. They evaluate by rule number — lowest number wins.
The primary use case for NACLs: block known malicious IPs. If GuardDuty flags an IP as a threat, or your WAF identifies a DDoS source, add a NACL deny rule immediately.
## Block a known malicious IP at the subnet levelaws ec2 create-network-acl-entry \ --network-acl-id acl-xxxxxxxx \ --rule-number 10 \ --protocol -1 \ --rule-action deny \ --cidr-block 198.51.100.0/24 \ --ingress \ --region ap-south-1The stateless trap — ephemeral ports:
NACLs must explicitly allow return traffic. When a client connects to your server on port 443, the response goes back on an ephemeral port (1024-65535) that the client chose. If your NACL blocks outbound traffic on the ephemeral range, the response never reaches the client.
## Correct NACL outbound rules for a web server:## Allow HTTPS responses back to clientsaws ec2 create-network-acl-entry \ --network-acl-id acl-xxxxxxxx \ --rule-number 100 \ --protocol tcp \ --port-range From=443,To=443 \ --rule-action allow \ --cidr-block 0.0.0.0/0 \ --egress ## Allow ephemeral port range for return trafficaws ec2 create-network-acl-entry \ --network-acl-id acl-xxxxxxxx \ --rule-number 110 \ --protocol tcp \ --port-range From=1024,To=65535 \ --rule-action allow \ --cidr-block 0.0.0.0/0 \ --egressWithout VPC Flow Logs you are debugging network issues blind. Every accepted and rejected connection is recorded — source IP, destination IP, port, protocol, and whether it was accepted or rejected.
## Enable VPC Flow Logs to CloudWatchaws ec2 create-flow-logs \ --resource-type VPC \ --resource-ids vpc-xxxxxxxx \ --traffic-type ALL \ --log-destination-type cloud-watch-logs \ --log-group-name /vpc/prod-flow-logs \ --deliver-logs-permission-arn \ arn:aws:iam::123456789012:role/FlowLogsRole \ --region ap-south-1Debugging a mysterious connection failure:
REJECT inbound → Security Group or NACL blocking incoming trafficREJECT outbound → Security Group or NACL blocking outgoing trafficACCEPT inbound but REJECT outbound → stateless NACL missing ephemeral port ruleACCEPT both but app still fails → application-level issue, not networkAt Razorpay, VPC Flow Logs feed into a CloudWatch Metrics Filter that alerts when there are more than 1,000 REJECT events per minute from a single source IP. This is the first indicator of a port scanning or brute-force attempt.
A single NAT Gateway in one AZ creates two problems: it is a single point of failure for internet access, and instances in other AZs pay cross-AZ data transfer fees on every outbound request.
## Create NAT Gateway in ap-south-1aaws ec2 allocate-address --domain vpc --region ap-south-1aws ec2 create-nat-gateway \ --subnet-id subnet-public-1a \ --allocation-id eipalloc-xxxxxxxx \ --tag-specifications \ 'ResourceType=natgateway,Tags=[{Key=Name,Value=nat-gw-1a}]' \ --region ap-south-1 ## Create separate NAT Gateway in ap-south-1baws ec2 allocate-address --domain vpc --region ap-south-1aws ec2 create-nat-gateway \ --subnet-id subnet-public-1b \ --allocation-id eipalloc-yyyyyyyy \ --tag-specifications \ 'ResourceType=natgateway,Tags=[{Key=Name,Value=nat-gw-1b}]' \ --region ap-south-1Then associate each private subnet route table with the NAT Gateway in its own AZ. AZ-1a instances route through NAT GW A. AZ-1b instances route through NAT GW B. Neither depends on the other.
Private EC2 instances accessing S3 through NAT Gateway pay NAT processing fees ($0.045/GB) for traffic that never needed to touch the internet. A VPC Gateway Endpoint routes S3 and DynamoDB traffic directly through AWS's private network — free of charge.
## Create free S3 Gateway Endpointaws ec2 create-vpc-endpoint \ --vpc-id vpc-xxxxxxxx \ --service-name com.amazonaws.ap-south-1.s3 \ --route-table-ids rtb-private-1a rtb-private-1b \ --region ap-south-1 ## Create free DynamoDB Gateway Endpointaws ec2 create-vpc-endpoint \ --vpc-id vpc-xxxxxxxx \ --service-name com.amazonaws.ap-south-1.dynamodb \ --route-table-ids rtb-private-1a rtb-private-1b \ --region ap-south-1For a team reading 10 TB/month from S3 through NAT Gateway, this single change saves $450/month. Do it in every VPC from day one.
Modern AWS security treats IAM as a network control. Even if traffic reaches an S3 bucket from inside your VPC, IAM decides whether the specific caller is allowed to access that specific object.
Restrict S3 access to VPC only:
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": [ "arn:aws:s3:::razorpay-payment-logs", "arn:aws:s3:::razorpay-payment-logs/*" ], "Condition": { "StringNotEquals": { "aws:SourceVpc": "vpc-xxxxxxxx" } } } ]}This bucket policy denies all S3 access that does not originate from your specific VPC. Even if someone has valid IAM credentials, they cannot access this bucket from outside the VPC — not from their laptop, not from another AWS account, not from any external system.
Require MFA for sensitive operations:
{ "Version": "2012-10-17", "Statement": [{ "Effect": "Deny", "Action": [ "ec2:AuthorizeSecurityGroupIngress", "ec2:ModifyVpcAttribute", "rds:ModifyDBInstance" ], "Resource": "*", "Condition": { "BoolIfExists": { "aws:MultiFactorAuthPresent": "false" } } }]}Network changes require MFA. Even if an attacker steals valid IAM credentials, they cannot open Security Group ports without the physical MFA device.
GuardDuty monitors VPC Flow Logs, CloudTrail, and DNS logs automatically and uses ML to detect threats that rule-based systems miss.
Critical GuardDuty findings to wire up immediately:
| Finding | What it means | Automated response |
|---|---|---|
| UnauthorizedAccess:EC2/SSHBruteForce | SSH brute force in progress | Add NACL deny rule |
| Recon:EC2/PortProbeUnprotectedPort | Port scanning detected | Alert security team |
| CryptoCurrency:EC2/BitcoinTool.B | Instance mining crypto | Isolate instance |
| UnauthorizedAccess:IAMUser/TorIPCaller | Credentials used via Tor | Revoke credentials |
Wire findings to EventBridge → Lambda → automated response:
GuardDuty finding: SSH brute force from 198.51.100.45 ↓EventBridge rule matches ↓Lambda adds NACL deny rule for 198.51.100.45 automaticallyLambda sends Slack alert to security channelTotal response time: under 30 secondsImplement these in order — each builds on the previous:
restricted-ssh and vpc-sg-open-only-to-authorized-ports.REMEMBER THIS**References and Further Reading** * [AWS VPC Security Best Practices](https://docs.aws.amazon.com/vpc/latest/userguide/vpc-security-best-practices.html) — official AWS guidance * [AWS GuardDuty findings types](https://docs.aws.amazon.com/guardduty/latest/ug/guardduty_finding-types-active.html) — full catalogue of threats detected * [AWS Config managed rules](https://docs.aws.amazon.com/config/latest/developerguide/managed-rules-by-aws-config.html) — compliance rule library
Security Groups are stateful firewalls attached to individual resources — they only support allow rules, not deny. NACLs are stateless firewalls at the subnet level and support both allow and deny rules. To block a specific IP address or CIDR range, you must use a NACL. Security Groups cannot explicitly deny traffic.
Set the subnet's auto-assign public IPv4 setting to disabled at the subnet level in the VPC console. Additionally, use an SCP at the AWS Organizations level to deny ec2:RunInstances when the network interface is configured with a public IP — this enforces the rule even if someone manually overrides subnet defaults.
Discussion0