Find RDS Databases That Don’t Require SSL/TLS

A metal padlock resting on a bundle of blue network cables

Photo by Andy Kennedy on Unsplash

The RDS force SSL parameter is rds.force_ssl for PostgreSQL, Aurora PostgreSQL and SQL Server, and require_secure_transport for MySQL, MariaDB and Aurora MySQL. To find databases that still accept plain-text connections, read each database’s parameter group with DescribeDBParameters or DescribeDBClusterParameters and flag values that are off. Defaults differ by engine and version.

Encryption at rest gets audited everywhere. Encryption in transit often doesn’t, because it lives in a parameter group rather than on the database itself, and each engine names it differently. A PostgreSQL 14 database, a SQL Server instance and an Aurora MySQL 3 cluster all accept unencrypted connections out of the box, even when the storage underneath is encrypted.

This example gives you a report-only TypeScript script for the AWS SDK for JavaScript v3 that checks the rds.force_ssl parameter or its MySQL equivalent on every RDS instance and Aurora cluster in the Regions you choose. It pairs with the script to find unencrypted RDS instances and Aurora clusters, which covers the at-rest half.

How does the rds.force_ssl parameter work on each engine?

Every engine can negotiate TLS. The setting below decides whether it also refuses connections that don’t. Clients such as psql and JDBC default to sslmode=prefer, which AWS describes as trying TLS first and falling back to an unencrypted connection, so an unenforced server rarely looks broken.

Engine Parameter (group type) Default Reboot after change?
RDS for PostgreSQL rds.force_ssl (DB parameter group) 1 on version 15 and later, 0 on 14 and older Yes when you attach a new custom group to a running instance; check the apply type for later value changes
Aurora PostgreSQL rds.force_ssl (DB cluster parameter group) 1 on version 17 and later, 0 on 16 and older Check the parameter’s apply type
RDS for SQL Server rds.force_ssl (DB parameter group) 0 Yes, the parameter is static
RDS for MySQL require_secure_transport (DB parameter group) OFF No
RDS for MariaDB require_secure_transport (DB parameter group, 10.5 and later) OFF up to 11.4, ON from 11.8 No
Aurora MySQL require_secure_transport (DB cluster parameter group only) OFF on versions 2 and 3, ON on 8.4 No

Defaults are from the AWS RDS and Aurora user guides as of September 2026. Two traps follow from the table. A major version upgrade can flip the default: AWS warns that moving Aurora PostgreSQL from 16 to 17 turns rds.force_ssl on and can break applications that aren’t configured for TLS. And default parameter groups can’t be edited, so enforcing TLS on a database that uses one means creating a custom group, setting the value there and attaching it.

The OWASP Database Security Cheat Sheet puts the goal plainly: configure the database to only allow encrypted connections, and have clients verify the server certificate.

What does the script do?

  1. Reads clusterspaginateDescribeDBClusters lists Aurora and Multi-AZ DB clusters and their DBClusterParameterGroup.
  2. Reads standalone instancespaginateDescribeDBInstances lists instances that aren’t cluster members, with their parameter group and ParameterApplyStatus.
  3. Looks up the TLS parameterPages through DescribeDBClusterParameters or DescribeDBParameters until it finds rds.force_ssl or require_secure_transport, caching each group so shared groups are read once.
  4. Gives a verdictENFORCED, NOT ENFORCED, PENDING REBOOT (value on, but a static change hasn’t been applied), UNSET (the group reports no value) or NOT COVERED for Oracle and Db2, which configure TLS elsewhere.

Report only. The script never modifies a parameter group. A shared custom group can serve dozens of databases, and flipping it on can cut off every client that doesn’t use TLS, so the change should go through your normal release process.

Prerequisites

Which IAM permissions does it need?

rds-tls-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadDatabasesAndParameterGroups",
      "Effect": "Allow",
      "Action": [
        "rds:DescribeDBInstances",
        "rds:DescribeDBClusters",
        "rds:DescribeDBParameters",
        "rds:DescribeDBClusterParameters"
      ],
      "Resource": "*"
    }
  ]
}

All four are read-only describe actions. AWS’s ReadOnlyAccess managed policy already includes them. To derive a policy from your own variant of the script, paste it into the free IAM policy generator for TypeScript code, then compare the result with the guide on how to review a generated IAM policy for least privilege.

The script to find RDS databases without SSL enforcement

find-rds-without-ssl-enforcement.ts

// find-rds-without-ssl-enforcement.ts
// Reports RDS DB instances and DB clusters whose parameter group doesn't require SSL/TLS:
// rds.force_ssl (PostgreSQL, Aurora PostgreSQL, SQL Server) or require_secure_transport
// (MySQL, MariaDB, Aurora MySQL). Report only: it never changes a parameter group.
// Usage:
//   npx tsx find-rds-without-ssl-enforcement.ts [--regions us-east-1,eu-west-1] [--fail]
import {
  RDSClient,
  paginateDescribeDBClusterParameters,
  paginateDescribeDBClusters,
  paginateDescribeDBInstances,
  paginateDescribeDBParameters,
} from "@aws-sdk/client-rds";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1")
  .split(",")
  .map((s) => s.trim())
  .filter(Boolean);
const failOnFindings = args.includes("--fail"); // exit 1 when anything isn't enforced (for CI)

interface Row {
  Region: string;
  Kind: "cluster" | "instance";
  Name: string;
  Engine: string;
  Version: string;
  ParamGroup: string;
  Parameter: string;
  Value: string;
  Apply: string;
  Verdict: "ENFORCED" | "NOT ENFORCED" | "UNSET" | "NOT COVERED" | "PENDING REBOOT";
}

// Which parameter enforces TLS for each engine. Oracle and Db2 use other mechanisms.
function tlsParameter(engine: string): string | undefined {
  if (engine === "postgres" || engine === "aurora-postgresql" || engine.startsWith("sqlserver")) return "rds.force_ssl";
  if (engine === "mysql" || engine === "mariadb" || engine === "aurora-mysql") return "require_secure_transport";
  return undefined;
}

const isOn = (v: string) => ["1", "on", "true"].includes(v.trim().toLowerCase());

function verdict(param: string | undefined, value: string | undefined): Row["Verdict"] {
  if (!param) return "NOT COVERED";
  if (value === undefined) return "UNSET";
  return isOn(value) ? "ENFORCED" : "NOT ENFORCED";
}

async function scanRegion(region: string): Promise<Row[]> {
  const rds = new RDSClient({ region });
  const cache = new Map<string, string | undefined>(); // "cluster|group|param" -> value

  async function clusterGroupValue(group: string, param: string): Promise<string | undefined> {
    const key = `cluster|${group}|${param}`;
    if (!cache.has(key)) {
      let value: string | undefined;
      for await (const page of paginateDescribeDBClusterParameters({ client: rds }, { DBClusterParameterGroupName: group })) {
        const p = page.Parameters?.find((x) => x.ParameterName === param);
        if (p) {
          value = p.ParameterValue;
          break;
        }
      }
      cache.set(key, value);
    }
    return cache.get(key);
  }

  async function instanceGroupValue(group: string, param: string): Promise<string | undefined> {
    const key = `instance|${group}|${param}`;
    if (!cache.has(key)) {
      let value: string | undefined;
      for await (const page of paginateDescribeDBParameters({ client: rds }, { DBParameterGroupName: group })) {
        const p = page.Parameters?.find((x) => x.ParameterName === param);
        if (p) {
          value = p.ParameterValue;
          break;
        }
      }
      cache.set(key, value);
    }
    return cache.get(key);
  }

  const rows: Row[] = [];

  // Aurora and Multi-AZ DB clusters: the TLS parameter lives in the DB cluster parameter group.
  for await (const page of paginateDescribeDBClusters({ client: rds }, {})) {
    for (const c of page.DBClusters ?? []) {
      const engine = c.Engine ?? "?";
      const param = tlsParameter(engine);
      const group = c.DBClusterParameterGroup ?? "";
      const value = param && group ? await clusterGroupValue(group, param) : undefined;
      rows.push({
        Region: region,
        Kind: "cluster",
        Name: c.DBClusterIdentifier ?? "?",
        Engine: engine,
        Version: c.EngineVersion ?? "?",
        ParamGroup: group,
        Parameter: param ?? "-",
        Value: value ?? "(unset)",
        Apply: "",
        Verdict: verdict(param, value),
      });
    }
  }

  // Standalone DB instances. Cluster members are covered by the cluster row above.
  for await (const page of paginateDescribeDBInstances({ client: rds }, {})) {
    for (const db of page.DBInstances ?? []) {
      if (db.DBClusterIdentifier) continue;
      const engine = db.Engine ?? "?";
      const param = tlsParameter(engine);
      const pg = db.DBParameterGroups?.[0];
      const group = pg?.DBParameterGroupName ?? "";
      const value = param && group ? await instanceGroupValue(group, param) : undefined;
      rows.push({
        Region: region,
        Kind: "instance",
        Name: db.DBInstanceIdentifier ?? "?",
        Engine: engine,
        Version: db.EngineVersion ?? "?",
        ParamGroup: group,
        Parameter: param ?? "-",
        Value: value ?? "(unset)",
        Apply: pg?.ParameterApplyStatus ?? "",
        // A changed static parameter (SQL Server's rds.force_ssl) only takes effect after a reboot.
        Verdict: pg?.ParameterApplyStatus === "pending-reboot" && verdict(param, value) === "ENFORCED" ? "PENDING REBOOT" : verdict(param, value),
      });
    }
  }
  return rows;
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of regions) {
    try {
      rows.push(...(await scanRegion(region)));
    } catch (err) {
      console.error(`${region}: ${err instanceof Error ? `${err.name}: ${err.message}` : String(err)}`);
    }
  }
  const order: Row["Verdict"][] = ["NOT ENFORCED", "PENDING REBOOT", "UNSET", "NOT COVERED", "ENFORCED"];
  rows.sort((a, b) => order.indexOf(a.Verdict) - order.indexOf(b.Verdict));
  if (rows.length) console.table(rows);
  const bad = rows.filter((r) => r.Verdict === "NOT ENFORCED").length;
  const unset = rows.filter((r) => r.Verdict === "UNSET").length;
  console.log(`${rows.length} databases checked in ${regions.join(", ")}: ${bad} not enforced, ${unset} unset (check the engine default).`);
  console.log("Report only. Change the parameter in a custom parameter group; default groups can't be modified.");
  if (failOnFindings && bad > 0) process.exit(1);
}

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

The parameter lookups use the SDK’s built-in paginators because a parameter group holds several hundred parameters spread over many pages. The pattern is explained in the guide to paginate any AWS API with AWS SDK v3 paginators.

How do you run it?

Terminal

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

# Report for two Regions
AWS_PROFILE=readonly npx tsx find-rds-without-ssl-enforcement.ts --regions us-east-1,eu-west-1

# In CI: exit with code 1 when any database doesn't enforce TLS
AWS_PROFILE=readonly npx tsx find-rds-without-ssl-enforcement.ts --regions us-east-1 --fail

Sample output

Output

┌─────────┬─────────────┬────────────┬─────────────────┬─────────────────────┬───────────────────────────┬───────────────────────────────┬────────────────────────────┬───────────┬──────────────────┬──────────────────┐
│ (index) │ Region      │ Kind       │ Name            │ Engine              │ Version                   │ ParamGroup                    │ Parameter                  │ Value     │ Apply            │ Verdict          │
├─────────┼─────────────┼────────────┼─────────────────┼─────────────────────┼───────────────────────────┼───────────────────────────────┼────────────────────────────┼───────────┼──────────────────┼──────────────────┤
│ 0       │ 'us-east-1' │ 'cluster'  │ 'orders-aurora' │ 'aurora-postgresql' │ '16.4'                    │ 'default.aurora-postgresql16' │ 'rds.force_ssl'            │ '0'       │ ''               │ 'NOT ENFORCED'   │
│ 1       │ 'us-east-1' │ 'instance' │ 'erp-mssql'     │ 'sqlserver-se'      │ '15.00'                   │ 'mssql-tls'                   │ 'rds.force_ssl'            │ '1'       │ 'pending-reboot' │ 'PENDING REBOOT' │
│ 2       │ 'us-east-1' │ 'instance' │ 'billing-mysql' │ 'mysql'             │ '8.0.39'                  │ 'default.mysql8.0'            │ 'require_secure_transport' │ '(unset)' │ 'in-sync'        │ 'UNSET'          │
│ 3       │ 'us-east-1' │ 'instance' │ 'legacy-ora'    │ 'oracle-se2'        │ '19'                      │ 'default.oracle-se2-19'       │ '-'                        │ '(unset)' │ 'in-sync'        │ 'NOT COVERED'    │
│ 4       │ 'us-east-1' │ 'cluster'  │ 'events-aurora' │ 'aurora-mysql'      │ '8.0.mysql_aurora.3.08.0' │ 'aurora-mysql8-tls'           │ 'require_secure_transport' │ 'ON'      │ ''               │ 'ENFORCED'       │
│ 5       │ 'us-east-1' │ 'instance' │ 'reports-pg'    │ 'postgres'          │ '17.2'                    │ 'default.postgres17'          │ 'rds.force_ssl'            │ '1'       │ 'in-sync'        │ 'ENFORCED'       │
└─────────┴─────────────┴────────────┴─────────────────┴─────────────────────┴───────────────────────────┴───────────────────────────────┴────────────────────────────┴───────────┴──────────────────┴──────────────────┘
6 databases checked in us-east-1: 1 not enforced, 1 unset (check the engine default).
Report only. Change the parameter in a custom parameter group; default groups can't be modified.

Names are illustrative. orders-aurora runs Aurora PostgreSQL 16 on the default cluster group, so it accepts plain-text connections. erp-mssql has the right value but is waiting for a reboot, because SQL Server’s rds.force_ssl is static. billing-mysql reports no value in the default group, which on RDS for MySQL most likely leaves the documented default, OFF, in place. reports-pg is enforced only because PostgreSQL 17 turns it on by default.

How do you turn on TLS enforcement safely?

  1. Find clients that connect without TLSOn PostgreSQL, AWS documents a query that joins pg_stat_ssl with pg_stat_activity to show the ssl flag per client address and application. On MySQL, run SHOW SESSION STATUS LIKE 'Ssl_cipher' from each application’s connection. Fix those clients first.
  2. Create or pick a custom parameter groupCopy the default group for the engine family, then set rds.force_ssl to 1 or require_secure_transport to ON. Aurora MySQL only accepts the setting in a DB cluster parameter group.
  3. Attach it and plan the rebootAttaching a new group to a running instance needs a reboot before it applies, and SQL Server needs one after every change to the value. Re-run the script until the verdict reads ENFORCED.
  4. Pin it in codeSet the parameter in your Terraform, CloudFormation or CDK parameter group so the next deploy can’t revert it. The free CloudFormation YAML to Terraform converter helps if your groups live in older templates.

Once TLS is required, a PostgreSQL client that tries to connect without it gets FATAL: no pg_hba.conf entry for host "…", user "…", database "…", SSL off, and a MySQL client gets MySQL Error 3159 (HY000): Connections using insecure transport are prohibited while --require_secure_transport=ON. Both messages are quoted from the AWS guides. MySQL’s own reference for require_secure_transport explains which connection types count as secure.

The same rule should hold for every hop in front of the database: find load balancers serving plain HTTP without a redirect and find S3 buckets whose policy doesn’t require HTTPS.

Troubleshooting

  • A database shows UNSET. The parameter group returned the parameter without a value, which usually means the engine default for that version is in effect. Compare the engine version with the table above, or set the value explicitly in a custom group so the report is unambiguous.
  • DBParameterGroupNotFoundFault or DBClusterParameterGroupNotFoundFault. The group was deleted or renamed between the two calls. Re-run the script.
  • Enforced, but a client still connects in plain text. Check for an RDS Proxy in front of the database; AWS documents that TLS can be required or optional for connections to the proxy separately from the database. The script to find unused RDS proxies lists them.
  • AccessDenied. A service control policy may block describe calls in some Regions; the guide to troubleshoot AWS IAM access denied errors walks through it.

Ask ChatWithCloud instead

For a quick check, ask ChatWithCloud “Which RDS databases in us-east-1 don’t enforce SSL?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and explains the result; how ChatWithCloud turns questions into AWS SDK calls shows the loop. Generated code runs without a confirmation step, so connect ChatWithCloud with a read-only AWS profile and make parameter group changes through your usual pipeline.

Frequently asked questions

How do I check if SSL is enforced on an RDS PostgreSQL instance?

Find the instance’s parameter group with aws rds describe-db-instances, then look up rds.force_ssl in it with aws rds describe-db-parameters --db-parameter-group-name <group>. A value of 1 means connections without TLS are rejected.

Does changing rds.force_ssl require a reboot?

For SQL Server, yes, because the parameter is static. For RDS for PostgreSQL, a reboot is needed when you attach a new custom parameter group to a running instance. For MySQL and MariaDB, changing require_secure_transport doesn’t need a reboot.

Can I change rds.force_ssl in the default parameter group?

No. Default parameter groups can’t be modified. Create a custom group, change the value there and attach it to the database.

Is SSL enforced by default on new RDS databases?

Only on some versions: RDS for PostgreSQL 15 and later, Aurora PostgreSQL 17 and later, RDS for MariaDB 11.8 and later and Aurora MySQL 8.4. Older versions, RDS for MySQL and SQL Server default to off.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud