← All posts
· 5 min read ·
AWSLambdaSecurityServerlessDevSecOpsIAM

AWS Lambda Security Hardening: The Checklist Most Guides Skip

Lambda's serverless model removes server management but introduces its own attack surface. From execution role scoping to VPC placement and function URL controls, here's the complete hardening checklist.

AWS Lambda architecture diagram with security controls highlighted

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.

← All posts