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.
{
"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?
- Lists ALB, NLB and GWLB
paginateDescribeLoadBalancersfrom@aws-sdk/client-elastic-load-balancing-v2, thenDescribeLoadBalancerAttributesfor each one. - Checks NLB listenersFor a Network Load Balancer without access logs,
DescribeListenerslooks for aTLSlistener. Without one, turning logs on would produce nothing, so it’s reported but not fixed. - Lists Classic Load BalancersThe separate
@aws-sdk/client-elastic-load-balancingclient reads theAccessLogattribute. - Enables logging, if askedWith
--apply --bucket(and optional--prefixand--names), it callsModifyLoadBalancerAttributeson each load balancer that needs it. Elastic Load Balancing then validates the bucket and writes anELBAccessLogTestFileto 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.
{
"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
// 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?
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
┌─────────┬──────────────┬───────────────┬───────────────────┬───────────────────────────────┬────────────────┬───────────────────────────────────────────────────────┐
│ (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?
- The test file arrived. Look for
AWSLogs/<account-id>/ELBAccessLogTestFileunder your prefix. Real log files follow within minutes of the first traffic. - The bucket isn’t public and has retention. Access logs contain client IPs, paths and query strings. The audit to categorize S3 buckets by public or private access confirms the bucket is closed.
- You can query them. Logs are gzip files; Athena reads them in place, and the guide to run an Athena query with AWS SDK v3 shows how to script it.
- The load balancer is still needed. Logging an abandoned load balancer only adds cost. The script to find unused load balancers with no targets or traffic is the better fix for those.
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 includesAWSLogs/<account-id>after your prefix. - The run stops before reading anything. An
AccessDeniedon aDescribecall 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
--regionfor 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