Node.js Serverless on AWS Lambda · Running Node.js Serverless in Production

Least Privilege and Lambda Security

How a vulnerable dependency in one function could delete every table in the account, and how to scope permissions.

Scenario

Example app: to move fast, all functions share one execution role with dynamodb:*, s3:* and secretsmanager:GetSecretValue on *. Several functions also run inside a VPC "for security".

What you see in production

A security audit after a dependency vulnerability (remote code execution in a parsing library) shows that the compromised image-resizing function could have read every secret and deleted every DynamoDB table and S3 bucket in the account. Separately, VPC-attached functions cannot reach third-party APIs because there is no NAT or endpoint.

The naive code

{
  "Effect": "Allow",
  "Action": ["dynamodb:*", "s3:*", "secretsmanager:GetSecretValue"],
  "Resource": "*"
}

Reproduce it

A tiny policy evaluator shows what the image resizer can do with each policy:

What a compromised function could doTry it yourself
const match = (pattern, value) => new RegExp("^" + pattern.split("*").map((p) => p.replace(/[.?+^$()[\]{}|\\]/g, "\\$&")).join(".*") + "$").test(value);
const allowed = (policy, action, resource) => policy.some((st) => st.Action.some((a) => match(a, action)) && st.Resource.some((r) => match(r, resource)));

const shared = [{ Action: ["dynamodb:*", "s3:*", "secretsmanager:GetSecretValue"], Resource: ["*"] }];
const scoped = [
  { Action: ["s3:GetObject"], Resource: ["arn:aws:s3:::uploads-bucket/incoming/*"] },
  { Action: ["s3:PutObject"], Resource: ["arn:aws:s3:::thumbnails-bucket/*"] },
];
const attempts = [
  ["s3:GetObject", "arn:aws:s3:::uploads-bucket/incoming/cat.jpg"],
  ["s3:PutObject", "arn:aws:s3:::thumbnails-bucket/cat.jpg"],
  ["dynamodb:DeleteTable", "arn:aws:dynamodb:eu-west-1:111122223333:table/orders"],
  ["secretsmanager:GetSecretValue", "arn:aws:secretsmanager:eu-west-1:111122223333:secret:prod/payments"],
];
for (const [action, resource] of attempts) {
  console.log(action.padEnd(31), "shared:", String(allowed(shared, action, resource)).padEnd(5), "scoped:", allowed(scoped, action, resource));
}

Output

s3:GetObject                    shared: true  scoped: true
s3:PutObject                    shared: true  scoped: true
dynamodb:DeleteTable            shared: true  scoped: false
secretsmanager:GetSecretValue   shared: true  scoped: false

Root cause

Execution roles define what code in the function can do with AWS credentials it receives automatically. A broad, shared role means any code execution bug in any function (including in dependencies) gives an attacker the union of all permissions. VPC placement does not reduce IAM permissions; it only changes network paths, and adds the need for NAT gateways or VPC endpoints.

The fix

  • One role per function with only the actions and resources it needs (SAM policy templates such as DynamoDBReadPolicy or CDK table.grantReadData(fn) generate scoped statements). Use IAM Access Analyzer to generate and validate policies from activity.
  • Resource-based policies limit who can invoke the function (specific API, source ARN and source account).
  • Keep secrets in Secrets Manager with per-secret access; never in code or plain environment variables.
  • Attach functions to a VPC only when they need private resources; then add VPC endpoints for AWS services and NAT only where internet egress is required.
  • Supply chain: pin and audit dependencies (npm audit, Dependabot or Renovate), sign code with AWS Signer where required, and scan with Amazon Inspector.

Trade-offs

  • Per-function roles mean more IAM to manage; generate them from IaC rather than writing JSON by hand.
  • VPC endpoints have hourly costs; NAT gateways add data-processing charges.
  • Very strict policies break when code changes; test permissions in a staging account.

Detect it in production

  • IAM Access Analyzer findings for unused permissions and external access.
  • CloudTrail alerts on sensitive actions (DeleteTable, PutBucketPolicy) by function roles.
  • Dependency vulnerability reports per function, and GuardDuty Lambda protection findings.

Key points

  • One least-privilege role per function, scoped to specific resources.
  • Restrict who can invoke functions with resource-based policies.
  • VPC only when needed, with endpoints; it does not replace IAM.
  • Treat dependencies as part of your attack surface.

Further reading: Defining Lambda function permissions with an execution role · Security in AWS Lambda

Practice questions

Why is a single shared execution role for all functions risky?

Answer

A code execution vulnerability in any function, including in a dependency, gives an attacker every permission in the shared role. Separate least-privilege roles limit the blast radius to what each function needs.

Does putting a Lambda function in a VPC make it more secure?

Answer

Only in a network sense: it can reach private resources and can be restricted by security groups. It does not reduce IAM permissions, and it requires VPC endpoints or NAT for AWS services and internet access. Use a VPC when the function needs private resources.

How do you restrict who can invoke a function?

Answer

With its resource-based policy: grant lambda:InvokeFunction only to specific principals such as one API Gateway API or an S3 bucket, using SourceArn and SourceAccount conditions to prevent confused-deputy access.

Open in the app to mark lessons complete, take notes and track your progress.