Find Publicly Accessible RDS Instances

Rack-mounted database servers with green status lights in a dark data centre

Photo by Kvistholt Photography on Unsplash

To find publicly accessible RDS instances, call DescribeDBInstances in every Region and keep the instances where PubliclyAccessible is true. Then check two things the flag alone doesn’t tell you: whether the DB subnet group’s route tables send 0.0.0.0/0 to an internet gateway, and whether a security group allows the database port from 0.0.0.0/0 or ::/0.

A database with a public endpoint is one security group edit away from the whole internet. The Public access option is easy to switch on while debugging a connection and easy to forget afterwards, and it’s set per DB instance, so a single Aurora reader can be public while the rest of its cluster stays private. This example is for engineers who want to find publicly accessible RDS instances across all Regions and see which ones are actually reachable.

You’ll get a TypeScript script for the AWS SDK for JavaScript v3 that prints one row per DB instance with a verdict. It changes nothing unless you pass --apply, in the same report-then-apply style as the other AWS SDK v3 security audit examples.

When is an RDS instance really reachable from the internet?

Three separate settings have to line up, and the script checks each one:

  • PubliclyAccessible is true. RDS then gives the instance a public IP address as well as its private one. Its DNS endpoint resolves to the private address from inside the VPC and to the public address from outside it.
  • The subnets are public. RDS requires every subnet in the DB subnet group of a publicly accessible instance to be public, meaning its route table sends internet traffic to an internet gateway. The script reads each subnet’s route table, falling back to the VPC’s main route table when a subnet has no explicit association.
  • A security group lets traffic in. Access is ultimately controlled by the instance’s security groups. A rule that allows the database port, or all traffic, from 0.0.0.0/0 or ::/0 completes the path.

When all three are true, the verdict is EXPOSED. When the flag and routes are set but no rule is open to everyone, the verdict is PUBLIC: anyone on the internet whose IP the security group allows can connect, which is sometimes intended (a fixed office range) and often isn’t. The AWS guide to hiding a DB instance in a VPC from the internet describes the same three conditions.

What does the script do?

  1. Lists RegionsDescribeRegions returns the Regions enabled for your account, or you pass --regions=.
  2. Reads every DB instanceDescribeDBInstances with the SDK paginator. Aurora instances appear here too, with their cluster name in DBClusterIdentifier.
  3. Checks the subnetsDescribeRouteTables once per VPC, looking for a 0.0.0.0/0 or ::/0 route whose target starts with igw-.
  4. Checks the security groupsDescribeSecurityGroups for the instance’s groups, matching rules that cover the endpoint port.
  5. Turns off public access only on requestWith --apply, ModifyDBInstance sets PubliclyAccessible to false on every flagged instance, or only on the ones you name with --ids=.

Prerequisites

Which IAM permissions does it need?

The first statement is all the report needs. The second is only for --apply; leave it out of an audit role. Replace 123456789012 with your account ID.

rds-public-access-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadDatabaseExposure",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeRegions",
        "ec2:DescribeRouteTables",
        "ec2:DescribeSecurityGroups",
        "rds:DescribeDBInstances"
      ],
      "Resource": "*"
    },
    {
      "Sid": "TurnOffPublicAccessWithApply",
      "Effect": "Allow",
      "Action": "rds:ModifyDBInstance",
      "Resource": "arn:aws:rds:*:123456789012:db:*"
    }
  ]
}

If you change the script, the IAM policy generator for TypeScript AWS SDK code drafts a new policy from the source. Review it before attaching.

The script to find publicly accessible RDS instances

find-publicly-accessible-rds-instances.ts

// find-publicly-accessible-rds-instances.ts
// Lists every RDS and Aurora DB instance in each Region with PubliclyAccessible, whether its DB subnet
// group routes to an internet gateway, and whether a security group admits 0.0.0.0/0 or ::/0 on the
// database port. Report-only by default. --apply sets PubliclyAccessible=false on flagged instances.
// Usage: npx tsx find-publicly-accessible-rds-instances.ts [--regions=us-east-1,eu-west-1] [--ids=db1,db2] [--apply]
import {
  EC2Client,
  DescribeRegionsCommand,
  DescribeSecurityGroupsCommand,
  paginateDescribeRouteTables,
  type IpPermission,
  type RouteTable,
} from "@aws-sdk/client-ec2";
import {
  RDSClient,
  ModifyDBInstanceCommand,
  paginateDescribeDBInstances,
  type DBInstance,
} from "@aws-sdk/client-rds";

const args = process.argv.slice(2);
const apply = args.includes("--apply");
const listArg = (name: string): string[] | undefined =>
  args.find((a) => a.startsWith(`--${name}=`))?.split("=")[1]?.split(",").map((s) => s.trim()).filter(Boolean);
const regionArg = listArg("regions");
const onlyIds = listArg("ids");

interface Row {
  Region: string;
  Instance: string;
  Cluster: string;
  Port: number | string;
  Public: boolean;
  IgwRoute: string;
  OpenRule: string;
  Verdict: string;
}

async function listRegions(): Promise<string[]> {
  if (regionArg) return regionArg;
  const out = await new EC2Client({}).send(new DescribeRegionsCommand({})); // enabled Regions only
  return (out.Regions ?? []).map((r) => r.RegionName ?? "").filter(Boolean).sort();
}

const routeTablesByVpc = new Map<string, RouteTable[]>();
async function routeTables(ec2: EC2Client, vpcId: string): Promise<RouteTable[]> {
  const cached = routeTablesByVpc.get(vpcId);
  if (cached) return cached;
  const tables: RouteTable[] = [];
  for await (const page of paginateDescribeRouteTables({ client: ec2 }, { Filters: [{ Name: "vpc-id", Values: [vpcId] }] })) {
    tables.push(...(page.RouteTables ?? []));
  }
  routeTablesByVpc.set(vpcId, tables);
  return tables;
}

// A subnet is public when its route table (explicit association, else the VPC's main table)
// sends 0.0.0.0/0 or ::/0 to an internet gateway.
function subnetHasIgwRoute(tables: RouteTable[], subnetId: string): boolean {
  const table =
    tables.find((t) => t.Associations?.some((a) => a.SubnetId === subnetId)) ??
    tables.find((t) => t.Associations?.some((a) => a.Main));
  return (table?.Routes ?? []).some(
    (r) =>
      (r.GatewayId ?? "").startsWith("igw-") &&
      (r.DestinationCidrBlock === "0.0.0.0/0" || r.DestinationIpv6CidrBlock === "::/0"),
  );
}

// Returns "sg-id (0.0.0.0/0)" for the first rule that lets the whole internet reach the port.
function openToWorld(groupId: string, perms: IpPermission[], port: number): string | undefined {
  for (const p of perms) {
    const coversPort =
      p.IpProtocol === "-1" ||
      (p.IpProtocol === "tcp" && (p.FromPort ?? 0) <= port && port <= (p.ToPort ?? 65535));
    if (!coversPort) continue;
    if (p.IpRanges?.some((r) => r.CidrIp === "0.0.0.0/0")) return `${groupId} (0.0.0.0/0)`;
    if (p.Ipv6Ranges?.some((r) => r.CidrIpv6 === "::/0")) return `${groupId} (::/0)`;
  }
  return undefined;
}

async function checkInstance(region: string, ec2: EC2Client, db: DBInstance): Promise<Row> {
  const id = db.DBInstanceIdentifier ?? "?";
  const port = db.Endpoint?.Port ?? db.DbInstancePort ?? 0;
  const isPublic = db.PubliclyAccessible === true;
  const vpcId = db.DBSubnetGroup?.VpcId;
  const subnetIds = (db.DBSubnetGroup?.Subnets ?? []).map((s) => s.SubnetIdentifier ?? "").filter(Boolean);

  let routed = 0;
  if (vpcId && subnetIds.length) {
    const tables = await routeTables(ec2, vpcId);
    routed = subnetIds.filter((s) => subnetHasIgwRoute(tables, s)).length;
  }

  let open: string | undefined;
  const groupIds = (db.VpcSecurityGroups ?? []).map((g) => g.VpcSecurityGroupId ?? "").filter(Boolean);
  if (groupIds.length && port) {
    const sgs = (await ec2.send(new DescribeSecurityGroupsCommand({ GroupIds: groupIds }))).SecurityGroups ?? [];
    for (const sg of sgs) {
      open = openToWorld(sg.GroupId ?? "?", sg.IpPermissions ?? [], port);
      if (open) break;
    }
  }

  // RDS requires every subnet in the group to be public for a publicly accessible instance.
  const allSubnetsRouted = subnetIds.length > 0 && routed === subnetIds.length;
  const verdict = !isPublic
    ? "ok (private)"
    : allSubnetsRouted && open
      ? "EXPOSED: public, routed, open to the internet"
      : allSubnetsRouted
        ? "PUBLIC: reachable from IPs the security group allows"
        : "PUBLIC FLAG: no complete internet gateway route";
  return {
    Region: region,
    Instance: id,
    Cluster: db.DBClusterIdentifier ?? "-",
    Port: port || "?",
    Public: isPublic,
    IgwRoute: subnetIds.length ? `${routed}/${subnetIds.length} subnets` : "-",
    OpenRule: open ?? "-",
    Verdict: verdict,
  };
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of await listRegions()) {
    const rds = new RDSClient({ region });
    const ec2 = new EC2Client({ region });
    try {
      for await (const page of paginateDescribeDBInstances({ client: rds }, {})) {
        for (const db of page.DBInstances ?? []) {
          if (onlyIds && !onlyIds.includes(db.DBInstanceIdentifier ?? "")) continue;
          rows.push(await checkInstance(region, ec2, db));
        }
      }
    } catch (err) {
      rows.push({ Region: region, Instance: "?", Cluster: "-", Port: "?", Public: false, IgwRoute: "?", OpenRule: "?", Verdict: `error: ${err instanceof Error ? err.name : String(err)}` });
    }
  }

  console.table(rows);
  const flagged = rows.filter((r) => r.Public);
  console.log(`${rows.length} DB instance(s) checked; ${flagged.length} with PubliclyAccessible=true; ${rows.filter((r) => r.Verdict.startsWith("EXPOSED")).length} exposed.`);

  if (!apply) {
    console.log("Report only: nothing changed. --apply would set PubliclyAccessible=false on the flagged instances.");
  } else {
    for (const r of flagged) {
      try {
        // No ApplyImmediately: PubliclyAccessible is applied immediately anyway, and ApplyImmediately=true
        // would also push any other pending modifications on the instance out of the maintenance window.
        await new RDSClient({ region: r.Region }).send(
          new ModifyDBInstanceCommand({ DBInstanceIdentifier: r.Instance, PubliclyAccessible: false }),
        );
        console.log(`${r.Region} ${r.Instance}: PubliclyAccessible set to false`);
      } catch (err) {
        console.log(`${r.Region} ${r.Instance}: failed (${err instanceof Error ? err.message : String(err)})`);
      }
    }
  }
  if (flagged.length && !apply) process.exitCode = 2; // lets CI or a cron wrapper fail on a finding
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

The call to ModifyDBInstance deliberately leaves out ApplyImmediately. The RDS API applies PubliclyAccessible changes immediately whatever that parameter says, while ApplyImmediately: true would also push any other pending modification on the instance, such as a queued instance class change, out of the maintenance window.

How do you run it?

Terminal

npm install @aws-sdk/client-rds @aws-sdk/client-ec2
npm install --save-dev tsx typescript

# Report every DB instance in every enabled Region
AWS_PROFILE=readonly npx tsx find-publicly-accessible-rds-instances.ts

# Turn off public access for two named instances only
AWS_PROFILE=db-admin npx tsx find-publicly-accessible-rds-instances.ts --regions=eu-west-1 --ids=reporting-mysql,legacy-sqlserver --apply

The report sets exit code 2 when it finds a public instance, so a scheduled job or CI step fails loudly instead of printing a table nobody reads.

Sample output

Output

┌─────────┬─────────────┬───────────────────────┬──────────────┬──────┬────────┬───────────────┬───────────────────────────┬────────────────────────────────────────────────────────┐
│ (index) │ Region      │ Instance              │ Cluster      │ Port │ Public │ IgwRoute      │ OpenRule                  │ Verdict                                                │
├─────────┼─────────────┼───────────────────────┼──────────────┼──────┼────────┼───────────────┼───────────────────────────┼────────────────────────────────────────────────────────┤
│ 0       │ 'eu-west-1' │ 'reporting-mysql'     │ '-'          │ 3306 │ true   │ '3/3 subnets' │ 'sg-0a1b2c3d (0.0.0.0/0)' │ 'EXPOSED: public, routed, open to the internet'        │
│ 1       │ 'eu-west-1' │ 'orders-pg'           │ '-'          │ 5432 │ false  │ '0/3 subnets' │ '-'                       │ 'ok (private)'                                         │
│ 2       │ 'us-east-1' │ 'app-aurora-reader-2' │ 'app-aurora' │ 5432 │ true   │ '2/2 subnets' │ '-'                       │ 'PUBLIC: reachable from IPs the security group allows' │
│ 3       │ 'us-east-1' │ 'app-aurora-writer'   │ 'app-aurora' │ 5432 │ false  │ '2/2 subnets' │ '-'                       │ 'ok (private)'                                         │
│ 4       │ 'us-west-2' │ 'legacy-sqlserver'    │ '-'          │ 1433 │ true   │ '1/2 subnets' │ '-'                       │ 'PUBLIC FLAG: no complete internet gateway route'      │
└─────────┴─────────────┴───────────────────────┴──────────────┴──────┴────────┴───────────────┴───────────────────────────┴────────────────────────────────────────────────────────┘
5 DB instance(s) checked; 3 with PubliclyAccessible=true; 1 exposed.
Report only: nothing changed. --apply would set PubliclyAccessible=false on the flagged instances.

The names are illustrative. reporting-mysql is the one to fix today. app-aurora-reader-2 shows the Aurora case: one reader was made public while the writer wasn’t, so the cluster looks private from its writer’s settings. legacy-sqlserver has the flag on but one subnet lost its internet gateway route, which can also affect the instance’s availability, so either finish making it private or fix the subnet group.

What happens when you turn off public access?

RDS removes the public IP address and the endpoint name stays the same, now resolving only to the private address. Clients inside the VPC or connected to it through peering, a VPN or Direct Connect keep working. Anything that connected over the internet stops: laptops using a SQL client, a BI tool hosted outside AWS, or a Lambda function outside the VPC.

Before you run --apply, check the database’s recent connections so you know who you’re about to cut off. The script to find idle RDS instances with no connections reads the DatabaseConnections metric; an instance with zero connections for weeks may be better deleted than fixed, after a final snapshot. If it stays, the script to find RDS instances without automated backups or encryption checks the other settings that protect it. For people who still need access, put a bastion or an SSM port-forwarding session in front of the database instead of a public endpoint.

What doesn’t the script catch?

Troubleshooting

  • AccessDenied on DescribeRouteTables or DescribeSecurityGroups. The RDS calls succeeded but the EC2 ones didn’t, so the Region row shows an error. Add the EC2 actions from the policy above; the guide to troubleshoot AWS IAM access denied errors step by step helps when an SCP is the cause.
  • InvalidDBInstanceState on --apply. The instance is stopped, rebooting or being modified. Wait until its status is available and run the script again with --ids=.
  • The verdict is EXPOSED but you can’t connect. The check doesn’t read network ACLs, and the VPC needs the DNS hostnames and DNS resolution attributes for the public endpoint to resolve. Treat the finding as real anyway; the setting is one change away from working.
  • A shared VPC instance fails to modify. In a shared VPC, only the account that created the DB instance can call RDS APIs on it. Run --apply from that account.

Security groups that no longer protect anything add noise to this kind of review; the script to find unused security groups in your AWS account clears them out first. Databases aren’t the only endpoints worth checking: the scripts to find EC2 instances with public IPv4 and IPv6 addresses and find EKS clusters whose API endpoint is public cover compute and Kubernetes.

Ask ChatWithCloud instead

For a quick check of one Region, ask ChatWithCloud “Which RDS instances are publicly accessible, and do their security groups allow 0.0.0.0/0?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and summarizes the result; the guide to analyze your AWS security posture with an AI CLI shows similar questions. It uses one profile and Region per session and runs generated code without asking first, so connect ChatWithCloud with a read-only AWS profile and keep the --apply step in this script. The ChatWithCloud security model explains what leaves your machine.

Frequently asked questions

How do I check if an RDS instance is publicly accessible?

Read the PubliclyAccessible field from DescribeDBInstances, or the Publicly accessible value on the Connectivity & security tab in the console. Then check the subnet route tables and security group rules, because the flag alone doesn’t make it reachable.

Does changing PubliclyAccessible cause downtime?

The change is applied immediately regardless of ApplyImmediately. The instance keeps running, but clients connecting over the internet lose access as soon as the public address is removed.

Can one Aurora instance be public while the others are private?

Yes. Public access is a DB instance setting, so each writer and reader in a cluster has its own value. The script checks every instance separately for that reason.

Is a publicly accessible RDS instance always a security problem?

Not always, but it needs a reason. If the security group only allows a few fixed addresses, the risk is lower; a private endpoint reached through a VPN or bastion is still the safer default.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud