Key Takeaways

  • Every Lambda function needs its own execution role scoped to exactly what it does — one shared "lambda-role" across every function in an account is the most common security gap teams never revisit.
  • lambda security groups only apply when a function is attached to a VPC — functions outside a VPC rely entirely on IAM for access control since they have no network-level boundary to secure.
  • Secrets (API keys, DB credentials) belong in Secrets Manager or Parameter Store, retrieved at runtime — never in environment variables in plaintext, and never hardcoded.
  • Lambda's reserved concurrency setting is also a security control: it caps how many concurrent executions a function can have, limiting blast radius if it's triggered maliciously or in a runaway loop.
  • CloudTrail records every Invoke call's caller identity; combine that with Lambda's own execution logs in CloudWatch for a full audit trail of who triggered what, when.

Lambda removes the server you'd normally have to patch and harden, but that doesn't mean there's nothing left to secure — it shifts the security surface to IAM permissions, VPC networking (when used), secrets handling, and code-level dependency risk. Compliance frameworks (SOC 2, HIPAA, PCI-DSS) still expect evidence that all of these are controlled, not just "it's serverless so it's fine."

Execution Roles: One Per Function, Scoped Tight

The execution role is the IAM role Lambda assumes to run your function code — whatever permissions it has, your function has. A minimal role for a function that only reads from one specific DynamoDB table:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:Query"],
      "Resource": "arn:aws:dynamodb:us-east-1:111122223333:table/orders"
    },
    {
      "Effect": "Allow",
      "Action": ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"],
      "Resource": "arn:aws:logs:us-east-1:111122223333:*"
    }
  ]
}

Compare that to the common anti-pattern — attaching AmazonDynamoDBFullAccess or, worse, a hand-rolled policy with "Action": "dynamodb:", "Resource": "" because it was faster to get working. That one function compromise now means every DynamoDB table in the account is reachable.

Lambda Security Groups: When and Why They Apply

A function only gets a security group if it's attached to a VPC — typically because it needs to reach an RDS instance, ElastiCache cluster, or another resource that isn't internet-accessible.

aws lambda update-function-configuration \
  --function-name order-processor \
  --vpc-config SubnetIds=subnet-0abc123,subnet-0def456,SecurityGroupIds=sg-0a1b2c3d4e5f67890

Design the attached lambda security group the same way you would for an EC2 instance: outbound rules scoped to exactly what the function needs to reach (e.g., port 5432 to the RDS security group), not 0.0.0.0/0 on all ports.

{
  "SecurityGroupRules": [
    {
      "IpProtocol": "tcp",
      "FromPort": 5432,
      "ToPort": 5432,
      "Direction": "egress",
      "DestinationSecurityGroupId": "sg-rds-orders-db"
    }
  ]
}

Note the trade-off: attaching a function to a VPC for a lambda security group to even apply adds cold-start latency (an ENI must attach) and requires a NAT Gateway or VPC endpoint if the function also needs internet access (e.g., calling an external API) — plan the subnet and routing design before, not after, deployment.

Secrets: Never in Plaintext Environment Variables

Environment variables are visible to anyone with lambda:GetFunctionConfiguration permission on that function — that's a much larger set of people than "whoever should know the database password."

import boto3
import json

secrets_client = boto3.client("secretsmanager")

def get_db_credentials():
    response = secrets_client.get_secret_value(SecretId="prod/orders-db/credentials")
    return json.loads(response["SecretString"])

def lambda_handler(event, context):
    creds = get_db_credentials()  # fetched at invoke time, never stored in env vars
    # connect to DB using creds["username"], creds["password"]

Cache the retrieved secret in a module-level variable (outside the handler) across warm invocations to avoid a Secrets Manager API call on every single request — just make sure the cache still respects secret rotation.

Reserved Concurrency as a Security Control

aws lambda put-function-concurrency \
  --function-name order-processor \
  --reserved-concurrent-executions 20

This isn't just a cost or throttling control — it limits how much damage a function can do if it's invoked maliciously (e.g., triggered repeatedly by a compromised upstream event source) or gets stuck in an unexpected retry loop that would otherwise scale to the account's full concurrency limit and potentially starve other functions.

Audit Trail: CloudTrail Plus CloudWatch

aws logs filter-log-events \
  --log-group-name /aws/lambda/order-processor \
  --filter-pattern "ERROR" \
  --start-time $(date -d '1 hour ago' +%s000)

CloudTrail answers "who invoked this function and from where" (API-level, management-plane visibility); CloudWatch Logs answers "what did the function actually do during that invocation" (application-level). Compliance audits typically want both — a caller identity trail and an execution behavior trail.

Lambda Security Boundaries

<svg viewBox="0 0 640 220" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Lambda function security layers: IAM execution role, optional VPC security group, secrets manager, and logging">
  <style>
    .box { fill: none; stroke: currentColor; stroke-width: 1.5; }
    .lbl { fill: currentColor; font-family: sans-serif; font-size: 12px; }
  </style>
  <rect class="box" x="230" y="20" width="180" height="50"/>
  <text class="lbl" x="255" y="50">Lambda function</text>
  <rect class="box" x="20" y="100" width="150" height="50"/>
  <text class="lbl" x="35" y="122">IAM execution</text>
  <text class="lbl" x="35" y="138">role (always)</text>
  <rect class="box" x="195" y="100" width="150" height="50"/>
  <text class="lbl" x="210" y="122">VPC security</text>
  <text class="lbl" x="210" y="138">group (if VPC-attached)</text>
  <rect class="box" x="370" y="100" width="150" height="50"/>
  <text class="lbl" x="385" y="122">Secrets Manager</text>
  <text class="lbl" x="385" y="138">(runtime fetch)</text>
  <rect class="box" x="195" y="170" width="150" height="40"/>
  <text class="lbl" x="210" y="195">CloudTrail + CloudWatch</text>
</svg>

FAQ

Do all Lambda functions have a security group? No — only functions attached to a VPC get one. Functions not attached to a VPC have no network-level security group; access control for them is entirely IAM-based.

What's the biggest Lambda security mistake teams make? Sharing one broad IAM execution role across many functions. A compromise or bug in one function then has access to everything every other function using that role can do — scope each role to one function's actual needs.

Can I put database passwords in Lambda environment variables? You can, but you shouldn't for anything production or compliance-relevant — anyone with read access to the function's configuration can see them in plaintext. Use Secrets Manager or Parameter Store (SecureString) and fetch at runtime instead.

Does attaching a Lambda function to a VPC slow it down? It can add cold-start latency due to ENI attachment, though AWS has significantly reduced this overhead in recent runtime updates. It also means the function needs explicit routing (NAT Gateway or VPC endpoint) for any internet-bound calls.

Harden Your Lambda Environment

f9infotech reviews Lambda execution roles, VPC security group design, and secrets handling against SOC 2 and HIPAA requirements — then fixes what doesn't pass. Talk to our AWS security team about a serverless security review.