Lambda abstracts away the server, but it doesn’t abstract away the security posture. The execution role, environment variables, networking, invocation controls, and runtime all have security implications that developers routinely overlook because the “just deploy a function” experience is so smooth. Here’s the full picture.
Execution Role: Minimum Viable Permissions
The single most impactful Lambda security control is the execution role. The common mistake is attaching AWSLambdaBasicExecutionRole (which includes CloudWatch Logs write) and then adding broad managed policies on top - AmazonS3FullAccess, AmazonDynamoDBFullAccess - because it’s quick.
The correct approach is a custom role with the exact permissions the function needs, scoped to the exact resources it accesses:
resource "aws_iam_role" "lambda" {
name = "my-function-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "lambda" {
name = "my-function-policy"
role = aws_iam_role.lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
# CloudWatch Logs - always needed
{
Effect = "Allow"
Action = ["logs:CreateLogStream", "logs:PutLogEvents"]
Resource = "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/my-function:*"
},
# Specific Secrets Manager ARNs - not GetSecretValue on *
{
Effect = "Allow"
Action = "secretsmanager:GetSecretValue"
Resource = [
"arn:aws:secretsmanager:us-east-1:123456789012:secret:my-app/api-key-*",
]
},
# Specific S3 prefix - not the whole bucket
{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = "arn:aws:s3:::my-bucket/uploads/*"
}
]
})
}
Use IAM Access Analyzer to validate that the policy is correctly scoped and doesn’t allow unintended access paths.
Resource-Based Policy: Control Who Can Invoke
Lambda has two policy types: the execution role (what Lambda can do) and the resource-based policy (who can invoke Lambda). By default, no one can invoke your function except the AWS services explicitly given permission.
If you’re using API Gateway:
resource "aws_lambda_permission" "apigw" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.api.function_name
principal = "apigateway.amazonaws.com"
# Scope to specific API and stage - not apigateway.amazonaws.com on *
source_arn = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
}
The source_arn constraint is important. Without it, any API Gateway in any account that knows your function ARN can invoke it (if they can trick AWS into including apigateway.amazonaws.com as the principal).
Environment Variables: Don’t Store Secrets
Lambda environment variables are visible in plaintext to anyone with lambda:GetFunctionConfiguration. They’re encrypted at rest with the AWS-managed key, but that’s not the same as secret.
The pattern to avoid:
# Don't do this
API_KEY=sk-live-abc123xyz
DB_PASSWORD=hunter2
The correct pattern: store a Secrets Manager ARN in the environment variable, fetch the value at runtime with a cache:
import boto3, os, time
_cache = {}
CACHE_TTL = 300
def _get_secret(arn):
now = time.time()
if arn in _cache and now < _cache[arn][1]:
return _cache[arn][0]
value = boto3.client("secretsmanager").get_secret_value(SecretId=arn)["SecretString"]
_cache[arn] = (value, now + CACHE_TTL)
return value
API_KEY_ARN = os.environ["API_KEY_SECRET_ARN"] # This ARN is safe to see
Anyone with lambda:GetFunctionConfiguration sees the ARN, not the key. The actual key requires both secretsmanager:GetSecretValue on that ARN and the execution role that grants it.
Tracing: Enable X-Ray Active Mode
X-Ray traces give you request-level visibility into Lambda execution - duration, downstream service calls, error rates. The active mode samples every request that isn’t already traced.
resource "aws_lambda_function" "api" {
# ...
tracing_config {
mode = "Active" # Not PassThrough
}
}
X-Ray’s free tier covers 100,000 traces per month. For most personal projects and small APIs, you won’t exceed this. For high-traffic functions, use PassThrough and rely on upstream sampling.
Reserved Concurrency: A Cost and Security Control
Reserved concurrency caps the maximum number of concurrent executions for a function. This limits:
- Cost: A runaway invocation loop can’t scale to 1,000 concurrent executions
- DoS impact: An attacker flooding your function can’t exhaust your account’s Lambda concurrency
resource "aws_lambda_function" "api" {
# ...
reserved_concurrent_executions = 10 # Maximum 10 concurrent invocations
}
Set this conservatively for functions that don’t need to scale. For functions behind API Gateway with throttling already configured, this is a belt-and-suspenders control.
Note: If you’re on an AWS account with a low total concurrency limit (new accounts default to 10 unreserved), setting reserved concurrency above the account limit will fail. Check your account limit first:
aws lambda get-account-settings --query 'AccountLimit.ConcurrentExecutions'
Function URLs vs API Gateway
Lambda Function URLs provide a direct HTTPS endpoint without API Gateway. They’re simpler and cheaper but have a different security model:
resource "aws_lambda_function_url" "api" {
function_name = aws_lambda_function.api.function_name
authorization_type = "AWS_IAM" # Not NONE
cors {
allow_origins = ["https://your-domain.com"]
allow_methods = ["GET", "POST"]
max_age = 300
}
}
authorization_type = "NONE" makes the function publicly accessible to the internet with no auth. Only use this if you handle auth in the function itself and have rate limiting in place. AWS_IAM requires SigV4-signed requests - good for machine-to-machine, awkward for browser clients.
For public APIs: use API Gateway with a throttle, Lambda authorizer, and CloudFront in front. The added complexity is worth the control surface.
The Security Checklist
Execution role
☐ Custom role (not AdministratorAccess or PowerUserAccess)
☐ CloudWatch Logs scoped to specific log group ARN
☐ Secrets Manager access scoped to specific secret ARNs
☐ No unused permissions attached
☐ Validated with IAM Access Analyzer
Invocation control
☐ Resource-based policy restricts invokers to specific ARNs
☐ API Gateway source_arn scoped to specific API
☐ Reserved concurrency set (or explicitly justified why not)
Secrets and config
☐ No plaintext secrets in environment variables
☐ Secrets Manager ARNs used with runtime fetch + cache
☐ Environment variables don't leak internal hostnames unnecessarily
Observability
☐ X-Ray Active tracing enabled
☐ CloudWatch alarm on error rate
☐ CloudWatch alarm on invocation count spike
Runtime
☐ Runtime is current (not end-of-life)
☐ Dependencies scanned for CVEs (Trivy / Snyk)
☐ Zip includes only runtime files, no .git, .env, test files
Lambda security is mostly IAM hygiene and configuration discipline - not fundamentally different from any other cloud service. The serverless abstraction removes the OS management burden but doesn’t remove the need for the same rigour.