Cloud Security

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.

March 9, 20267 min readShipSafer Team

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_IAM auth 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

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.