Initializing Enclave...

Fixing Lambda AccessDenied on cloudwatch:PutMetricData: The Missing aws:SourceAccount Condition Explained

Threat/Impact Level: HIGH | Exploitability/Downtime Risk: HIGH | Time to Fix: 10 mins


TL;DR

  • What broke: Your Lambda execution role's resource-based or identity-based policy permits cloudwatch:PutMetricData but a Service Control Policy (SCP) or an explicit Deny with an aws:SourceAccount condition is blocking the call because the originating principal context doesn't match — or the trust policy on a cross-service role is missing the aws:SourceAccount condition entirely, opening a confused-deputy vector that AWS now flags.
  • How to fix it: Add aws:SourceAccount (and ideally aws:SourceArn) as StringEquals condition keys to the IAM policy statement that grants cloudwatch:PutMetricData, scoped to your exact AWS account ID and Lambda function ARN.
  • Fast path: Use our Client-Side Sandbox below to auto-refactor this — paste your failing policy JSON and get the corrected version without sending your ARNs to a third-party server.

The Incident (What Does the Error Mean?)

You will see this in CloudTrail or Lambda execution logs:

{
  "errorCode": "AccessDenied",
  "errorMessage": "User: arn:aws:sts::123456789012:assumed-role/my-lambda-exec-role/my-function
                   is not authorized to perform: cloudwatch:PutMetricData
                   with an explicit deny in a service control policy",
  "eventSource": "monitoring.amazonaws.com",
  "eventName": "PutMetricData"
}

Immediate consequence: Every custom metric emission from your Lambda silently fails. If you depend on these metrics for alarms (autoscaling triggers, error-rate alerts, SLA dashboards), those alarms go stale. You are now flying blind in production.

The subtler variant appears when a resource-based policy on a CloudWatch metric namespace or a cross-account role assumption requires aws:SourceAccount to be asserted by the calling service, and your Lambda's execution role never sets it. AWS Lambda does forward aws:SourceAccount automatically in some service-to-service flows, but if your policy has an explicit Deny conditioned on the absence of this key, the call is dead on arrival.


The Attack Vector / Blast Radius

This is a confused-deputy problem. Without aws:SourceAccount scoping:

  1. Any AWS service (or compromised Lambda in a different account that has assumed a shared role) can call cloudwatch:PutMetricData and write arbitrary metric data into your namespace.
  2. Poisoned metrics can suppress or trigger CloudWatch Alarms — an attacker who can write fake low-error-rate data into your namespace can mask an ongoing breach from your on-call team.
  3. In multi-tenant SaaS architectures using shared execution roles, a tenant's Lambda could pollute another tenant's metric namespace, corrupting billing or SLA calculations.
  4. If your SCPs enforce aws:SourceAccount as a preventive guardrail (common in AWS Control Tower landing zones), any role missing this condition will be denied — causing widespread Lambda metric blackouts across an entire OU if the role is shared.

Blast radius: All Lambda functions sharing this execution role lose CloudWatch custom metric capability simultaneously. Downstream alarms, dashboards, and autoscaling policies based on those metrics become unreliable.


How to Fix It

Basic Fix — Add the Condition to the Execution Role Policy

Locate the IAM policy attached to your Lambda execution role that grants cloudwatch:PutMetricData and add the aws:SourceAccount condition.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCWMetrics",
      "Effect": "Allow",
      "Action": "cloudwatch:PutMetricData",
-     "Resource": "*"
+     "Resource": "*",
+     "Condition": {
+       "StringEquals": {
+         "aws:SourceAccount": "123456789012"
+       }
+     }
    }
  ]
}

⚠️ cloudwatch:PutMetricData does not support resource-level restrictions (it requires "Resource": "*"), so the condition key is your only scoping mechanism.


Enterprise Best Practice — Pin Both SourceAccount and SourceArn

For production workloads, lock it down to the specific Lambda function ARN using aws:SourceArn alongside aws:SourceAccount. This prevents any other Lambda in your account from abusing the same policy statement.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowCWMetricsScopedToLambda",
      "Effect": "Allow",
      "Action": "cloudwatch:PutMetricData",
-     "Resource": "*"
+     "Resource": "*",
+     "Condition": {
+       "StringEquals": {
+         "aws:SourceAccount": "123456789012",
+         "aws:SourceArn": "arn:aws:lambda:us-east-1:123456789012:function:my-function"
+       }
+     }
    }
  ]
}

If you are using Terraform:

data "aws_iam_policy_document" "lambda_cw_metrics" {
  statement {
    sid     = "AllowCWMetricsScopedToLambda"
    effect  = "Allow"
    actions = ["cloudwatch:PutMetricData"]
    resources = ["*"]

+   condition {
+     test     = "StringEquals"
+     variable = "aws:SourceAccount"
+     values   = [var.aws_account_id]
+   }
+
+   condition {
+     test     = "StringEquals"
+     variable = "aws:SourceArn"
+     values   = [aws_lambda_function.this.arn]
+   }
  }
}

💡 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

1. Checkov — Block merge if SourceAccount is missing:

Add a custom Checkov policy (CKV_CUSTOM_IAM_CW_SOURCE_ACCOUNT) that fails any Terraform plan where cloudwatch:PutMetricData is allowed without an aws:SourceAccount condition.

# .checkov.yml
checks:
  - id: CKV_CUSTOM_IAM_CW_SOURCE_ACCOUNT
    name: "Ensure cloudwatch:PutMetricData has aws:SourceAccount condition"
    resource: aws_iam_policy_document

2. OPA/Conftest policy for Terraform plans:

deny[msg] {
  stmt := input.resource_changes[_].change.after.statement[_]
  stmt.actions[_] == "cloudwatch:PutMetricData"
  not stmt.condition[_].variable == "aws:SourceAccount"
  msg := "cloudwatch:PutMetricData statement missing aws:SourceAccount condition"
}

3. AWS Config Rule: Enable iam-policy-no-statements-with-admin-access and pair it with a custom Config rule using AWS Lambda that inspects all managed policies for cloudwatch:PutMetricData statements lacking condition keys. Trigger on PutRolePolicy and PutUserPolicy CloudTrail events.

4. SCPs as a backstop (AWS Control Tower): In your Control Tower landing zone, enforce a preventive SCP that explicitly denies cloudwatch:PutMetricData unless aws:SourceAccount matches the account ID. This catches any role that slips through code review:

{
  "Effect": "Deny",
  "Action": "cloudwatch:PutMetricData",
  "Resource": "*",
  "Condition": {
    "StringNotEquals": {
      "aws:SourceAccount": "${aws:PrincipalAccount}"
    }
  }
}

This SCP pattern ensures that even if a developer deploys a Lambda execution role without the condition, the SCP catches it at the API layer before it becomes a confused-deputy liability.

Related Diagnostics

"Part of the Security Utility Matrix."

View all 140 Security Tools →