Back to blog
AWSBest PracticesCloud SecurityCompute & ContainersNetworking

EC2 Instance Has a Public IP: Why It's Risky and How to Fix It

Learn why EC2 instances with public IPs expand your attack surface, how to move workloads to private subnets with NAT, and how to prevent it with policy as code.

TL;DR

This check flags EC2 instances that have a public IP address attached, which puts them directly on the internet and widens your attack surface. Move workloads into private subnets and route outbound traffic through a NAT gateway, with inbound traffic fronted by a load balancer.

A public IP on an EC2 instance is one of those settings that feels harmless until it isn't. You launch an instance, accept the defaults, and AWS happily assigns it a routable address. Now anything on the internet can attempt to reach it, and the only thing standing between an attacker and your workload is a security group rule you may or may not have configured correctly.

The ec2_public_ip check exists because public IPs are rarely necessary and almost always a liability. Most instances that have one don't need one.


What this check detects

The check inspects each EC2 instance and reports those with a public IPv4 address assigned to a network interface. This includes both auto-assigned public IPs (the kind AWS hands out when you launch into a subnet with MapPublicIpOnLaunch enabled) and Elastic IPs associated with the instance.

A flagged instance means there is a path from the public internet to that machine, gated only by routing, security groups, and network ACLs.

Note: Having a public IP does not automatically mean the instance is reachable. Traffic still has to pass through the security group and network ACL. But a public IP removes one layer of defense entirely, and it only takes one overly permissive security group rule to expose the host.


Why it matters

The risk comes down to exposure. An instance with a private IP only is unreachable from the internet by design, no matter how badly its security group is configured. An instance with a public IP is reachable the moment a port is opened to 0.0.0.0/0.

Here are the scenarios that turn this from a theoretical concern into an incident:

  • Accidental SSH or RDP exposure. Someone adds a temporary 0.0.0.0/0 rule on port 22 to debug something, forgets to remove it, and the instance gets brute-forced within hours. Automated scanners hit public IPs constantly.
  • Unpatched services. A database, admin panel, or internal API binds to a public interface. If the host is internet-facing, that service is now a target for known CVEs.
  • Lateral movement staging. A compromised public instance becomes a beachhead into your VPC, especially if it has an IAM role attached and can reach internal resources.
  • Compliance failures. Frameworks like PCI DSS and SOC 2 expect workloads handling sensitive data to sit behind network controls, not on a public address.

The business impact is straightforward: data breaches, ransomware footholds, cryptomining on your compute bill, and audit findings. None of which you want from a setting you never deliberately turned on.

Warning: Public IPs are also frequently the entry point for cryptomining attacks. An exposed instance with weak credentials or an unpatched service can be running someone else's mining workload within minutes, and you find out when the bill arrives.


How to fix it

The fix depends on whether the instance actually needs inbound or outbound internet access. Work through these in order.

Step 1: Confirm whether the public IP is needed

Check what the instance does. Application servers, workers, and databases almost never need a public IP. The only common legitimate case is a host that must be directly reachable from the internet, and even then a load balancer is usually the better answer.

Find the instances that have public IPs:

aws ec2 describe-instances \
  --filters "Name=instance-state-name,Values=running" \
  --query 'Reservations[].Instances[?PublicIpAddress!=`null`].[InstanceId,PublicIpAddress,SubnetId]' \
  --output table

Step 2: Disable auto-assignment on the subnet

If your instances are picking up public IPs automatically, the subnet has MapPublicIpOnLaunch turned on. Disable it so future launches stay private:

aws ec2 modify-subnet-attribute \
  --subnet-id subnet-0abc123456789def0 \
  --no-map-public-ip-on-launch

This only affects new launches. Existing instances keep their assigned addresses.

Step 3: Remove the public IP from a running instance

You cannot remove an auto-assigned public IP from a running instance directly. The address is tied to the network interface at launch. You have a few options:

  • For an auto-assigned public IP, stop and start the instance after moving it to a subnet without auto-assignment, or launch a replacement instance in a private subnet.
  • For an Elastic IP, disassociate and release it.

To release an Elastic IP:

Danger: Disassociating an Elastic IP immediately drops any active connections to that address, and releasing it returns the IP to the AWS pool. If anything external depends on that specific address (DNS records, allowlists, partner integrations), update those first or you will cause an outage.

# Find the association
aws ec2 describe-addresses \
  --query 'Addresses[].[PublicIp,AllocationId,AssociationId,InstanceId]' \
  --output table

# Disassociate
aws ec2 disassociate-address --association-id eipassoc-0abc123456789def0

# Release it back to the pool
aws ec2 release-address --allocation-id eipalloc-0abc123456789def0

Step 4: Provide outbound access through a NAT gateway

Most private instances still need to reach the internet for package updates, API calls, and patching. Route that through a NAT gateway in a public subnet:

# Allocate an EIP for the NAT gateway
aws ec2 allocate-address --domain vpc

# Create the NAT gateway in a public subnet
aws ec2 create-nat-gateway \
  --subnet-id subnet-PUBLIC0123456789 \
  --allocation-id eipalloc-NATGATEWAY00

# Route the private subnet's outbound traffic through it
aws ec2 create-route \
  --route-table-id rtb-PRIVATE0123456789 \
  --destination-cidr-block 0.0.0.0/0 \
  --nat-gateway-id nat-0abc123456789def0

Warning: NAT gateways cost money, both an hourly charge and a per-gigabyte data processing fee. For high outbound traffic, this adds up. If your instances only need to reach AWS services like S3 or DynamoDB, use VPC endpoints instead and skip the NAT entirely for that traffic.

Step 5: Front inbound traffic with a load balancer

If the instance serves traffic to users, put an Application Load Balancer in the public subnets and keep the instances private. The ALB terminates TLS, handles the public exposure, and forwards to your instances over private IPs. This also gives you health checks, scaling, and a single place to attach a WAF.


Doing it properly with infrastructure as code

Fixing instances by hand is fine for cleanup, but the durable fix is to never assign public IPs in the first place. Here is the Terraform pattern: private instances, public NAT, no public IP on the workload.

resource "aws_subnet" "private" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.10.0/24"
  map_public_ip_on_launch = false   # the important line
}

resource "aws_instance" "app" {
  ami                         = var.ami_id
  instance_type               = "t3.micro"
  subnet_id                   = aws_subnet.private.id
  associate_public_ip_address = false   # be explicit
  vpc_security_group_ids      = [aws_security_group.app.id]
}

Setting associate_public_ip_address = false explicitly means no one can accidentally inherit a public IP from a subnet default later.


How to prevent it from happening again

Cleanup is temporary. Guardrails are permanent. Layer these so a public IP either can't be created or gets caught immediately.

Block it with an SCP

A Service Control Policy can deny running instances with public IPs across an entire AWS Organization:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyPublicIpOnRunInstances",
      "Effect": "Deny",
      "Action": "ec2:RunInstances",
      "Resource": "arn:aws:ec2:*:*:network-interface/*",
      "Condition": {
        "Bool": { "ec2:AssociatePublicIpAddress": "true" }
      }
    }
  ]
}

Catch it in CI/CD with policy as code

Scan Terraform plans before they apply. A Checkov run will fail the build on a public IP:

checkov -d . --check CKV_AWS_88

Or write a Conftest/OPA policy that rejects any instance with associate_public_ip_address set to true, and wire it into your pull request checks. Either way, the goal is to fail the merge, not discover the problem in production.

Tip: Pair the preventive controls with continuous detection. Lensix runs the ec2_public_ip check on a schedule so even resources created outside your IaC pipeline, like a console launch during an incident, get flagged before they linger.


Best practices

  • Default to private. Treat a public IP as something you justify, not something you accept. Most tiers of most applications belong in private subnets.
  • Use a three-tier subnet layout. Public subnets for load balancers and NAT gateways only, private subnets for application and data tiers.
  • Replace SSH with Session Manager. AWS Systems Manager Session Manager gives you shell access to private instances with no inbound ports and no public IP, fully logged in CloudTrail.
  • Use VPC endpoints for AWS service traffic. Keep traffic to S3, ECR, Secrets Manager, and others off the public internet and off your NAT bill.
  • Audit Elastic IPs regularly. Unattached EIPs cost money and attached ones expand exposure. Review them on a cadence.
  • Combine network and identity controls. A private subnet plus least-privilege IAM roles means even a compromised instance has limited reach.

Removing public IPs from instances that don't need them is one of the highest-value, lowest-effort hardening steps available in AWS. It shrinks your attack surface, simplifies your security group reviews, and closes off an entire class of internet-facing attacks before they start.