Siddhant Deval
Siddhant Deval
backend19 min read

Serverless Security: IAM Least Privilege, VPC Networking & Secrets Governance

A shared Lambda execution role with broad policies is the serverless equivalent of running all processes as root — every function in the account inherits the same blast radius. This article covers per-function IAM least privilege, resource-level ARN scoping, condition keys for additional constraints, VPC networking for Lambda (Hyperplane ENI model, no cold-start cost post-2019), VPC Endpoints eliminating NAT Gateway data charges, and Secrets Manager rotation architecture.

Serverless Security: IAM Least Privilege, VPC Networking & Secrets Governance

Every AWS primitive is a tradeoff surface, not a feature toggle. The IAM anti-pattern that every AWS security review flags first: a single Lambda execution role attached to every function in the service, with dynamodb:* on * and s3:* on *. The reasoning is understandable — convenience and the assumption that "it's all in the same account anyway." The risk is that IAM permissions are an envelope of impact when credentials are compromised. If every function shares the same role, a single compromised function can read every DynamoDB table, write to every S3 bucket, and invoke every other Lambda in the account. Least privilege is not an audit checkbox — it is blast radius engineering.


1. Per-Function IAM Least Privilege

The correct IAM model for serverless is one role per function, scoped to the exact resources that function accesses.

1.1 Resource-Level ARN Scoping

TYPESCRIPT
// CDK: per-function execution role with minimal permissions
import { Function, Runtime, Code } from 'aws-cdk-lib/aws-lambda'
import { Role, ServicePrincipal, PolicyStatement, Effect } from 'aws-cdk-lib/aws-iam'
import { Table } from 'aws-cdk-lib/aws-dynamodb'
import { Queue } from 'aws-cdk-lib/aws-sqs'

const orderTable = Table.fromTableName(this, 'OrderTable', 'Orders')
const orderQueue = Queue.fromQueueArn(this, 'OrderQueue', orderQueueArn)

// ❌ Shared role: every function gets DynamoDB full access on all tables
const sharedRole = new Role(this, 'SharedLambdaRole', {
  assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
})
sharedRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: ['dynamodb:*'], // WAY too broad
  resources: ['*'],        // All tables in the account
}))

// ✅ Per-function role: OrderProcessor can ONLY GetItem/PutItem on the Orders table
const orderProcessorRole = new Role(this, 'OrderProcessorRole', {
  assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
  managedPolicies: [
    ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')
  ]
})

orderProcessorRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: [
    'dynamodb:GetItem',
    'dynamodb:PutItem',
    'dynamodb:UpdateItem',
    // NOT: DeleteItem, Scan, Query, BatchWrite, CreateTable, DeleteTable
  ],
  resources: [
    orderTable.tableArn,              // Only the Orders table
    `${orderTable.tableArn}/index/*`, // Include GSIs if Query is needed on them
  ]
}))

orderProcessorRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: ['sqs:ReceiveMessage', 'sqs:DeleteMessage', 'sqs:GetQueueAttributes'],
  resources: [orderQueue.queueArn],   // Only the Order queue
}))

const orderProcessorFn = new Function(this, 'OrderProcessor', {
  runtime: Runtime.NODEJS_20_X,
  handler: 'handler.main',
  code: Code.fromAsset('dist/order-processor'),
  role: orderProcessorRole, // Dedicated role, not shared
})

1.2 Condition Keys — A Second Constraint Layer

Condition keys add additional constraints beyond action + resource. Useful for:

TYPESCRIPT
// Condition key: restrict DynamoDB access to specific table partition key prefixes
// Prevents an order-processing Lambda from reading user profile data
orderProcessorRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: ['dynamodb:GetItem', 'dynamodb:PutItem'],
  resources: [orderTable.tableArn],
  conditions: {
    'ForAllValues:StringLike': {
      'dynamodb:LeadingKeys': ['ORDER#*'], // Only partition keys starting with ORDER#
    }
  }
}))

// Condition key: restrict S3 operations to a specific prefix
processingRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: ['s3:GetObject'],
  resources: [`${bucket.bucketArn}/invoices/*`], // Only the invoices/ prefix
}))

// Condition key: allow Secrets Manager access only from VPC (prevents exfiltration from outside VPC)
secretsRole.addToPolicy(new PolicyStatement({
  effect: Effect.ALLOW,
  actions: ['secretsmanager:GetSecretValue'],
  resources: [secret.secretArn],
  conditions: {
    StringEquals: {
      'aws:SourceVpc': vpcId, // Only from within the VPC
    }
  }
}))
Crucial Requirement

The dynamodb:LeadingKeys condition requires ForAllValues:StringLike (not StringLike) because the request may include multiple partition key values in a BatchGetItem. ForAllValues ensures the condition applies to every key in the batch. Using StringLike alone only evaluates the first key and allows arbitrary keys in batch operations.


2. VPC Networking for Lambda

Lambda can run inside a VPC (for RDS, ElastiCache, or private service access) or outside a VPC (for DynamoDB, S3, SQS, and public internet access). Post-2019, VPC attachment no longer adds cold-start latency — the Hyperplane ENI model pre-allocates ENIs and shares them across Lambda functions in the same VPC/subnet/security group configuration.

2.1 VPC Attachment — When to Use It

TYPESCRIPT
// CDK: Lambda inside VPC for RDS access
import { Function } from 'aws-cdk-lib/aws-lambda'
import { Vpc, SubnetType, SecurityGroup } from 'aws-cdk-lib/aws-ec2'

const vpc = Vpc.fromLookup(this, 'Vpc', { vpcId: 'vpc-abc123' })

const lambdaSg = new SecurityGroup(this, 'LambdaSG', { vpc, allowAllOutbound: false })

const dbFn = new Function(this, 'DbFunction', {
  runtime: Runtime.NODEJS_20_X,
  handler: 'handler.main',
  code: Code.fromAsset('dist'),
  vpc,
  vpcSubnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS }, // Private subnets
  securityGroups: [lambdaSg],
})

// Allow Lambda SG to connect to RDS SG on port 5432
lambdaSg.addEgressRule(rdsSg, Port.tcp(5432))
rdsSg.addIngressRule(lambdaSg, Port.tcp(5432))
Scenario VPC needed? Reasoning
Lambda → DynamoDB VPC Endpoint routes privately; no need for VPC attachment
Lambda → S3 VPC Endpoint routes privately
Lambda → RDS/Aurora RDS only accessible inside VPC
Lambda → ElastiCache ElastiCache only accessible inside VPC
Lambda → public internet ❌ (NAT) VPC attachment + NAT Gateway required for outbound internet

2.2 VPC Endpoints — Eliminating NAT Gateway Data Charges

NAT Gateway charges $0.045 per GB of data processed. VPC Endpoints (Interface Endpoints and Gateway Endpoints) route traffic to AWS services through the AWS backbone network without traversing the public internet.

TYPESCRIPT
// CDK: Gateway VPC Endpoints for DynamoDB and S3 (free — no per-GB charge)
import { GatewayVpcEndpoint, GatewayVpcEndpointAwsService } from 'aws-cdk-lib/aws-ec2'

// Gateway Endpoints: no hourly cost, no data processing cost
new GatewayVpcEndpoint(this, 'DynamoDBEndpoint', {
  vpc,
  service: GatewayVpcEndpointAwsService.DYNAMODB,
  // Adds route table entries — DynamoDB traffic stays on AWS backbone
})

new GatewayVpcEndpoint(this, 'S3Endpoint', {
  vpc,
  service: GatewayVpcEndpointAwsService.S3,
})

// Interface Endpoints: $0.01/hr per AZ + no per-GB data charge
import { InterfaceVpcEndpoint, InterfaceVpcEndpointAwsService } from 'aws-cdk-lib/aws-ec2'

new InterfaceVpcEndpoint(this, 'SecretsManagerEndpoint', {
  vpc,
  service: InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
  privateDnsEnabled: true, // Lambda calls secretsmanager.us-east-1.amazonaws.com → routes to VPC endpoint
  subnets: { subnetType: SubnetType.PRIVATE_WITH_EGRESS },
})

NAT vs VPC Endpoint cost model:

Workload: Lambda in VPC making 1M DynamoDB calls/day with avg 10KB response = 10 GB/day = 300 GB/month

NAT Gateway cost:
  Data processing: 300 GB × $0.045 = $13.50/month
  NAT GW hourly: ~$32.40/month (2 AZs × $0.045/hr × 720 hrs)
  Total: ~$45.90/month

Gateway VPC Endpoint cost:
  $0 (no hourly, no data processing charge)
  Monthly savings: $45.90

Payback period: immediate — Gateway Endpoints are always free
Pro Tip & Optimization

Add DynamoDB and S3 Gateway Endpoints to every VPC that has Lambda functions accessing these services. They have zero cost, zero configuration overhead, and reduce NAT Gateway data processing charges immediately. This is one of the highest-ROI AWS networking optimizations available.


3. Secrets Management Governance

3.1 Anti-Pattern: Environment Variable Secrets

TYPESCRIPT
// ❌ Secrets in environment variables
const fn = new Function(this, 'Fn', {
  environment: {
    DB_PASSWORD: 'my-plaintext-password', // Visible in console, in CloudTrail, in logs
    STRIPE_KEY: 'sk_live_xxx',            // Rotated = redeploy required
    JWT_SECRET: 'my-jwt-secret',          // Blast radius: any function with env access reads this
  }
})

3.2 Secrets Manager with Lambda Extension Cache

TYPESCRIPT
// CDK: Add AWS Parameters and Secrets Lambda Extension
import { LayerVersion } from 'aws-cdk-lib/aws-lambda'

// The extension is provided as a managed Lambda Layer — region-specific ARNs
const secretsExtensionLayer = LayerVersion.fromLayerVersionArn(
  this,
  'SecretsExtension',
  'arn:aws:lambda:us-east-1:177933569100:layer:AWS-Parameters-and-Secrets-Lambda-Extension:11'
)

const fn = new Function(this, 'Fn', {
  layers: [secretsExtensionLayer],
  environment: {
    PARAMETERS_SECRETS_EXTENSION_CACHE_ENABLED: 'true',
    PARAMETERS_SECRETS_EXTENSION_CACHE_SIZE: '1000',
    // Note: The secret VALUE is not in environment variables
    // Only the secret NAME is (not sensitive)
    SECRET_ARN: secret.secretArn,
  }
})

// Grant permission to read the specific secret only
secret.grantRead(fn)
TYPESCRIPT
// Handler: fetch secret from local extension cache (no network call to Secrets Manager)
async function getDatabasePassword(): Promise<string> {
  const secretArn = process.env.SECRET_ARN!
  const port = process.env.PARAMETERS_SECRETS_EXTENSION_HTTP_PORT ?? '2773'
  const token = process.env.AWS_SESSION_TOKEN!

  const response = await fetch(
    `http://localhost:${port}/secretsmanager/get?secretId=${encodeURIComponent(secretArn)}`,
    { headers: { 'X-Aws-Parameters-Secrets-Token': token } }
  )
  const { SecretString } = await response.json() as { SecretString: string }
  return JSON.parse(SecretString).password
}

3.3 Automatic Rotation

TYPESCRIPT
// CDK: enable automatic rotation with a rotation Lambda
import { Secret } from 'aws-cdk-lib/aws-secretsmanager'
import { HostedRotation } from 'aws-cdk-lib/aws-secretsmanager'

const dbPassword = new Secret(this, 'DBPassword', {
  generateSecretString: {
    secretStringTemplate: JSON.stringify({ username: 'app_user' }),
    generateStringKey: 'password',
    excludeCharacters: '"@/',
  }
})

// Automatic rotation every 30 days
dbPassword.addRotationSchedule('RotationSchedule', {
  hostedRotation: HostedRotation.mysqlSingleUser({ vpc }),
  automaticallyAfter: Duration.days(30),
})
IAM scope diagram: Shared role (red, all functions access all resources) vs per-function roles (cyan, each function scoped to its own resource ARNs); blast radius illustrated with highlighting
IAM scope diagram: Shared role (red, all functions access all resources) vs per-function roles (cyan, each function scoped to its own resource ARNs); blast r…
VPC topology: Lambda in private subnet → Gateway VPC Endpoint → DynamoDB (no NAT, no public internet); vs Lambda → NAT Gateway → Internet → DynamoDB; cost delta annotated
VPC topology: Lambda in private subnet → Gateway VPC Endpoint → DynamoDB (no NAT, no public internet); vs Lambda → NAT Gateway → Internet → DynamoDB; cost de…

Summary

Concept Rule
IAM per-function roles One role per function, scoped to exact resource ARNs — shared roles = shared blast radius
Condition keys Second constraint layer — dynamodb:LeadingKeys for partition key scoping, aws:SourceVpc for network-bound access
VPC for Lambda Only when function needs RDS, ElastiCache, or private services — not needed for DynamoDB, S3, SQS
Gateway VPC Endpoints Always add for DynamoDB and S3 in VPC Lambdas — free, reduces NAT data charges immediately
Secrets in env vars Anti-pattern — visible in console, CloudTrail, logs; not rotatable without redeployment
Secrets Manager + Extension Caches secrets locally in the Lambda MicroVM; TTL-based refresh; automatic rotation supported

What's Next

In Part 12: Serverless Observability & FinOps — CloudWatch, X-Ray & Cost Modeling, we turn to the observability layer that makes all prior engineering decisions visible in production: EMF for zero-overhead custom metrics, X-Ray for distributed trace correlation across async fan-outs, and the unit-economics cost models that reveal where DynamoDB Scan or Lambda memory misconfiguration is creating silent billing surprises.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#IAM#Security#VPC#Secrets Manager#Lambda#Least Privilege#AWS
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.