Find Load Balancers Without Access Logs (ALB, NLB, Classic)

Rows of network cables plugged into a rack-mounted switch with green status lights

Photo by Jan Antonin Kolar on Unsplash

ALB access logs not enabled is the default: every Application, Network and Classic Load Balancer starts with S3 access logging turned off, so requests leave no per-request record. To find them, list your load balancers, read the access_logs.s3.enabled attribute (or AccessLog.Enabled on Classic), and turn logging on with an S3 bucket in the same Region.

Access logs are the record you reach for after an incident: which client IP hit which path, what the target returned and how long it took. When ALB access logs are not enabled, that history simply doesn’t exist, and you can’t turn logging on retroactively. Nothing warns you either, because the load balancer works fine without them.

This example is for engineers auditing a Region before something goes wrong. It lists every load balancer, shows where each one logs, flags the ones that don’t, and with --apply --bucket turns access logs on. It pairs with the audit that finds load balancers still serving plain HTTP, which checks listeners rather than logging.

ALB access logs not enabled: what you lose, and what each type can log

The four load balancer types don’t log the same things, and one of them can’t write access logs at all. The script reads the right attribute for each.

Type Attribute the script reads What gets logged
Application (ALB) access_logs.s3.enabled, connection_logs.s3.enabled Every HTTP(S), HTTP/2, gRPC and WebSocket request, including requests that never reached a target; connection logs are a separate, optional record of client connections
Network (NLB) access_logs.s3.enabled TLS connections only. Without a TLS listener, no access logs are created
Classic (ELB) AccessLog.Enabled Requests, published every 5 or 60 minutes (EmitInterval, default 60)
Gateway (GWLB) None No access log attribute; VPC flow logs are the closest record

ALB and NLB log files land in the bucket every 5 minutes per load balancer node, on a best-effort basis, so treat them as a record of the nature of the traffic rather than a complete count. Elastic Load Balancing doesn’t charge for access logs; you pay for S3 storage, so give the bucket a lifecycle rule. The script to find S3 buckets without lifecycle rules catches log buckets that grow forever.

Both ALB and NLB can now also send logs through CloudWatch Logs delivery, configured on the load balancer’s Integrations tab. That path doesn’t set the access_logs.s3.* attributes, so a load balancer that only uses it shows as OFF here. Check the Integrations tab before you enable S3 logging on top.

Which bucket policy does the log bucket need?

ALB and Classic Load Balancers need an S3 bucket in the same Region with SSE-S3 encryption (the only option they support) and a bucket policy that lets the logdelivery.elasticloadbalancing.amazonaws.com service principal call s3:PutObject on bucket/prefix/AWSLogs/<account-id>/*. Regions available before August 2022 also accept a legacy policy naming a Region-specific Elastic Load Balancing account, but AWS recommends replacing it. NLB logs are written by delivery.logs.amazonaws.com instead, need s3:GetBucketAcl as well, and also accept SSE-KMS with a customer managed key. The exact policies are in the AWS guide to enabling access logs for your Application Load Balancer.

alb-log-bucket-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowElbLogDelivery",
      "Effect": "Allow",
      "Principal": { "Service": "logdelivery.elasticloadbalancing.amazonaws.com" },
      "Action": "s3:PutObject",
      "Resource": "arn:aws:s3:::acme-elb-logs/prod/AWSLogs/111122223333/*",
      "Condition": {
        "ArnLike": { "aws:SourceArn": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/*" }
      }
    }
  ]
}

Always keep the account ID in the resource path, and leave the prefix out of it if you don’t use one. The prefix itself must not contain the string AWSLogs.

What does the script do?

  1. Lists ALB, NLB and GWLBpaginateDescribeLoadBalancers from @aws-sdk/client-elastic-load-balancing-v2, then DescribeLoadBalancerAttributes for each one.
  2. Checks NLB listenersFor a Network Load Balancer without access logs, DescribeListeners looks for a TLS listener. Without one, turning logs on would produce nothing, so it’s reported but not fixed.
  3. Lists Classic Load BalancersThe separate @aws-sdk/client-elastic-load-balancing client reads the AccessLog attribute.
  4. Enables logging, if askedWith --apply --bucket (and optional --prefix and --names), it calls ModifyLoadBalancerAttributes on each load balancer that needs it. Elastic Load Balancing then validates the bucket and writes an ELBAccessLogTestFile to it.

Prerequisites

  • Node.js 18 or later with tsx, plus the two Elastic Load Balancing client packages.
  • A read-only profile for the report and a separate profile for --apply; the guide to AWS SDK v3 credential providers and named profiles shows how the script picks them up.
  • For --apply: a log bucket in the same Region with the bucket policy above already attached.

Which IAM permissions does it need?

The Describe* calls don’t support resource-level permissions, so they use "*". Only the second statement changes anything, and only with --apply.

load-balancer-access-logs-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadLoadBalancers",
      "Effect": "Allow",
      "Action": [
        "elasticloadbalancing:DescribeLoadBalancers",
        "elasticloadbalancing:DescribeLoadBalancerAttributes",
        "elasticloadbalancing:DescribeListeners"
      ],
      "Resource": "*"
    },
    {
      "Sid": "EnableAccessLogsOnlyWithApply",
      "Effect": "Allow",
      "Action": "elasticloadbalancing:ModifyLoadBalancerAttributes",
      "Resource": "arn:aws:elasticloadbalancing:us-east-1:111122223333:loadbalancer/*"
    }
  ]
}

If you change the script, the IAM policy generator for TypeScript code lists the actions the new version calls.

The script to find load balancers without access logs

find-load-balancers-without-access-logs.ts

// find-load-balancers-without-access-logs.ts
// Lists every Application, Network, Gateway and Classic Load Balancer in one Region and reports whether
// S3 access logs are enabled (plus connection logs for ALBs). With --apply --bucket it turns access logs on
// for the load balancers that are missing them.
// Usage:
//   npx tsx find-load-balancers-without-access-logs.ts [--region us-east-1]
//   npx tsx find-load-balancers-without-access-logs.ts --region us-east-1 --apply --bucket my-elb-logs [--prefix prod] [--names alb-a,alb-b]
import {
  ElasticLoadBalancingV2Client,
  DescribeLoadBalancerAttributesCommand,
  ModifyLoadBalancerAttributesCommand,
  paginateDescribeListeners,
  paginateDescribeLoadBalancers,
  type LoadBalancer,
} from "@aws-sdk/client-elastic-load-balancing-v2";
import {
  ElasticLoadBalancingClient,
  DescribeLoadBalancerAttributesCommand as DescribeClassicAttributesCommand,
  ModifyLoadBalancerAttributesCommand as ModifyClassicAttributesCommand,
  paginateDescribeLoadBalancers as paginateClassicLoadBalancers,
} from "@aws-sdk/client-elastic-load-balancing";

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 region = flag("--region") ?? process.env.AWS_REGION ?? "us-east-1";
const apply = args.includes("--apply");
const bucket = flag("--bucket");
const prefix = flag("--prefix") ?? "";
const only = new Set((flag("--names") ?? "").split(",").map((n) => n.trim()).filter(Boolean));

const elbv2 = new ElasticLoadBalancingV2Client({ region });
const classic = new ElasticLoadBalancingClient({ region });

interface Row {
  Name: string;
  Type: string;
  Scheme: string;
  AccessLogs: string;
  ConnectionLogs: string;
  Note: string;
}
interface Target {
  row: Row;
  fix?: () => Promise<void>;
}

const errText = (err: unknown): string => (err instanceof Error ? `${err.name}: ${err.message}` : String(err));
const where = (b?: string, p?: string): string => (b ? `s3://${b}${p ? `/${p}` : ""}` : "(no bucket)");

async function hasTlsListener(arn: string): Promise<boolean> {
  for await (const page of paginateDescribeListeners({ client: elbv2 }, { LoadBalancerArn: arn })) {
    if ((page.Listeners ?? []).some((l) => l.Protocol === "TLS")) return true;
  }
  return false;
}

async function checkV2(lb: LoadBalancer): Promise<Target> {
  const arn = lb.LoadBalancerArn ?? "";
  const row: Row = { Name: lb.LoadBalancerName ?? "?", Type: lb.Type ?? "?", Scheme: lb.Scheme ?? "?", AccessLogs: "n/a", ConnectionLogs: "n/a", Note: "" };
  if (lb.Type === "gateway") {
    row.Note = "Gateway Load Balancers have no access log attribute";
    return { row };
  }
  const out = await elbv2.send(new DescribeLoadBalancerAttributesCommand({ LoadBalancerArn: arn }));
  const attr = new Map((out.Attributes ?? []).map((a) => [a.Key ?? "", a.Value ?? ""]));
  const on = attr.get("access_logs.s3.enabled") === "true";
  row.AccessLogs = on ? where(attr.get("access_logs.s3.bucket"), attr.get("access_logs.s3.prefix")) : "OFF";
  if (lb.Type === "application") {
    row.ConnectionLogs = attr.get("connection_logs.s3.enabled") === "true" ? "on" : "off";
  }
  if (on) return { row };
  if (lb.Type === "network" && !(await hasTlsListener(arn))) {
    row.Note = "no TLS listener: NLB access logs would stay empty";
    return { row };
  }
  return {
    row,
    fix: async () => {
      await elbv2.send(new ModifyLoadBalancerAttributesCommand({
        LoadBalancerArn: arn,
        Attributes: [
          { Key: "access_logs.s3.enabled", Value: "true" },
          { Key: "access_logs.s3.bucket", Value: bucket },
          { Key: "access_logs.s3.prefix", Value: prefix },
        ],
      }));
    },
  };
}

async function checkClassic(name: string, scheme: string): Promise<Target> {
  const row: Row = { Name: name, Type: "classic", Scheme: scheme, AccessLogs: "OFF", ConnectionLogs: "n/a", Note: "" };
  const out = await classic.send(new DescribeClassicAttributesCommand({ LoadBalancerName: name }));
  const log = out.LoadBalancerAttributes?.AccessLog;
  if (log?.Enabled) {
    row.AccessLogs = `${where(log.S3BucketName, log.S3BucketPrefix)} every ${log.EmitInterval ?? 60} min`;
    return { row };
  }
  return {
    row,
    fix: async () => {
      await classic.send(new ModifyClassicAttributesCommand({
        LoadBalancerName: name,
        LoadBalancerAttributes: {
          AccessLog: { Enabled: true, S3BucketName: bucket, S3BucketPrefix: prefix || undefined, EmitInterval: 60 },
        },
      }));
    },
  };
}

async function main(): Promise<void> {
  if (apply && !bucket) throw new Error("--apply needs --bucket (an S3 bucket in the same Region with the ELB log delivery policy)");
  if (/AWSLogs/.test(prefix)) throw new Error("--prefix must not contain the string AWSLogs");

  const targets: Target[] = [];
  for await (const page of paginateDescribeLoadBalancers({ client: elbv2 }, {})) {
    for (const lb of page.LoadBalancers ?? []) targets.push(await checkV2(lb));
  }
  for await (const page of paginateClassicLoadBalancers({ client: classic }, {})) {
    for (const lb of page.LoadBalancerDescriptions ?? []) {
      if (lb.LoadBalancerName) targets.push(await checkClassic(lb.LoadBalancerName, lb.Scheme ?? "?"));
    }
  }

  console.table(targets.map((t) => t.row));
  const missing = targets.filter((t) => t.fix);
  console.log(`${targets.length} load balancers in ${region}; ${missing.length} without access logs that can be fixed.`);
  if (!apply) {
    console.log("Report only: nothing was modified. Add --apply --bucket <name> to enable access logs.");
    return;
  }
  for (const t of missing) {
    if (only.size && !only.has(t.row.Name)) continue;
    try {
      await t.fix?.();
      console.log(`Enabled access logs on ${t.row.Name} -> ${where(bucket, prefix)}`);
    } catch (err) {
      console.error(`Could not enable access logs on ${t.row.Name}: ${errText(err)}`);
      process.exitCode = 1;
    }
  }
}

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

Both clients paginate with the SDK’s built-in helpers; the guide to AWS SDK v3 paginators explains the for await pattern. The script was tested against mocked clients, as in the guide to mocking AWS SDK v3 calls in unit tests.

How do you run it?

Terminal

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

# Report only
AWS_PROFILE=readonly npx tsx find-load-balancers-without-access-logs.ts --region us-east-1

# Turn access logs on for two load balancers
AWS_PROFILE=network-admin npx tsx find-load-balancers-without-access-logs.ts --region us-east-1 \
  --apply --bucket acme-elb-logs --prefix prod --names web-alb,legacy-elb

Sample output

Output (with –apply)

┌─────────┬──────────────┬───────────────┬───────────────────┬───────────────────────────────┬────────────────┬───────────────────────────────────────────────────────┐
│ (index) │ Name         │ Type          │ Scheme            │ AccessLogs                    │ ConnectionLogs │ Note                                                  │
├─────────┼──────────────┼───────────────┼───────────────────┼───────────────────────────────┼────────────────┼───────────────────────────────────────────────────────┤
│ 0       │ 'web-alb'    │ 'application' │ 'internet-facing' │ 'OFF'                         │ 'off'          │ ''                                                    │
│ 1       │ 'logged-alb' │ 'application' │ 'internal'        │ 's3://acme-elb-logs/internal' │ 'on'           │ ''                                                    │
│ 2       │ 'tls-nlb'    │ 'network'     │ 'internet-facing' │ 'OFF'                         │ 'n/a'          │ ''                                                    │
│ 3       │ 'tcp-nlb'    │ 'network'     │ 'internal'        │ 'OFF'                         │ 'n/a'          │ 'no TLS listener: NLB access logs would stay empty'   │
│ 4       │ 'fw-gwlb'    │ 'gateway'     │ 'internal'        │ 'n/a'                         │ 'n/a'          │ 'Gateway Load Balancers have no access log attribute' │
│ 5       │ 'legacy-elb' │ 'classic'     │ 'internet-facing' │ 'OFF'                         │ 'n/a'          │ ''                                                    │
└─────────┴──────────────┴───────────────┴───────────────────┴───────────────────────────────┴────────────────┴───────────────────────────────────────────────────────┘
6 load balancers in us-east-1; 3 without access logs that can be fixed.
Enabled access logs on web-alb -> s3://acme-elb-logs/prod
Enabled access logs on legacy-elb -> s3://acme-elb-logs/prod

Names are illustrative. tls-nlb was left alone because it wasn’t in --names; it would need a bucket with the delivery.logs.amazonaws.com policy anyway. tcp-nlb has only a TCP listener, so NLB access logs would stay empty; for that traffic, the script to find VPCs without flow logs covers the network layer instead.

What should you check after turning logs on?

Before deleting a log bucket: turn access logs off first. If you delete the bucket while logging still points at it, someone else could create a bucket with the same name and the right policy, and Elastic Load Balancing could write your logs there.

Troubleshooting

  • Classic Load Balancer fails with “Access Denied for bucket: <bucket-name>. Please check S3bucket permission”. The bucket policy is missing or wrong for the Region, the resource ARN has a different bucket name or prefix, or the bucket uses an encryption option other than SSE-S3.
  • ALB or NLB fails with InvalidConfigurationRequest. The API rejected the configuration; with access logs, the bucket policy, Region or encryption is the usual suspect. Check that the bucket is in the load balancer’s Region and that the policy’s resource path includes AWSLogs/<account-id> after your prefix.
  • The run stops before reading anything. An AccessDenied on a Describe call means the profile lacks the read statement; the guide to troubleshooting AWS IAM AccessDenied errors walks through reading the message.
  • Only some Regions are covered. The script checks one Region per run. Loop over your Regions in the shell, or pass --region for each.

Ask ChatWithCloud instead

For a quick answer without installing anything, ask ChatWithCloud “Which load balancers in eu-west-1 don’t have access logs enabled, and where do the others log to?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the attributes it read. Because ChatWithCloud runs generated code without a confirmation step, use a read-only profile for audits like this and make the change with the script above.

Frequently asked questions

Why are ALB access logs not enabled by default?

Access logging is an optional feature that is off until you set access_logs.s3.enabled to true and give it a bucket. AWS doesn’t create a bucket for you, so every new load balancer starts without logs.

Do ALB access logs cost anything?

Elastic Load Balancing doesn’t charge for access logs or for the bandwidth used to deliver them. You pay S3 storage for the log files, which a lifecycle rule keeps in check.

Can I use an SSE-KMS encrypted bucket for ALB access logs?

No. For Application and Classic Load Balancers, SSE-S3 is the only supported encryption. Network Load Balancer access logs also accept SSE-KMS with a customer managed key, but not an AWS managed key.

Why is my NLB access log bucket empty?

Network Load Balancer access logs only record TLS connections. A load balancer with only TCP or UDP listeners produces no access logs even when the attribute is enabled.

Related guides

Ask your AWS account in plain English

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

npx chatwithcloud