Serverless Security Best Practices

Reverend Philip Jan 18, 2026 11 min read

Secure serverless applications from common vulnerabilities. Learn IAM policies, input validation, and runtime protection.

Serverless architectures introduce unique security challenges. Functions are ephemeral, permissions are granular, and the attack surface differs from traditional applications. Here's how to secure serverless applications effectively.

Serverless Security Model

Shared Responsibility

Understanding the security boundary between you and your cloud provider is essential. While the provider handles infrastructure security, you remain responsible for everything in your application code and configuration.

Cloud Provider Responsibilities:
├── Physical infrastructure
├── Hypervisor security
├── Container isolation
├── Function runtime patching
└── Network infrastructure

Your Responsibilities:
├── Function code security
├── IAM permissions
├── Input validation
├── Secrets management
├── Dependencies
└── Application logic

Unique Risks

Serverless introduces attack vectors that don't exist in traditional architectures. Understanding these risks helps you focus your security efforts appropriately.

Serverless-specific concerns:
1. Over-permissioned functions
2. Event injection attacks
3. Insecure secrets in env vars
4. Vulnerable dependencies
5. Insufficient logging
6. Function-level DoS
7. Cold start timing attacks

IAM and Least Privilege

Function-Specific Roles

One of the most common serverless security mistakes is granting broad permissions to functions. Each function should have its own IAM role with only the permissions it actually needs.

# serverless.yml - BAD: Overly permissive
provider:
  iam:
    role:
      statements:
        - Effect: Allow
          Action:
            - dynamodb:*  # Too broad!
            - s3:*        # Too broad!
          Resource: '*'   # Way too broad!

# GOOD: Specific permissions per function
functions:
  getUser:
    handler: handlers/user.get
    iamRoleStatements:
      - Effect: Allow
        Action:
          - dynamodb:GetItem
        Resource:
          - !GetAtt UsersTable.Arn

  createUser:
    handler: handlers/user.create
    iamRoleStatements:
      - Effect: Allow
        Action:
          - dynamodb:PutItem
        Resource:
          - !GetAtt UsersTable.Arn
      - Effect: Allow
        Action:
          - sqs:SendMessage
        Resource:
          - !GetAtt NewUserQueue.Arn

The read-only getUser function only needs GetItem permission, while createUser needs PutItem plus the ability to send SQS messages. This granularity limits the blast radius if either function is compromised.

Terraform IAM Example

When using Terraform, create separate roles for each function with precisely scoped permissions. This approach scales better than trying to manage a single shared role.

# Per-function IAM role
resource "aws_iam_role" "get_order_function" {
  name = "get-order-function-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

resource "aws_iam_role_policy" "get_order_function" {
  name = "get-order-function-policy"
  role = aws_iam_role.get_order_function.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:GetItem",
          "dynamodb:Query"
        ]
        Resource = [
          aws_dynamodb_table.orders.arn,
          "${aws_dynamodb_table.orders.arn}/index/*"
        ]
      },
      {
        Effect = "Allow"
        Action = [
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ]
        Resource = "${aws_cloudwatch_log_group.lambda.arn}:*"
      }
    ]
  })
}

Note the specific resource ARNs rather than wildcards. This prevents the function from accessing other DynamoDB tables even if an attacker gains control.

Permission Boundaries

Permission boundaries set a ceiling on what any function can do, regardless of its individual policy. Use these as a safety net to prevent accidental over-permissioning.

# Restrict maximum permissions any function can have
resource "aws_iam_policy" "lambda_boundary" {
  name = "lambda-permission-boundary"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "dynamodb:*",
          "s3:GetObject",
          "s3:PutObject",
          "sqs:*",
          "logs:*",
          "xray:*"
        ]
        Resource = "*"
      },
      {
        Effect = "Deny"
        Action = [
          "iam:*",
          "organizations:*",
          "ec2:*"
        ]
        Resource = "*"
      }
    ]
  })
}

# Apply boundary to function roles
resource "aws_iam_role" "function" {
  name                 = "function-role"
  permissions_boundary = aws_iam_policy.lambda_boundary.arn
  # ...
}

Even if someone mistakenly grants IAM permissions to a function, the boundary will deny those actions.

Input Validation

Event Source Validation

Never trust input from any source, including events from other AWS services. Attackers can craft malicious payloads that exploit parsing vulnerabilities or injection flaws.

// API Gateway event validation
import { APIGatewayProxyEvent } from 'aws-lambda';
import Joi from 'joi';

const orderSchema = Joi.object({
  customerId: Joi.string().uuid().required(),
  items: Joi.array().items(
    Joi.object({
      productId: Joi.string().uuid().required(),
      quantity: Joi.number().integer().min(1).max(100).required(),
    })
  ).min(1).required(),
});

export async function createOrder(event: APIGatewayProxyEvent) {
  // Validate content type
  const contentType = event.headers['content-type'];
  if (!contentType?.includes('application/json')) {
    return {
      statusCode: 415,
      body: JSON.stringify({ error: 'Content-Type must be application/json' }),
    };
  }

  // Parse and validate body
  let body;
  try {
    body = JSON.parse(event.body || '{}');
  } catch {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: 'Invalid JSON' }),
    };
  }

  const { error, value } = orderSchema.validate(body);
  if (error) {
    return {
      statusCode: 400,
      body: JSON.stringify({
        error: 'Validation failed',
        details: error.details,
      }),
    };
  }

  // Process validated input
  return processOrder(value);
}

This function validates content type, parses JSON safely, and uses Joi for schema validation. All three steps are necessary for defense in depth.

SNS/SQS Event Validation

Even internal events from SNS or SQS need validation. A compromised upstream service or misconfigured IAM policy could inject malicious messages into your queue.

// Validate messages from internal services too
import { SQSEvent, SQSRecord } from 'aws-lambda';

interface OrderMessage {
  orderId: string;
  action: 'process' | 'cancel' | 'refund';
  timestamp: string;
}

function validateOrderMessage(record: SQSRecord): OrderMessage {
  const body = JSON.parse(record.body);

  // Verify message structure
  if (!body.orderId || typeof body.orderId !== 'string') {
    throw new Error('Invalid orderId');
  }

  if (!['process', 'cancel', 'refund'].includes(body.action)) {
    throw new Error('Invalid action');
  }

  // Verify message isn't too old (prevent replay attacks)
  const messageAge = Date.now() - new Date(body.timestamp).getTime();
  if (messageAge > 300000) { // 5 minutes
    throw new Error('Message too old');
  }

  return body as OrderMessage;
}

export async function handler(event: SQSEvent) {
  const results = [];

  for (const record of event.Records) {
    try {
      const message = validateOrderMessage(record);
      await processMessage(message);
      results.push({ messageId: record.messageId, status: 'success' });
    } catch (error) {
      console.error('Invalid message', { record, error });
      results.push({ messageId: record.messageId, status: 'failed' });
    }
  }

  return { results };
}

The timestamp check prevents replay attacks where an attacker captures and resubmits old messages. Adjust the age threshold based on your message processing SLA.

Secrets Management

AWS Secrets Manager

Never store secrets in environment variables visible in the Lambda console. Use Secrets Manager or Parameter Store with encryption, and cache the values to minimize API calls.

import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';

const client = new SecretsManagerClient({});

// Cache secrets to avoid repeated API calls
let cachedSecrets: Record<string, string> | null = null;
let cacheExpiry = 0;

async function getSecrets(): Promise<Record<string, string>> {
  // Return cached if valid
  if (cachedSecrets && Date.now() < cacheExpiry) {
    return cachedSecrets;
  }

  const command = new GetSecretValueCommand({
    SecretId: process.env.SECRETS_ARN,
  });

  const response = await client.send(command);
  cachedSecrets = JSON.parse(response.SecretString!);
  cacheExpiry = Date.now() + 5 * 60 * 1000; // 5 minute cache

  return cachedSecrets;
}

export async function handler(event: any) {
  const secrets = await getSecrets();
  const dbConnection = await connectDatabase(secrets.DATABASE_URL);
  // ...
}

Caching reduces cold start latency and Secrets Manager API costs. The 5-minute cache balances freshness with performance.

Parameter Store with Encryption

Parameter Store is a cost-effective alternative for simpler secrets. Use SecureString parameters with KMS encryption for sensitive values.

import { SSMClient, GetParametersCommand } from '@aws-sdk/client-ssm';

const ssm = new SSMClient({});

async function getParameters(names: string[]): Promise<Record<string, string>> {
  const command = new GetParametersCommand({
    Names: names,
    WithDecryption: true,
  });

  const response = await ssm.send(command);

  const params: Record<string, string> = {};
  for (const param of response.Parameters || []) {
    const name = param.Name!.split('/').pop()!;
    params[name] = param.Value!;
  }

  return params;
}

// Load at cold start, outside handler
const parametersPromise = getParameters([
  '/myapp/prod/api_key',
  '/myapp/prod/db_password',
]);

export async function handler(event: any) {
  const params = await parametersPromise;
  // Use params.api_key, params.db_password
}

Loading parameters outside the handler means they're fetched during cold start but cached for subsequent warm invocations.

Avoid Environment Variables for Secrets

Environment variables are visible in the Lambda console and CloudWatch logs if you're not careful. Store only references, never actual secret values.

# BAD: Secrets in environment variables
functions:
  myFunction:
    environment:
      DB_PASSWORD: "super-secret-password"  # Visible in console!

# GOOD: Reference to Secrets Manager
functions:
  myFunction:
    environment:
      SECRETS_ARN: !Ref MySecrets

Dependency Security

Vulnerability Scanning

Your function's dependencies are a major attack vector. Automate vulnerability scanning in your CI/CD pipeline to catch issues before deployment.

# GitHub Actions workflow
name: Security Scan

on: [push, pull_request]

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Snyk
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

      - name: Run npm audit
        run: npm audit --audit-level=high

      - name: Run OWASP dependency check
        uses: dependency-check/Dependency-Check_Action@main
        with:
          project: 'my-serverless-app'
          path: '.'
          format: 'HTML'

Running multiple scanners catches more vulnerabilities since each tool has different databases and detection methods.

Lock Dependencies

Use exact versions in package.json and always commit your lock file. This ensures reproducible builds and prevents supply chain attacks through version hijacking.

// package.json - Use exact versions
{
  "dependencies": {
    "aws-sdk": "2.1472.0",
    "joi": "17.11.0",
    "uuid": "9.0.1"
  }
}
# Use npm ci in CI/CD (respects package-lock.json)
npm ci --production

# Regularly update and audit
npm update
npm audit fix

The npm ci command installs exactly what's in your lock file, while npm install might update minor versions.

Minimal Dependencies

Every dependency is a potential vulnerability. Use native language features when possible and import only what you need from the AWS SDK.

// BAD: Heavy dependencies for simple tasks
import _ from 'lodash';
const result = _.get(obj, 'a.b.c');

// GOOD: Native alternatives
const result = obj?.a?.b?.c;

// BAD: Full AWS SDK
import AWS from 'aws-sdk';

// GOOD: Only what you need
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

The modular AWS SDK v3 dramatically reduces bundle size compared to importing the entire v2 SDK.

Logging and Monitoring

Structured Logging

Structured logs enable security analysis and alerting. Use consistent formats with correlation IDs to trace requests across function invocations.

import { Logger } from '@aws-lambda-powertools/logger';

const logger = new Logger({
  serviceName: 'order-service',
  logLevel: 'INFO',
});

export async function handler(event: any, context: any) {
  // Add correlation IDs
  logger.addContext(context);
  logger.appendKeys({
    requestId: event.requestContext?.requestId,
    userId: event.requestContext?.authorizer?.claims?.sub,
  });

  try {
    logger.info('Processing order', {
      orderId: event.pathParameters?.orderId,
    });

    const result = await processOrder(event);

    logger.info('Order processed successfully');
    return result;

  } catch (error) {
    // Log errors with context (but sanitize sensitive data)
    logger.error('Order processing failed', {
      error: error.message,
      stack: error.stack,
    });
    throw error;
  }
}

Never log sensitive data like passwords, tokens, or PII. Sanitize error messages before logging since they might contain user input.

Security Monitoring

Log security-relevant events with consistent structure to enable alerting and incident response. Create CloudWatch alarms for high-severity events.

// Log security-relevant events
function logSecurityEvent(event: {
  type: string;
  severity: 'low' | 'medium' | 'high' | 'critical';
  details: Record<string, any>;
}) {
  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    eventType: 'security',
    ...event,
  }));
}

// Example: Failed authentication
logSecurityEvent({
  type: 'authentication_failed',
  severity: 'medium',
  details: {
    reason: 'invalid_token',
    sourceIp: event.requestContext.identity.sourceIp,
    userAgent: event.requestContext.identity.userAgent,
  },
});

// Example: Suspicious activity
logSecurityEvent({
  type: 'rate_limit_exceeded',
  severity: 'high',
  details: {
    userId: userId,
    endpoint: event.path,
    requestCount: count,
  },
});

The consistent format makes it easy to create CloudWatch Insights queries and alarms for specific security events.

Network Security

VPC Configuration

Place functions in a VPC when they need to access private resources. Configure security groups to limit outbound traffic to only required destinations.

# serverless.yml - Functions in VPC
provider:
  vpc:
    securityGroupIds:
      - !Ref LambdaSecurityGroup
    subnetIds:
      - !Ref PrivateSubnet1
      - !Ref PrivateSubnet2

resources:
  Resources:
    LambdaSecurityGroup:
      Type: AWS::EC2::SecurityGroup
      Properties:
        GroupDescription: Lambda security group
        VpcId: !Ref VPC
        SecurityGroupEgress:
          - IpProtocol: tcp
            FromPort: 443
            ToPort: 443
            CidrIp: 0.0.0.0/0  # HTTPS only
          - IpProtocol: tcp
            FromPort: 5432
            ToPort: 5432
            DestinationSecurityGroupId: !Ref DatabaseSecurityGroup

VPC functions have longer cold starts due to ENI provisioning. Consider VPC endpoints for AWS services to avoid NAT gateway costs.

API Gateway Security

AWS WAF protects your API Gateway from common attacks. Enable rate limiting and use AWS managed rule sets for broad protection.

# WAF integration
resources:
  Resources:
    WebACL:
      Type: AWS::WAFv2::WebACL
      Properties:
        DefaultAction:
          Allow: {}
        Rules:
          - Name: RateLimit
            Priority: 1
            Action:
              Block: {}
            Statement:
              RateBasedStatement:
                Limit: 2000
                AggregateKeyType: IP
          - Name: AWSManagedRulesCommonRuleSet
            Priority: 2
            OverrideAction:
              None: {}
            Statement:
              ManagedRuleGroupStatement:
                VendorName: AWS
                Name: AWSManagedRulesCommonRuleSet

The rate limit prevents function-level DoS attacks, while the managed rules block common web exploits like SQL injection and XSS.

Function Configuration

Timeouts and Memory

Set appropriate limits to prevent runaway functions and limit resource consumption during attacks.

# Limit blast radius
functions:
  myFunction:
    timeout: 10  # Seconds - prevent runaway functions
    memorySize: 256  # MB - limit resources
    reservedConcurrency: 100  # Max concurrent executions

    # Enable X-Ray for tracing
    tracing: Active

Reserved concurrency prevents a single function from consuming your account's entire concurrent execution limit during a DoS attack.

Dead Letter Queues

Failed invocations should go somewhere for investigation. Configure dead letter queues to capture failures without losing visibility.

functions:
  myFunction:
    onError: !GetAtt DeadLetterQueue.Arn
    # Failed invocations go to DLQ for investigation

resources:
  Resources:
    DeadLetterQueue:
      Type: AWS::SQS::Queue
      Properties:
        MessageRetentionPeriod: 1209600  # 14 days

Review DLQ messages regularly. Repeated failures might indicate configuration issues, but they could also reveal attack attempts.

Conclusion

Serverless security requires a shift in mindset from traditional applications. Apply least-privilege IAM permissions per function, validate all inputs regardless of source, use secrets management services instead of environment variables, and keep dependencies minimal and updated. Enable comprehensive logging for security monitoring, configure VPC and WAF for network security, and set appropriate limits on function resources. The ephemeral nature of serverless provides some security benefits, but the expanded attack surface requires careful attention to these serverless-specific security practices.

Share this article

Related Articles

Distributed Locking Patterns

Coordinate access to shared resources across services. Implement distributed locks with Redis, ZooKeeper, and databases.

Jan 16, 2026

API Design First Development

Design APIs before implementing them. Use OpenAPI specifications, mock servers, and contract-first workflows.

Jan 15, 2026

Need help with your project?

Let's discuss how we can help you build reliable software.