Initializing Enclave...

How to Fix Lambda Permission Denied on Custom CloudWatch Log Groups (AWSLambdaBasicExecutionRole Not Sufficient)

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH (silent log loss, blind production Lambda) | Time to Fix: 10 mins


TL;DR

  • What broke: AWSLambdaBasicExecutionRole hard-codes permissions only for the auto-generated log group /aws/lambda/<function-name>. Your Lambda writing to any other group gets an implicit AccessDenied — silently, with zero retries.
  • How to fix it: Detach the managed policy. Attach a custom inline or managed policy granting logs:CreateLogGroup, logs:CreateLogStream, and logs:PutLogEvents scoped to the exact ARN of your custom log group.
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your role definition and get a least-privilege policy back without sending your ARNs to a third-party server.

The Incident (What Does the Error Mean?)

You'll see one of the following in your Lambda invocation response or CloudTrail:

ERROR: AccessDeniedException: User: arn:aws:sts::123456789012:assumed-role/my-lambda-role/my-function
is not authorized to perform: logs:PutLogEvents
on resource: arn:aws:logs:us-east-1:123456789012:log-group:/my-app/custom-logs:log-stream:*
because no identity-based policy allows the logs:PutLogEvents action

Or, worse — you see nothing at all. The Lambda runtime swallows the CloudWatch SDK error when using the built-in logging integration, your function returns 200 OK, and your logs simply never arrive. This is the production-outage variant: the system looks healthy, but you are flying blind.

Immediate consequence: Every log line emitted by your function — errors, traces, audit events — is dropped on the floor. If this Lambda is processing payments, authentication events, or compliance-critical workflows, you have a regulatory gap opening in real time.


The Attack Vector / Blast Radius

Why this is more dangerous than a simple misconfiguration:

  1. Silent failure = delayed detection. Unlike an HTTP 403 that surfaces immediately, IAM denials on CloudWatch PutLogEvents do not throw a fatal Lambda error by default. Your monitoring dashboards stay green. Mean-time-to-detect (MTTD) on this class of failure is measured in hours or days.

  2. Audit trail destruction. If your custom log group is your security audit sink — storing who accessed what, when — a permissions gap here is functionally equivalent to disabling your SIEM feed. An attacker who has already compromised the Lambda execution role could deliberately misconfigure this to suppress evidence of lateral movement.

  3. Over-correction risk is equally dangerous. The knee-jerk fix is slapping Resource: "*" on a logs:* action. That grants the Lambda role the ability to write to — and read from — every log group in the account, including those belonging to other services, security tools, and VPC Flow Logs. A compromised Lambda can now exfiltrate logs from your WAF, your GuardDuty findings stream, and your RDS audit logs.

  4. Cascading failure in log-aggregation pipelines. If downstream systems (Kinesis Firehose, OpenSearch, Splunk forwarders) are subscribed to the custom log group expecting a continuous stream, a gap in PutLogEvents causes buffer overflows and missed-event alerts in those pipelines.


How to Fix It (The Solution)

Basic Fix — Inline Policy on the Execution Role

This is the minimum viable fix. Scope Resource to the exact log group ARN plus the log stream wildcard beneath it.

# IAM Role Policy (JSON)
{
  "Version": "2012-10-17",
  "Statement": [
-   {
-     "Effect": "Allow",
-     "Action": [
-       "logs:CreateLogGroup",
-       "logs:CreateLogStream",
-       "logs:PutLogEvents"
-     ],
-     "Resource": "arn:aws:logs:*:*:*"
-   }
+   {
+     "Effect": "Allow",
+     "Action": [
+       "logs:CreateLogGroup"
+     ],
+     "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/my-app/custom-logs"
+   },
+   {
+     "Effect": "Allow",
+     "Action": [
+       "logs:CreateLogStream",
+       "logs:PutLogEvents"
+     ],
+     "Resource": "arn:aws:logs:us-east-1:123456789012:log-group:/my-app/custom-logs:log-stream:*"
+   }
  ]
}

Key detail: logs:CreateLogGroup targets the log group ARN directly (no :log-stream:* suffix). logs:CreateLogStream and logs:PutLogEvents must target the log stream ARN pattern under that group.


Enterprise Best Practice — Terraform with Least-Privilege + Condition Keys

In a multi-account, multi-region setup, hardcoding ARNs is a maintenance nightmare. Use aws_iam_policy_document with interpolated locals and add a StringEquals condition on aws:RequestedRegion to prevent the role from being used cross-region if exfiltrated.

# terraform/iam.tf

- resource "aws_iam_role_policy_attachment" "lambda_basic" {
-   role       = aws_iam_role.lambda_exec.name
-   policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
- }

+ data "aws_iam_policy_document" "lambda_custom_logs" {
+   statement {
+     sid    = "AllowLogGroupCreation"
+     effect = "Allow"
+     actions = ["logs:CreateLogGroup"]
+     resources = [
+       "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:${var.custom_log_group_name}"
+     ]
+     condition {
+       test     = "StringEquals"
+       variable = "aws:RequestedRegion"
+       values   = [var.aws_region]
+     }
+   }
+
+   statement {
+     sid    = "AllowLogStreamAndPut"
+     effect = "Allow"
+     actions = [
+       "logs:CreateLogStream",
+       "logs:PutLogEvents"
+     ]
+     resources = [
+       "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:${var.custom_log_group_name}:log-stream:*"
+     ]
+     condition {
+       test     = "StringEquals"
+       variable = "aws:RequestedRegion"
+       values   = [var.aws_region]
+     }
+   }
+ }
+
+ resource "aws_iam_policy" "lambda_custom_logs" {
+   name   = "lambda-${var.function_name}-custom-logs"
+   policy = data.aws_iam_policy_document.lambda_custom_logs.json
+ }
+
+ resource "aws_iam_role_policy_attachment" "lambda_custom_logs" {
+   role       = aws_iam_role.lambda_exec.name
+   policy_arn = aws_iam_policy.lambda_custom_logs.arn
+ }

Do not leave AWSLambdaBasicExecutionRole attached alongside this. It will still grant access to /aws/lambda/<function-name> which is now an unused surface area. Remove it.


💡 Tired of pasting proprietary configs into ChatGPT? Generic AI tools log your company's ARNs, DB strings, and private keys. StackEngine is a zero-backend, pure Client-Side WASM utility. Drop your failing config into the sandbox above. We redact your secrets locally in the browser and auto-generate the refactored code using your own API key.


Prevention in CI/CD

This class of error is 100% preventable at the PR stage. Wire in at least two of the following:

1. Checkov — catches wildcard Resource on logs actions

# .checkov.yml
checks:
  - CKV_AWS_111   # Ensure IAM policies do not allow write access without constraints
  - CKV_AWS_290   # Ensure Lambda has a least-privilege execution role

Run in CI: checkov -d ./terraform --framework terraform --check CKV_AWS_111,CKV_AWS_290

2. OPA / Conftest policy — enforce no Resource: * on logs namespace

# policies/lambda_logs.rego
package lambda.iam

deny[msg] {
  stmt := input.Statement[_]
  stmt.Effect == "Allow"
  action := stmt.Action[_]
  startswith(action, "logs:")
  stmt.Resource == "*"
  msg := sprintf("IAM statement grants logs:%v on Resource '*'. Scope to explicit log group ARN.", [action])
}

3. AWS Config Rule — continuous compliance post-deploy

Enable the managed rule lambda-function-public-access-prohibited and pair it with a custom Config rule using the IAM_POLICY_NO_STATEMENTS_WITH_ADMIN_ACCESS template modified for the logs:* action space. Alert to your Security Hub findings stream.

4. aws iam simulate-principal-policy in your pipeline

# Pre-deploy smoke test — fails the pipeline if the custom log group is not reachable
aws iam simulate-principal-policy \
  --policy-source-arn arn:aws:iam::123456789012:role/my-lambda-role \
  --action-names logs:PutLogEvents \
  --resource-arns "arn:aws:logs:us-east-1:123456789012:log-group:/my-app/custom-logs:log-stream:*" \
  --query 'EvaluationResults[0].EvalDecision' \
  --output text
# Expected output: allowed
# If output is 'implicitDeny' or 'explicitDeny' — fail the build.

This runs in under 2 seconds and catches the permission gap before the Lambda ever touches production.

Related Diagnostics

"Part of the Security Utility Matrix."

View all 140 Security Tools →