AWS Lambda Security: IAM, Environment Variables, and Cold Start Hardening
Secure AWS Lambda with least-privilege execution roles, secrets via SSM and Secrets Manager, VPC configuration, layer vulnerability scanning, and function URL auth.
AWS Lambda Security: IAM, Environment Variables, and Cold Start Hardening
Lambda's serverless model shifts much of the traditional security surface — no OS to patch, no long-running servers to harden — but introduces new attack vectors specific to the execution model. A compromised Lambda function can exfiltrate data, access internal services, or escalate privileges through its IAM role. This guide covers the complete Lambda security stack.
Least-Privilege Execution Role
The execution role is the biggest Lambda security risk. Over-permissive roles are the leading cause of Lambda-related incidents.
The wrong way:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
]
}
This grants the Lambda function access to every AWS service in your account. If an attacker gains code execution in your function, they can exfiltrate data from any S3 bucket, read any secret, and potentially pivot to other services.
The right way — scope to exactly what the function needs:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadSpecificDynamoTable",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:Query",
"dynamodb:PutItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:123456789:table/orders"
},
{
"Sid": "ReadSpecificSecret",
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue"
],
"Resource": "arn:aws:secretsmanager:us-east-1:123456789:secret:prod/orders-api-key-*"
},
{
"Sid": "CloudWatchLogs",
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:123456789:log-group:/aws/lambda/orders-service:*"
}
]
}
In Terraform:
resource "aws_iam_role_policy" "lambda_policy" {
name = "orders-lambda-policy"
role = aws_iam_role.lambda_exec.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["dynamodb:GetItem", "dynamodb:Query", "dynamodb:PutItem"]
Resource = aws_dynamodb_table.orders.arn
},
{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = "${aws_secretsmanager_secret.api_key.arn}*"
}
]
})
}
Secrets Management: SSM vs Secrets Manager
Never store secrets in Lambda environment variables directly — they are visible in the AWS console and CloudFormation templates.
AWS Systems Manager Parameter Store (for non-sensitive config)
import boto3
from functools import lru_cache
ssm = boto3.client('ssm')
@lru_cache(maxsize=None)
def get_parameter(name: str) -> str:
response = ssm.get_parameter(Name=name, WithDecryption=True)
return response['Parameter']['Value']
def handler(event, context):
api_endpoint = get_parameter('/prod/orders/api-endpoint')
# lru_cache means this is fetched once per Lambda container lifetime
AWS Secrets Manager (for credentials, API keys, database passwords)
import boto3
import json
from functools import lru_cache
secrets_client = boto3.client('secretsmanager')
@lru_cache(maxsize=None)
def get_secret(secret_name: str) -> dict:
response = secrets_client.get_secret_value(SecretId=secret_name)
return json.loads(response['SecretString'])
def handler(event, context):
# Fetched once per container, cached for subsequent invocations
db_creds = get_secret('prod/orders-db')
conn = connect(
host=db_creds['host'],
user=db_creds['username'],
password=db_creds['password']
)
Use lru_cache to cache the secret for the container's lifetime — this avoids latency and Secrets Manager API costs on every invocation while still refreshing on cold starts.
VPC Configuration
By default, Lambda functions run in a shared AWS-managed VPC with internet access but no access to your private resources (RDS, ElastiCache, internal APIs). Add Lambda to your VPC to enable private resource access:
resource "aws_lambda_function" "api" {
function_name = "orders-api"
runtime = "python3.12"
handler = "main.handler"
vpc_config {
subnet_ids = [
aws_subnet.private_a.id,
aws_subnet.private_b.id,
]
security_group_ids = [aws_security_group.lambda.id]
}
}
# Lambda security group — minimal egress
resource "aws_security_group" "lambda" {
name = "lambda-orders-api"
vpc_id = aws_vpc.main.id
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"] # VPC CIDR for internal service calls
}
egress {
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = ["10.0.1.0/24"] # Database subnet only
}
}
Important: Lambda functions in a VPC with private subnets lose internet access. If you need internet access (e.g., to call external APIs), add a NAT Gateway.
Lambda Function URLs: Authentication
Function URLs expose your Lambda directly over HTTPS without API Gateway. Always configure authentication:
resource "aws_lambda_function_url" "api" {
function_name = aws_lambda_function.api.function_name
authorization_type = "AWS_IAM" # Never use "NONE" for non-public endpoints
cors {
allow_credentials = true
allow_origins = ["https://app.example.com"]
allow_methods = ["GET", "POST"]
allow_headers = ["Content-Type", "Authorization"]
max_age = 3600
}
}
If you use authorization_type = "NONE" (for public webhooks), implement your own request validation:
import hmac
import hashlib
import os
def verify_webhook_signature(payload: bytes, signature: str) -> bool:
secret = os.environ['WEBHOOK_SECRET'].encode()
expected = 'sha256=' + hmac.new(secret, payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
def handler(event, context):
body = event['body'].encode()
signature = event['headers'].get('x-hub-signature-256', '')
if not verify_webhook_signature(body, signature):
return {'statusCode': 401, 'body': 'Unauthorized'}
Lambda Layer Security
Lambda Layers are shared code packages attached to functions. A vulnerable library in a layer affects every function that uses it.
Audit layers for vulnerabilities:
# Extract and scan a layer
aws lambda get-layer-version \
--layer-name my-dependencies \
--version-number 5 \
--query 'Content.Location' \
--output text | xargs curl -o layer.zip
unzip layer.zip -d layer-contents/
# Scan with Trivy
trivy fs --security-checks vuln layer-contents/
# Or with Grype
grype dir:layer-contents/
Pin layer versions in your Lambda configuration and review them when creating new versions:
resource "aws_lambda_function" "api" {
layers = [
"arn:aws:lambda:us-east-1:123456789:layer:shared-deps:42", # Pinned version
]
}
Environment Variable Security
What you can safely put in Lambda environment variables:
- Configuration values (region, table names, feature flags)
- Non-sensitive references (secret ARNs, parameter names)
What must not go in environment variables:
- Database passwords
- API keys
- Private keys or certificates
- JWT secrets
resource "aws_lambda_function" "api" {
environment {
variables = {
# Safe — configuration, not secrets
DYNAMODB_TABLE = aws_dynamodb_table.orders.name
ENVIRONMENT = var.environment
LOG_LEVEL = "INFO"
# Reference to secret, not the secret itself
DB_SECRET_ARN = aws_secretsmanager_secret.db.arn
API_KEY_PARAM_PATH = "/prod/orders/api-key"
}
}
}
Resource-Based Policy: Restricting Who Can Invoke
Control which services and accounts can invoke your function:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowAPIGateway",
"Effect": "Allow",
"Principal": {
"Service": "apigateway.amazonaws.com"
},
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:123456789:function:orders-api",
"Condition": {
"ArnLike": {
"AWS:SourceArn": "arn:aws:execute-api:us-east-1:123456789:abc123/*"
}
}
}
]
}
The Condition with AWS:SourceArn prevents confused deputy attacks where a different API Gateway in another account calls your function.
Cold Start Security Considerations
During a cold start, Lambda initializes the execution environment. Security-relevant implications:
- Secrets fetched at cold start are cached for container lifetime — balance between latency and staleness
- Connection pools opened at initialization persist across warm invocations — ensure they have appropriate timeouts
- Initialization code runs outside the handler and is not subject to the function timeout
import boto3
import json
# Runs at cold start — not subject to function timeout
# But also not retried on failure — handle errors carefully
secrets_client = boto3.client('secretsmanager')
_cached_config = None
def get_config():
global _cached_config
if _cached_config is None:
response = secrets_client.get_secret_value(SecretId='prod/config')
_cached_config = json.loads(response['SecretString'])
return _cached_config
def handler(event, context):
config = get_config() # Returns cached value on warm invocations
...
Security Checklist
- Execution role uses least-privilege IAM policy — no
*actions or resources - Secrets loaded from SSM Parameter Store or Secrets Manager, not environment variables
- Lambda in VPC with private subnets for access to internal resources
- VPC security group egress restricted to required ports and CIDRs only
- Function URLs use
AWS_IAMauth or implement signature verification for webhooks - Layer versions pinned and scanned for CVEs with Trivy or Grype
- Resource-based policy restricts invocation to specific source ARNs
- No sensitive values in CloudWatch Logs (redact before logging)
- Dead letter queue configured for failed async invocations
- X-Ray tracing enabled for observability into external calls