Find Load Balancers Serving Plain HTTP Without a Redirect

Glowing blue fiber optic cable ends against a dark background

Photo by Compare Fibre on Unsplash

To find load balancers without HTTPS enforcement, list each load balancer’s listeners with DescribeListeners and, for every HTTP listener, check its rules with DescribeRules. Any rule, including the default one, whose action isn’t a redirect with Protocol: HTTPS serves plain HTTP. Then look up each HTTPS listener’s SslPolicy with DescribeSSLPolicies to catch TLS 1.0 and 1.1.

Most Application Load Balancers have a port 80 listener, and most of those should do exactly one thing: send a 301 to the HTTPS version of the same URL. The ones that forward to a target group instead are easy to miss, because the site works fine in a browser that already uses HTTPS. This example is for engineers who need to find load balancers without HTTPS enforcement across an account, including the listener rules that quietly serve one path over plain HTTP.

You’ll get a read-only TypeScript script for the AWS SDK for JavaScript v3 that also reports which HTTPS listeners still accept old TLS versions and which set an HSTS header. It’s one of our AWS SDK v3 security audit examples.

What should an HTTP listener do?

The OWASP Transport Layer Security Cheat Sheet recommends using TLS for all pages, not just sensitive ones, and having the server on port 80 answer with an immediate permanent redirect (HTTP 301) to HTTPS, backed by an HSTS header so browsers stop trying HTTP at all. On an Application Load Balancer that means:

  • The default action is a redirect. Type: redirect with RedirectConfig.Protocol set to HTTPS and StatusCode HTTP_301. A protocol of #{protocol} keeps the original scheme, so it doesn’t count.
  • No rule forwards traffic. Rules run before the default action. A rule for /api/* that forwards to a target group serves that path over HTTP even when the default action redirects.
  • The HTTPS listener has a modern security policy. TLS 1.0 and 1.1 were formally deprecated by RFC 8996 in March 2021.

The TLS point catches more listeners than you’d expect. The Elastic Load Balancing documentation lists the default policy for HTTPS listeners created in the console as ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09, but listeners created with the AWS CLI, CloudFormation or the AWS CDK without a policy get ELBSecurityPolicy-2016-08, which allows TLS 1.0, 1.1 and 1.2.

What does the script do?

  1. Lists Regions and load balancersDescribeRegions, then paginateDescribeLoadBalancers per Region. Gateway Load Balancers are skipped.
  2. Reads every listenerpaginateDescribeListeners per load balancer.
  3. Checks HTTP listener rulespaginateDescribeRules returns the default rule and every other rule; any that don’t redirect to HTTPS are reported.
  4. Checks TLS versionsOne DescribeSSLPolicies call per Region returns the SslProtocols each policy in use allows, so no policy list is hard-coded.
  5. Checks HSTSDescribeListenerAttributes on ALB HTTPS listeners looks for routing.http.response.strict_transport_security.header_value.

Network Load Balancer TCP listeners are ignored because the load balancer can’t see whether the traffic is HTTP; NLB TLS listeners are included in the TLS check.

Prerequisites

Which IAM permissions does it need?

Only describe actions, so the policy can’t change a listener even by mistake.

lb-https-audit-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadListeners",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeRegions",
        "elasticloadbalancing:DescribeLoadBalancers",
        "elasticloadbalancing:DescribeListeners",
        "elasticloadbalancing:DescribeRules",
        "elasticloadbalancing:DescribeSSLPolicies",
        "elasticloadbalancing:DescribeListenerAttributes"
      ],
      "Resource": "*"
    }
  ]
}

You can check a draft against your own code with the free IAM policy generator for TypeScript SDK scripts.

The full script to find load balancers without HTTPS

find-load-balancers-without-https.ts

// find-load-balancers-without-https.ts
// Finds Application Load Balancer HTTP listeners that serve content instead of redirecting to HTTPS
// (checking the default action and every listener rule), and HTTPS/TLS listeners whose security
// policy still allows TLS 1.0 or 1.1. For ALB HTTPS listeners it also shows whether an HSTS header is set.
// Read-only: it changes nothing.
// Usage: npx tsx find-load-balancers-without-https.ts [--regions=us-east-1,eu-west-1]
import { EC2Client, DescribeRegionsCommand } from "@aws-sdk/client-ec2";
import {
  ElasticLoadBalancingV2Client,
  DescribeListenerAttributesCommand,
  DescribeSSLPoliciesCommand,
  paginateDescribeListeners,
  paginateDescribeLoadBalancers,
  paginateDescribeRules,
  type Action,
  type Listener,
} from "@aws-sdk/client-elastic-load-balancing-v2";

const regionArg = process.argv.slice(2).find((a) => a.startsWith("--regions="))?.split("=")[1];

interface Row { Region: string; LoadBalancer: string; Scheme: string; Listener: string; Detail: string; Verdict: string }

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

// True when the action list ends in a redirect whose protocol is HTTPS.
const redirectsToHttps = (actions: Action[] = []): boolean =>
  actions.some((a) => a.Type === "redirect" && a.RedirectConfig?.Protocol === "HTTPS");
const describe = (actions: Action[] = []): string =>
  actions.map((a) => (a.Type === "redirect" ? `redirect(${a.RedirectConfig?.Protocol ?? "?"})` : a.Type ?? "?")).join("+");

async function scanRegion(region: string): Promise<Row[]> {
  const elb = new ElasticLoadBalancingV2Client({ region });
  const rows: Row[] = [];
  const tlsListeners: { lb: string; scheme: string; listener: Listener; alb: boolean }[] = [];

  for await (const page of paginateDescribeLoadBalancers({ client: elb }, {})) {
    for (const lb of page.LoadBalancers ?? []) {
      if (lb.Type === "gateway") continue; // Gateway Load Balancers have no HTTP or TLS listeners
      const name = lb.LoadBalancerName ?? "";
      const scheme = lb.Scheme ?? "";
      for await (const lp of paginateDescribeListeners({ client: elb }, { LoadBalancerArn: lb.LoadBalancerArn })) {
        for (const l of lp.Listeners ?? []) {
          const label = `${l.Protocol}:${l.Port}`;
          if (l.Protocol === "HTTPS" || l.Protocol === "TLS") {
            tlsListeners.push({ lb: name, scheme, listener: l, alb: lb.Type === "application" });
            continue;
          }
          if (l.Protocol !== "HTTP") continue; // TCP/UDP listeners: the load balancer can't see the protocol

          // Rules (including the default rule) decide what an HTTP request actually gets.
          const served: string[] = [];
          for await (const rp of paginateDescribeRules({ client: elb }, { ListenerArn: l.ListenerArn })) {
            for (const rule of rp.Rules ?? []) {
              if (redirectsToHttps(rule.Actions)) continue;
              served.push(rule.IsDefault ? `default:${describe(rule.Actions)}` : `rule ${rule.Priority}:${describe(rule.Actions)}`);
            }
          }
          const defaultServes = served.some((s) => s.startsWith("default:"));
          const verdict = !served.length ? "ok: redirects to HTTPS"
            : defaultServes ? "PLAIN HTTP: default action serves content"
            : `PLAIN HTTP on ${served.length} rule(s)`;
          rows.push({ Region: region, LoadBalancer: name, Scheme: scheme, Listener: label, Detail: served.join("; ") || "redirect(HTTPS)", Verdict: verdict });
        }
      }
    }
  }

  // Look up the TLS versions each security policy allows, once per policy name.
  const policyNames = [...new Set(tlsListeners.map((t) => t.listener.SslPolicy ?? "").filter(Boolean))];
  const protocols = new Map<string, string[]>();
  if (policyNames.length) {
    const res = await elb.send(new DescribeSSLPoliciesCommand({ Names: policyNames }));
    for (const p of res.SslPolicies ?? []) protocols.set(p.Name ?? "", p.SslProtocols ?? []);
  }

  for (const t of tlsListeners) {
    const policy = t.listener.SslPolicy ?? "?";
    const versions = protocols.get(policy) ?? [];
    const legacy = versions.filter((v) => v === "TLSv1" || v === "TLSv1.1");
    let hsts = "";
    if (t.alb && t.listener.Protocol === "HTTPS") {
      const attrs = await elb.send(new DescribeListenerAttributesCommand({ ListenerArn: t.listener.ListenerArn }));
      const value = attrs.Attributes?.find((a) => a.Key === "routing.http.response.strict_transport_security.header_value")?.Value;
      hsts = value ? "; HSTS set" : "; no HSTS";
    }
    rows.push({
      Region: region,
      LoadBalancer: t.lb,
      Scheme: t.scheme,
      Listener: `${t.listener.Protocol}:${t.listener.Port}`,
      Detail: `${policy} (${versions.join(", ") || "?"})${hsts}`,
      Verdict: legacy.length ? `OLD TLS: allows ${legacy.join(" and ")}` : "ok",
    });
  }
  return rows;
}

async function main(): Promise<void> {
  const rows: Row[] = [];
  for (const region of await listRegions()) {
    try {
      rows.push(...(await scanRegion(region)));
    } catch (err) {
      console.error(`${region}: skipped (${err instanceof Error ? `${err.name}: ${err.message}` : String(err)})`);
    }
  }
  console.table(rows);
  const plain = rows.filter((r) => r.Verdict.startsWith("PLAIN HTTP"));
  const oldTls = rows.filter((r) => r.Verdict.startsWith("OLD TLS"));
  console.log(`${plain.length} HTTP listener(s) serve plain HTTP (${plain.filter((r) => r.Scheme === "internet-facing").length} internet-facing); ${oldTls.length} TLS listener(s) allow TLS 1.0/1.1.`);
  console.log("Read-only: nothing was changed.");
}

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

How do you run it?

Terminal

npm install @aws-sdk/client-elastic-load-balancing-v2 @aws-sdk/client-ec2
npm install --save-dev tsx typescript

AWS_PROFILE=readonly npx tsx find-load-balancers-without-https.ts
AWS_PROFILE=readonly npx tsx find-load-balancers-without-https.ts --regions=us-east-1,eu-west-1

Sample output

Output

┌─────────┬─────────────┬────────────────┬───────────────────┬─────────────┬───────────────────────────────────────────────────────────────────────────┬─────────────────────────────────────────────┐
│ (index) │ Region      │ LoadBalancer   │ Scheme            │ Listener    │ Detail                                                                    │ Verdict                                     │
├─────────┼─────────────┼────────────────┼───────────────────┼─────────────┼───────────────────────────────────────────────────────────────────────────┼─────────────────────────────────────────────┤
│ 0       │ 'eu-west-1' │ 'shop-web'     │ 'internet-facing' │ 'HTTP:80'   │ 'redirect(HTTPS)'                                                         │ 'ok: redirects to HTTPS'                    │
│ 1       │ 'eu-west-1' │ 'admin-portal' │ 'internet-facing' │ 'HTTP:80'   │ 'default:forward'                                                         │ 'PLAIN HTTP: default action serves content' │
│ 2       │ 'us-east-1' │ 'api-public'   │ 'internet-facing' │ 'HTTP:80'   │ 'rule 10:forward'                                                         │ 'PLAIN HTTP on 1 rule(s)'                   │
│ 3       │ 'eu-west-1' │ 'shop-web'     │ 'internet-facing' │ 'HTTPS:443' │ 'ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09 (TLSv1.3, TLSv1.2); HSTS set' │ 'ok'                                        │
│ 4       │ 'us-east-1' │ 'api-public'   │ 'internet-facing' │ 'HTTPS:443' │ 'ELBSecurityPolicy-2016-08 (TLSv1, TLSv1.1, TLSv1.2); no HSTS'            │ 'OLD TLS: allows TLSv1 and TLSv1.1'         │
└─────────┴─────────────┴────────────────┴───────────────────┴─────────────┴───────────────────────────────────────────────────────────────────────────┴─────────────────────────────────────────────┘
2 HTTP listener(s) serve plain HTTP (2 internet-facing); 1 TLS listener(s) allow TLS 1.0/1.1.
Read-only: nothing was changed.

Names are illustrative. admin-portal is the serious one: its whole site answers over HTTP. api-public redirects by default, but rule 10 forwards some paths over HTTP, and its HTTPS listener was created with the CLI default policy.

How do you fix each finding?

Every fix is a single call per listener. Try it on a staging load balancer first, because clients that can only speak HTTP or TLS 1.0 will stop working.

Terminal

# 1. Make the HTTP listener's default action a permanent redirect to HTTPS
aws elbv2 modify-listener --listener-arn "$HTTP_LISTENER_ARN" \
  --default-actions '[{"Type":"redirect","RedirectConfig":{"Protocol":"HTTPS","Port":"443","StatusCode":"HTTP_301"}}]'

# 2. Move the HTTPS listener to the policy AWS recommends
aws elbv2 modify-listener --listener-arn "$HTTPS_LISTENER_ARN" \
  --ssl-policy ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09

# 3. Add an HSTS header on the HTTPS listener (Application Load Balancers)
aws elbv2 modify-listener-attributes --listener-arn "$HTTPS_LISTENER_ARN" \
  --attributes '[{"Key":"routing.http.response.strict_transport_security.header_value","Value":"max-age=31536000"}]'

For a rule that forwards over HTTP, change its action to the same redirect or delete it; there’s rarely a reason for a path to stay on plain HTTP. HSTS tells browsers to use only HTTPS for the host for max-age seconds, so start with a short value while you test and raise it once nothing breaks. Header modification is off by default and is set per listener.

Check the certificate and the rest of the path

A redirect is only useful if the HTTPS side works. The script to find expiring ACM certificates before they break HTTPS covers the certificates on these listeners. If CloudFront sits in front of the load balancer, the viewer side has its own TLS setting, which the script to check CloudFront minimum TLS version on every distribution reports, and the script to find CloudFront distributions without a WAF web ACL shows whether that edge also filters requests. And if the port 80 listener exists only for the redirect, the load balancer’s security group still needs port 80 open; the script to find security groups open to the internet on common ports helps you keep everything else closed.

Troubleshooting

  • A Region is reported as skipped with AccessDenied. Usually a missing describe action or an SCP. The guide to troubleshoot AWS IAM access denied errors shows how to find which.
  • Classic Load Balancers don’t appear. They use the older Elastic Load Balancing API (@aws-sdk/client-elastic-load-balancing), which this script doesn’t call.
  • A health-check path is flagged. A rule that returns a fixed response over HTTP is reported too. If it’s intentional, leave it; the verdict is a prompt to decide, not an error.
  • A load balancer has no listeners at all. It serves nothing and still costs money; the script to find unused load balancers with no healthy targets is the right next step.

Ask ChatWithCloud instead

You can also ask ChatWithCloud “Which load balancers have an HTTP listener that doesn’t redirect to HTTPS?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your AWS profile and explains the result, the same way it answers the questions in the guide to analyze your AWS security posture with an AI CLI. It works in one profile and Region per session and runs generated code without asking first, so connect ChatWithCloud to a read-only AWS profile and keep the listener changes to yourself. The ChatWithCloud security model describes what’s sent for processing.

Frequently asked questions

How do I redirect HTTP to HTTPS on an Application Load Balancer?

Set the HTTP listener’s default action to redirect with Protocol HTTPS, port 443 and status code HTTP_301, and make sure no other rule on that listener forwards traffic.

Can an ALB redirect HTTPS to HTTP?

No. Redirect actions can go from HTTP to HTTP, HTTP to HTTPS, or HTTPS to HTTPS, but not from HTTPS to HTTP.

Which ALB security policy allows TLS 1.0?

Among others, ELBSecurityPolicy-2016-08, the default for HTTPS listeners created outside the console, and ELBSecurityPolicy-TLS13-1-0-2021-06. DescribeSSLPolicies lists the protocols of any policy.

Does the script change any listener?

No. It only calls describe actions. The fixes above are separate commands you run yourself.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud