Serverless

Serverless Security Best Practices

Secure serverless applications with IAM least privilege, input validation, secrets management, and runtime monitoring patterns

Serverless Security Best Practices

Overview

Serverless architectures shift operational responsibility to cloud providers, but they do not shift security responsibility. You still own your code, your data, your IAM policies, and your attack surface — and in many ways, serverless introduces new vectors that traditional application security never had to consider. This guide covers the full spectrum of serverless security, from IAM least privilege and secrets rotation to supply chain attacks and runtime monitoring, with production-tested Node.js patterns you can deploy today.

Prerequisites

  • Working knowledge of AWS Lambda and API Gateway
  • Node.js 18+ and npm
  • AWS CLI configured with appropriate credentials
  • Familiarity with IAM policies and CloudFormation or SAM
  • Basic understanding of OWASP security principles

Least Privilege IAM for Lambda

The single most impactful security measure for any Lambda function is its execution role. Most teams start with overly permissive policies because it is faster to develop against, and then never tighten them. This is the number one serverless security failure I see in production.

Every Lambda function should have its own dedicated IAM role with precisely the permissions it needs and nothing more. Here is what a properly scoped policy looks like for a function that reads from one DynamoDB table and writes to one S3 bucket:

# serverless.yml or SAM template
Resources:
  ProcessOrderFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handlers/processOrder.handler
      Runtime: nodejs18.x
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:GetItem
                - dynamodb:Query
              Resource: !GetAtt OrdersTable.Arn
            - Effect: Allow
              Action:
                - s3:PutObject
              Resource: !Sub "${InvoiceBucket.Arn}/*"
            - Effect: Allow
              Action:
                - logs:CreateLogGroup
                - logs:CreateLogStream
                - logs:PutLogEvents
              Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*"

Notice that the DynamoDB permissions are limited to GetItem and Query — not dynamodb:*. The S3 permission is PutObject only, scoped to a specific bucket. This function cannot delete items, scan the full table, or read from S3. If an attacker compromises this function, the blast radius is contained.

Never do this:

# DANGEROUS — never use wildcard permissions
- Effect: Allow
  Action: "*"
  Resource: "*"

Use IAM Access Analyzer to audit your existing roles. It will flag unused permissions and help you generate least-privilege policies based on actual CloudTrail activity.

Input Validation and Injection Prevention

Serverless functions accept input from many sources: API Gateway events, SQS messages, S3 event notifications, DynamoDB streams, and more. Every one of these is an attack vector. You must validate and sanitize all input regardless of source.

var Joi = require("joi");

var orderSchema = Joi.object({
  customerId: Joi.string().uuid().required(),
  items: Joi.array().items(
    Joi.object({
      productId: Joi.string().alphanum().max(64).required(),
      quantity: Joi.number().integer().min(1).max(100).required(),
      price: Joi.number().positive().max(99999).required()
    })
  ).min(1).max(50).required(),
  shippingAddress: Joi.object({
    street: Joi.string().max(200).required(),
    city: Joi.string().max(100).required(),
    state: Joi.string().length(2).required(),
    zip: Joi.string().pattern(/^\d{5}(-\d{4})?$/).required()
  }).required()
});

function validateInput(body) {
  var result = orderSchema.validate(body, {
    abortEarly: false,
    stripUnknown: true
  });

  if (result.error) {
    var messages = result.error.details.map(function(detail) {
      return detail.message;
    });
    throw new ValidationError(messages);
  }

  return result.value;
}

The stripUnknown: true option is critical. It removes any fields not defined in your schema, preventing attackers from injecting unexpected properties that downstream code might accidentally use.

For NoSQL injection prevention with DynamoDB, never construct filter expressions from raw user input:

// VULNERABLE — never do this
var params = {
  TableName: "Orders",
  FilterExpression: "category = " + userInput
};

// SAFE — use expression attribute values
var params = {
  TableName: "Orders",
  FilterExpression: "category = :cat",
  ExpressionAttributeValues: {
    ":cat": { S: sanitize(userInput) }
  }
};

function sanitize(input) {
  if (typeof input !== "string") {
    throw new Error("Expected string input");
  }
  return input.replace(/[^\w\s-]/g, "").substring(0, 255);
}

Secrets Management in Serverless

Hardcoding secrets in environment variables is a common serverless antipattern. Environment variables are visible in the Lambda console, appear in CloudFormation templates, and get logged in deployment pipelines. Use AWS Secrets Manager or SSM Parameter Store with encryption instead.

var AWS = require("aws-sdk");

var secretsManager = new AWS.SecretsManager();
var cachedSecrets = {};
var cacheExpiry = 0;
var CACHE_TTL = 300000; // 5 minutes

function getSecrets(secretName) {
  var now = Date.now();

  if (cachedSecrets[secretName] && now < cacheExpiry) {
    return Promise.resolve(cachedSecrets[secretName]);
  }

  return secretsManager.getSecretValue({
    SecretId: secretName
  }).promise().then(function(data) {
    var parsed = JSON.parse(data.SecretString);
    cachedSecrets[secretName] = parsed;
    cacheExpiry = now + CACHE_TTL;
    return parsed;
  });
}

exports.handler = function(event, context) {
  return getSecrets("prod/api/database-credentials")
    .then(function(secrets) {
      var dbConnection = createConnection({
        host: secrets.host,
        port: secrets.port,
        user: secrets.username,
        password: secrets.password,
        database: secrets.dbname
      });
      return processRequest(event, dbConnection);
    })
    .catch(function(err) {
      console.error("Failed to retrieve secrets:", err.message);
      return {
        statusCode: 500,
        body: JSON.stringify({ error: "Internal server error" })
      };
    });
};

The caching layer is important. Without it, every cold start and potentially every invocation hits Secrets Manager, adding latency and cost. The 5-minute TTL balances freshness with performance.

For automatic secret rotation, configure Secrets Manager rotation with a Lambda rotation function:

Resources:
  DatabaseSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: prod/api/database-credentials
      GenerateSecretString:
        SecretStringTemplate: '{"username": "app_user"}'
        GenerateStringKey: password
        PasswordLength: 32
        ExcludeCharacters: '"@/\'

  SecretRotationSchedule:
    Type: AWS::SecretsManager::RotationSchedule
    Properties:
      SecretId: !Ref DatabaseSecret
      RotationLambdaARN: !GetAtt RotationFunction.Arn
      RotationRules:
        AutomaticallyAfterDays: 30

Function-Level Isolation

Each Lambda function should be an isolated security boundary. This means separate IAM roles, separate environment variables, and separate VPC configurations where applicable. Monolithic Lambda functions that handle multiple routes or operations violate the principle of least privilege because the function's IAM role must have the union of all permissions for all operations.

// BAD — monolithic handler with broad permissions
exports.handler = function(event, context) {
  switch (event.httpMethod + " " + event.resource) {
    case "GET /users":
      return listUsers();      // needs dynamodb:Scan
    case "DELETE /users/{id}":
      return deleteUser();     // needs dynamodb:DeleteItem
    case "POST /reports":
      return generateReport(); // needs s3:PutObject, ses:SendEmail
  }
};

// GOOD — separate functions with scoped permissions
// handlers/listUsers.js — only has dynamodb:Query permission
exports.handler = function(event, context) {
  return listUsers(event);
};

// handlers/deleteUser.js — only has dynamodb:DeleteItem, restricted to admin role
exports.handler = function(event, context) {
  return deleteUser(event);
};

This also limits the blast radius of a dependency vulnerability. If a compromised npm package only exists in one handler, only that handler's permissions are exposed.

VPC Security for Lambda

Lambda functions that access private resources like RDS databases or ElastiCache clusters must run inside a VPC. But VPC-attached Lambda functions lose internet access by default, which breaks calls to AWS APIs and external services. The correct architecture uses private subnets with a NAT Gateway, or VPC endpoints for AWS services.

Resources:
  ProcessOrderFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: handlers/processOrder.handler
      Runtime: nodejs18.x
      VpcConfig:
        SubnetIds:
          - !Ref PrivateSubnet1
          - !Ref PrivateSubnet2
        SecurityGroupIds:
          - !Ref LambdaSecurityGroup

  LambdaSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for order processing Lambda
      VpcId: !Ref VPC
      SecurityGroupEgress:
        - IpProtocol: tcp
          FromPort: 5432
          ToPort: 5432
          DestinationSecurityGroupId: !Ref DatabaseSecurityGroup
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: 0.0.0.0/0

  # VPC endpoint for Secrets Manager — avoids NAT Gateway costs
  SecretsManagerEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref VPC
      ServiceName: !Sub "com.amazonaws.${AWS::Region}.secretsmanager"
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      SubnetIds:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
      SecurityGroupIds:
        - !Ref VPCEndpointSecurityGroup

The security group for the Lambda function restricts outbound traffic to only the database port and HTTPS. This prevents a compromised function from making arbitrary network connections.

Dependency Vulnerability Scanning

Your Lambda function is only as secure as its weakest dependency. A single compromised or outdated npm package can give an attacker full access to your function's execution environment and IAM permissions. Integrate vulnerability scanning into your CI/CD pipeline.

{
  "scripts": {
    "audit": "npm audit --audit-level=high",
    "audit:fix": "npm audit fix",
    "security:check": "node scripts/security-check.js",
    "prepackage": "npm run audit && npm run security:check"
  }
}
// scripts/security-check.js
var childProcess = require("child_process");
var fs = require("fs");

function runSecurityChecks() {
  console.log("Running dependency security checks...\n");

  // Check for known vulnerable packages
  var auditResult = childProcess.execSync("npm audit --json", {
    encoding: "utf8",
    stdio: ["pipe", "pipe", "pipe"]
  });

  var audit = JSON.parse(auditResult);
  var criticalVulns = [];

  Object.keys(audit.vulnerabilities || {}).forEach(function(pkg) {
    var vuln = audit.vulnerabilities[pkg];
    if (vuln.severity === "critical" || vuln.severity === "high") {
      criticalVulns.push({
        package: pkg,
        severity: vuln.severity,
        title: vuln.via[0].title || vuln.via[0],
        fixAvailable: vuln.fixAvailable
      });
    }
  });

  if (criticalVulns.length > 0) {
    console.error("CRITICAL/HIGH vulnerabilities found:");
    criticalVulns.forEach(function(v) {
      console.error("  - " + v.package + " (" + v.severity + "): " + v.title);
    });
    process.exit(1);
  }

  // Check for packages with excessive permissions
  var packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"));
  var deps = Object.keys(packageJson.dependencies || {});
  var suspiciousPatterns = ["postinstall", "preinstall"];

  deps.forEach(function(dep) {
    var depPkgPath = "node_modules/" + dep + "/package.json";
    if (fs.existsSync(depPkgPath)) {
      var depPkg = JSON.parse(fs.readFileSync(depPkgPath, "utf8"));
      suspiciousPatterns.forEach(function(pattern) {
        if (depPkg.scripts && depPkg.scripts[pattern]) {
          console.warn("WARNING: " + dep + " has a " + pattern + " script: " + depPkg.scripts[pattern]);
        }
      });
    }
  });

  console.log("Security checks passed.");
}

runSecurityChecks();

Run npm audit in CI and fail the build on critical or high severity vulnerabilities. Use lockfiles (package-lock.json) to pin exact dependency versions. Review lockfile diffs in pull requests — supply chain attacks often manifest as unexpected changes in transitive dependencies.

API Gateway Authorization Patterns

API Gateway sits in front of your Lambda functions and is your first line of defense. Use built-in authorizers to reject unauthorized requests before they even reach your function code.

Lambda Authorizer (Custom)

// authorizer.js
var jwt = require("jsonwebtoken");

var SIGNING_KEY = null;

function getSigningKey() {
  if (SIGNING_KEY) {
    return Promise.resolve(SIGNING_KEY);
  }
  var AWS = require("aws-sdk");
  var ssm = new AWS.SSM();
  return ssm.getParameter({
    Name: "/prod/jwt-signing-key",
    WithDecryption: true
  }).promise().then(function(result) {
    SIGNING_KEY = result.Parameter.Value;
    return SIGNING_KEY;
  });
}

exports.handler = function(event, context) {
  var token = event.authorizationToken;

  if (!token || !token.startsWith("Bearer ")) {
    return context.fail("Unauthorized");
  }

  token = token.substring(7);

  return getSigningKey().then(function(key) {
    var decoded = jwt.verify(token, key, {
      algorithms: ["HS256"],
      issuer: "grizzlypeaksoftware.com",
      audience: "api.grizzlypeaksoftware.com"
    });

    var policy = generatePolicy(decoded.sub, "Allow", event.methodArn, {
      userId: decoded.sub,
      role: decoded.role,
      email: decoded.email
    });

    return policy;
  }).catch(function(err) {
    console.error("Authorization failed:", err.message);
    return context.fail("Unauthorized");
  });
};

function generatePolicy(principalId, effect, resource, contextData) {
  var arnParts = resource.split(":");
  var apiGatewayArnParts = arnParts[5].split("/");
  var wildcardResource = arnParts[0] + ":" + arnParts[1] + ":" +
    arnParts[2] + ":" + arnParts[3] + ":" + arnParts[4] + ":" +
    apiGatewayArnParts[0] + "/" + apiGatewayArnParts[1] + "/*/*";

  return {
    principalId: principalId,
    policyDocument: {
      Version: "2012-10-17",
      Statement: [{
        Action: "execute-api:Invoke",
        Effect: effect,
        Resource: wildcardResource
      }]
    },
    context: contextData || {}
  };
}

Configure API Gateway to cache authorizer results for a reasonable TTL (5 minutes) to reduce latency and Lambda invocations. Always validate the iss and aud claims in JWT tokens to prevent token confusion attacks.

Cognito User Pool Authorizer

For simpler use cases, a Cognito authorizer offloads token validation entirely:

Resources:
  ApiGateway:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Auth:
        DefaultAuthorizer: CognitoAuthorizer
        Authorizers:
          CognitoAuthorizer:
            UserPoolArn: !GetAtt UserPool.Arn
            Identity:
              Header: Authorization

Encryption at Rest and in Transit

All data stored by serverless functions must be encrypted at rest. AWS provides this by default for most services, but you must verify and configure it explicitly.

Resources:
  OrdersTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: orders
      SSESpecification:
        SSEEnabled: true
        SSEType: KMS
        KMSMasterKeyId: !Ref OrdersTableKey

  InvoiceBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: invoices-prod
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: aws:kms
              KMSMasterKeyId: !Ref InvoiceBucketKey
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

  OrdersTableKey:
    Type: AWS::KMS::Key
    Properties:
      Description: Encryption key for orders table
      KeyPolicy:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowKeyAdmin
            Effect: Allow
            Principal:
              AWS: !Sub "arn:aws:iam::${AWS::AccountId}:root"
            Action: "kms:*"
            Resource: "*"
          - Sid: AllowLambdaUse
            Effect: Allow
            Principal:
              AWS: !GetAtt ProcessOrderRole.Arn
            Action:
              - kms:Decrypt
              - kms:GenerateDataKey
            Resource: "*"

For data in transit, enforce HTTPS at the API Gateway level and use TLS for all outbound connections:

var https = require("https");

var agent = new https.Agent({
  minVersion: "TLSv1.2",
  rejectUnauthorized: true
});

function makeSecureRequest(url, data) {
  var AWS = require("aws-sdk");
  var httpOptions = {
    agent: agent,
    connectTimeout: 5000,
    timeout: 10000
  };

  AWS.config.update({ httpOptions: httpOptions });

  return new Promise(function(resolve, reject) {
    var options = {
      hostname: new URL(url).hostname,
      path: new URL(url).pathname,
      method: "POST",
      agent: agent,
      headers: {
        "Content-Type": "application/json"
      }
    };

    var req = https.request(options, function(res) {
      var body = "";
      res.on("data", function(chunk) { body += chunk; });
      res.on("end", function() { resolve(JSON.parse(body)); });
    });

    req.on("error", reject);
    req.write(JSON.stringify(data));
    req.end();
  });
}

Logging and Auditing for Compliance

Serverless applications must produce structured audit logs that capture who did what, when, and what the outcome was. This is non-negotiable for SOC 2, HIPAA, PCI-DSS, and most regulatory frameworks.

function createAuditLogger(functionName) {
  return {
    logAction: function(action, userId, resource, details, outcome) {
      var auditEntry = {
        timestamp: new Date().toISOString(),
        functionName: functionName,
        action: action,
        userId: userId || "anonymous",
        resource: resource,
        outcome: outcome,
        details: sanitizeForLogging(details),
        requestId: process.env.AWS_LAMBDA_LOG_STREAM_NAME,
        traceId: process.env._X_AMZN_TRACE_ID
      };

      // Structured JSON log — CloudWatch Insights can query this
      console.log(JSON.stringify(auditEntry));
    }
  };
}

function sanitizeForLogging(obj) {
  if (!obj) return obj;
  var sanitized = JSON.parse(JSON.stringify(obj));
  var sensitiveFields = ["password", "ssn", "creditCard", "token", "secret", "apiKey"];

  Object.keys(sanitized).forEach(function(key) {
    if (sensitiveFields.indexOf(key.toLowerCase()) !== -1) {
      sanitized[key] = "[REDACTED]";
    }
    if (typeof sanitized[key] === "object" && sanitized[key] !== null) {
      sanitized[key] = sanitizeForLogging(sanitized[key]);
    }
  });

  return sanitized;
}

// Usage in handler
var audit = createAuditLogger("processOrder");

exports.handler = function(event, context) {
  var userId = event.requestContext.authorizer.userId;
  var body = JSON.parse(event.body);

  audit.logAction("ORDER_CREATE", userId, "orders", {
    itemCount: body.items.length,
    totalAmount: body.totalAmount
  }, "INITIATED");

  return processOrder(body).then(function(result) {
    audit.logAction("ORDER_CREATE", userId, "orders/" + result.orderId, {
      orderId: result.orderId
    }, "SUCCESS");

    return { statusCode: 201, body: JSON.stringify(result) };
  }).catch(function(err) {
    audit.logAction("ORDER_CREATE", userId, "orders", {
      error: err.message
    }, "FAILURE");

    return { statusCode: 500, body: JSON.stringify({ error: "Order processing failed" }) };
  });
};

Set CloudWatch Logs retention policies explicitly. The default is to retain logs forever, which incurs ongoing costs and may violate data retention policies:

Resources:
  ProcessOrderLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${ProcessOrderFunction}"
      RetentionInDays: 90

Supply Chain Security

Supply chain attacks on npm packages are a real and growing threat. The event-stream incident, ua-parser-js compromise, and colors.js sabotage all demonstrated that any package in your dependency tree can become an attack vector overnight.

Protect your serverless functions with these measures:

// scripts/verify-lockfile.js
var fs = require("fs");
var crypto = require("crypto");

function verifyLockfile() {
  var lockfile = fs.readFileSync("package-lock.json", "utf8");
  var lock = JSON.parse(lockfile);

  var issues = [];

  function checkPackage(name, pkg) {
    // Flag packages with install scripts
    if (pkg.hasInstallScript) {
      issues.push("INSTALL_SCRIPT: " + name + " has lifecycle scripts");
    }

    // Flag packages from non-registry sources
    if (pkg.resolved && !pkg.resolved.startsWith("https://registry.npmjs.org/")) {
      issues.push("NON_REGISTRY: " + name + " resolves to " + pkg.resolved);
    }

    // Flag packages without integrity hashes
    if (!pkg.integrity) {
      issues.push("NO_INTEGRITY: " + name + " has no integrity hash");
    }
  }

  var packages = lock.packages || {};
  Object.keys(packages).forEach(function(path) {
    if (path === "") return; // skip root
    var name = path.replace("node_modules/", "");
    checkPackage(name, packages[path]);
  });

  if (issues.length > 0) {
    console.error("Supply chain issues found:");
    issues.forEach(function(issue) {
      console.error("  - " + issue);
    });
    if (issues.some(function(i) { return i.startsWith("NON_REGISTRY"); })) {
      process.exit(1);
    }
  } else {
    console.log("Lockfile verification passed.");
  }
}

verifyLockfile();

Lock your npm registry to the official registry in .npmrc:

registry=https://registry.npmjs.org/
package-lock=true
ignore-scripts=true

The ignore-scripts=true setting prevents lifecycle scripts from running during npm install. This blocks the most common supply chain attack vector but may break some legitimate packages. Test thoroughly before enabling in production.

Runtime Security Monitoring

Even with all preventive measures in place, you need runtime monitoring to detect anomalies and active attacks. AWS Lambda supports custom metrics, alarms, and anomaly detection through CloudWatch.

var AWS = require("aws-sdk");
var cloudwatch = new AWS.CloudWatch();

function publishSecurityMetric(metricName, value, dimensions) {
  var params = {
    Namespace: "ServerlessSecurity",
    MetricData: [{
      MetricName: metricName,
      Value: value,
      Unit: "Count",
      Timestamp: new Date(),
      Dimensions: dimensions || []
    }]
  };

  return cloudwatch.putMetricData(params).promise().catch(function(err) {
    console.error("Failed to publish metric:", err.message);
  });
}

function securityMiddleware(handler) {
  return function(event, context) {
    var startTime = Date.now();
    var sourceIp = event.requestContext &&
      event.requestContext.identity &&
      event.requestContext.identity.sourceIp;

    // Track request patterns
    publishSecurityMetric("IncomingRequests", 1, [
      { Name: "FunctionName", Value: context.functionName },
      { Name: "SourceIP", Value: sourceIp || "unknown" }
    ]);

    // Detect payload size anomalies
    var bodySize = event.body ? Buffer.byteLength(event.body) : 0;
    if (bodySize > 100000) { // 100KB threshold
      publishSecurityMetric("OversizedPayload", 1, [
        { Name: "FunctionName", Value: context.functionName }
      ]);
      console.warn("Oversized payload detected: " + bodySize + " bytes from " + sourceIp);
    }

    return Promise.resolve()
      .then(function() {
        return handler(event, context);
      })
      .then(function(result) {
        var duration = Date.now() - startTime;

        // Detect timing anomalies (possible crypto mining or data exfiltration)
        if (duration > 25000) {
          publishSecurityMetric("LongRunningExecution", 1, [
            { Name: "FunctionName", Value: context.functionName }
          ]);
        }

        return result;
      })
      .catch(function(err) {
        publishSecurityMetric("FunctionErrors", 1, [
          { Name: "FunctionName", Value: context.functionName },
          { Name: "ErrorType", Value: err.name || "UnknownError" }
        ]);
        throw err;
      });
  };
}

// Usage
exports.handler = securityMiddleware(function(event, context) {
  // Your actual handler logic here
  return processRequest(event);
});

Set up CloudWatch Alarms on these custom metrics to trigger SNS notifications when anomalous patterns emerge.

OWASP Serverless Top 10

The OWASP Serverless Top 10 identifies the most critical security risks specific to serverless architectures. Here is a mapping of each risk to the mitigation strategies covered in this article:

  1. Injection — Use parameterized queries and input validation with Joi or similar libraries. Never concatenate user input into queries or commands.

  2. Broken Authentication — Use API Gateway authorizers (Lambda or Cognito). Validate JWT claims including issuer, audience, and expiration.

  3. Sensitive Data Exposure — Encrypt at rest with KMS. Redact sensitive fields in logs. Never store secrets in environment variables.

  4. XML External Entities (XXE) — Disable external entity processing in any XML parser. Prefer JSON over XML.

  5. Broken Access Control — Implement function-level isolation with dedicated IAM roles. Validate user permissions in handler code, not just at the gateway.

  6. Security Misconfiguration — Use Infrastructure as Code (SAM, CloudFormation, Terraform) to enforce consistent security configurations. Never manually configure resources in the console.

  7. Cross-Site Scripting — Sanitize all output if your serverless functions generate HTML. Use sanitize-html for server-side rendering.

  8. Insecure Deserialization — Validate all deserialized data with schema validation. Never use eval() or Function() on user input.

  9. Using Components with Known Vulnerabilities — Run npm audit in CI. Pin dependency versions with lockfiles. Monitor for supply chain attacks.

  10. Insufficient Logging and Monitoring — Emit structured JSON logs. Set up CloudWatch alarms. Retain logs for compliance periods. Include request IDs and trace IDs in all log entries.

Complete Working Example

Here is a security-hardened serverless API that processes orders with IAM least privilege, input validation, secrets rotation, and comprehensive audit logging.

# template.yaml (AWS SAM)
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Security-hardened order processing API

Globals:
  Function:
    Runtime: nodejs18.x
    Timeout: 30
    MemorySize: 256
    Tracing: Active
    Environment:
      Variables:
        NODE_ENV: production
        LOG_LEVEL: info

Resources:
  ApiGateway:
    Type: AWS::Serverless::Api
    Properties:
      StageName: prod
      Auth:
        DefaultAuthorizer: TokenAuthorizer
        Authorizers:
          TokenAuthorizer:
            FunctionArn: !GetAtt AuthorizerFunction.Arn
            FunctionPayloadType: TOKEN
            Identity:
              Header: Authorization
              ReauthorizeEvery: 300

  AuthorizerFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/authorizer.handler
      CodeUri: ./
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action: ssm:GetParameter
              Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/prod/jwt-signing-key"

  CreateOrderFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: src/handlers/createOrder.handler
      CodeUri: ./
      Events:
        CreateOrder:
          Type: Api
          Properties:
            RestApiId: !Ref ApiGateway
            Path: /orders
            Method: POST
      Policies:
        - Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - dynamodb:PutItem
              Resource: !GetAtt OrdersTable.Arn
            - Effect: Allow
              Action:
                - secretsmanager:GetSecretValue
              Resource: !Ref PaymentSecret
            - Effect: Allow
              Action:
                - kms:Decrypt
              Resource: !GetAtt OrdersTableKey.Arn

  OrdersTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: orders-prod
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: orderId
          AttributeType: S
      KeySchema:
        - AttributeName: orderId
          KeyType: HASH
      SSESpecification:
        SSEEnabled: true
        SSEType: KMS
        KMSMasterKeyId: !Ref OrdersTableKey

  OrdersTableKey:
    Type: AWS::KMS::Key
    Properties:
      Description: Encryption key for orders table

  PaymentSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: prod/payment-gateway
      GenerateSecretString:
        SecretStringTemplate: '{"provider": "stripe"}'
        GenerateStringKey: apiKey
        PasswordLength: 64

  CreateOrderLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/aws/lambda/${CreateOrderFunction}"
      RetentionInDays: 90
// src/handlers/createOrder.js
var AWS = require("aws-sdk");
var Joi = require("joi");
var crypto = require("crypto");

var dynamodb = new AWS.DynamoDB.DocumentClient();
var secretsManager = new AWS.SecretsManager();

var cachedPaymentKey = null;
var cacheExpiry = 0;

var orderSchema = Joi.object({
  items: Joi.array().items(
    Joi.object({
      productId: Joi.string().alphanum().max(64).required(),
      quantity: Joi.number().integer().min(1).max(100).required(),
      unitPrice: Joi.number().positive().precision(2).max(99999).required()
    })
  ).min(1).max(50).required(),
  shippingAddress: Joi.object({
    street: Joi.string().max(200).required(),
    city: Joi.string().max(100).required(),
    state: Joi.string().length(2).uppercase().required(),
    zip: Joi.string().pattern(/^\d{5}(-\d{4})?$/).required(),
    country: Joi.string().length(2).uppercase().default("US")
  }).required(),
  paymentMethodId: Joi.string().max(128).required()
});

function getPaymentKey() {
  var now = Date.now();
  if (cachedPaymentKey && now < cacheExpiry) {
    return Promise.resolve(cachedPaymentKey);
  }

  return secretsManager.getSecretValue({
    SecretId: "prod/payment-gateway"
  }).promise().then(function(data) {
    var secret = JSON.parse(data.SecretString);
    cachedPaymentKey = secret.apiKey;
    cacheExpiry = now + 300000;
    return cachedPaymentKey;
  });
}

function sanitizeForLog(data) {
  var safe = JSON.parse(JSON.stringify(data));
  if (safe.paymentMethodId) {
    safe.paymentMethodId = safe.paymentMethodId.substring(0, 4) + "****";
  }
  return safe;
}

exports.handler = function(event, context) {
  var requestId = context.awsRequestId;
  var userId = event.requestContext.authorizer.userId;
  var startTime = Date.now();

  // Parse and validate input
  var body;
  try {
    body = JSON.parse(event.body);
  } catch (e) {
    console.log(JSON.stringify({
      level: "WARN",
      action: "ORDER_CREATE",
      requestId: requestId,
      userId: userId,
      outcome: "INVALID_JSON",
      timestamp: new Date().toISOString()
    }));
    return Promise.resolve({
      statusCode: 400,
      headers: {
        "Content-Type": "application/json",
        "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
        "X-Content-Type-Options": "nosniff",
        "X-Frame-Options": "DENY"
      },
      body: JSON.stringify({ error: "Invalid JSON body" })
    });
  }

  var validation = orderSchema.validate(body, {
    abortEarly: false,
    stripUnknown: true
  });

  if (validation.error) {
    var errors = validation.error.details.map(function(d) { return d.message; });
    console.log(JSON.stringify({
      level: "WARN",
      action: "ORDER_CREATE",
      requestId: requestId,
      userId: userId,
      outcome: "VALIDATION_FAILED",
      errors: errors,
      timestamp: new Date().toISOString()
    }));
    return Promise.resolve({
      statusCode: 400,
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ error: "Validation failed", details: errors })
    });
  }

  var validatedOrder = validation.value;
  var orderId = crypto.randomUUID();

  var totalAmount = validatedOrder.items.reduce(function(sum, item) {
    return sum + (item.quantity * item.unitPrice);
  }, 0);

  console.log(JSON.stringify({
    level: "INFO",
    action: "ORDER_CREATE",
    requestId: requestId,
    userId: userId,
    orderId: orderId,
    outcome: "INITIATED",
    itemCount: validatedOrder.items.length,
    totalAmount: totalAmount,
    timestamp: new Date().toISOString()
  }));

  return getPaymentKey()
    .then(function(apiKey) {
      // Process payment (mock — replace with real payment provider)
      return { paymentId: "pay_" + crypto.randomBytes(16).toString("hex") };
    })
    .then(function(payment) {
      var orderRecord = {
        orderId: orderId,
        userId: userId,
        items: validatedOrder.items,
        shippingAddress: validatedOrder.shippingAddress,
        totalAmount: totalAmount,
        paymentId: payment.paymentId,
        status: "confirmed",
        createdAt: new Date().toISOString(),
        updatedAt: new Date().toISOString()
      };

      return dynamodb.put({
        TableName: "orders-prod",
        Item: orderRecord,
        ConditionExpression: "attribute_not_exists(orderId)"
      }).promise().then(function() {
        return orderRecord;
      });
    })
    .then(function(order) {
      var duration = Date.now() - startTime;

      console.log(JSON.stringify({
        level: "INFO",
        action: "ORDER_CREATE",
        requestId: requestId,
        userId: userId,
        orderId: orderId,
        outcome: "SUCCESS",
        durationMs: duration,
        timestamp: new Date().toISOString()
      }));

      return {
        statusCode: 201,
        headers: {
          "Content-Type": "application/json",
          "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
          "X-Content-Type-Options": "nosniff",
          "X-Frame-Options": "DENY",
          "Cache-Control": "no-store"
        },
        body: JSON.stringify({
          orderId: order.orderId,
          status: order.status,
          totalAmount: order.totalAmount,
          createdAt: order.createdAt
        })
      };
    })
    .catch(function(err) {
      var duration = Date.now() - startTime;

      console.error(JSON.stringify({
        level: "ERROR",
        action: "ORDER_CREATE",
        requestId: requestId,
        userId: userId,
        orderId: orderId,
        outcome: "FAILURE",
        error: err.message,
        errorCode: err.code,
        durationMs: duration,
        timestamp: new Date().toISOString()
      }));

      return {
        statusCode: 500,
        headers: {
          "Content-Type": "application/json",
          "Strict-Transport-Security": "max-age=31536000; includeSubDomains",
          "X-Content-Type-Options": "nosniff"
        },
        body: JSON.stringify({ error: "Order processing failed" })
      };
    });
};

This handler demonstrates every principle covered in this article: validated input with stripUnknown, secrets retrieved from Secrets Manager with caching, structured audit logs with sanitized data, security headers on all responses, and a deployment template with least-privilege IAM.

Common Issues and Troubleshooting

1. Lambda Times Out When Accessing Secrets Manager in VPC

Task timed out after 30.00 seconds

This happens when your Lambda function is in a VPC but has no route to the Secrets Manager endpoint. Either add a NAT Gateway to your private subnet or create a VPC Interface Endpoint for com.amazonaws.{region}.secretsmanager. The VPC endpoint approach is cheaper and faster.

2. Access Denied on KMS Decrypt

AccessDeniedException: User: arn:aws:sts::123456789:assumed-role/MyFunction-role/MyFunction
is not authorized to perform: kms:Decrypt on resource: arn:aws:kms:us-east-1:123456789:key/abc-123

Your Lambda execution role lacks kms:Decrypt permission for the specific KMS key. Add the key ARN to the role's policy. A common mistake is granting DynamoDB access but forgetting that a KMS-encrypted table also requires KMS permissions.

3. Authorizer Response Malformed

Execution failed due to configuration error: Authorizer response malformed

Your Lambda authorizer is returning an invalid policy document. The principalId field is required and must be a string. The policyDocument must include a valid IAM policy with Version, Statement, Action, Effect, and Resource fields. Double-check that your Resource ARN matches the API Gateway method ARN format.

4. npm Audit Fails in CI with ENOLOCK

npm ERR! code ENOLOCK
npm ERR! audit This command requires an existing lockfile.

Your CI pipeline is missing package-lock.json. Either commit the lockfile to version control (recommended) or run npm install before npm audit. Never add package-lock.json to .gitignore — the lockfile is essential for reproducible builds and supply chain verification.

5. Cold Start Latency Spike After Adding VPC

Duration: 12847.23 ms   Init Duration: 11203.44 ms

VPC-attached Lambda functions historically had 10+ second cold starts due to ENI (Elastic Network Interface) creation. AWS improved this significantly with Hyperplane ENIs, but cold starts in VPC can still be 1-3 seconds longer. Use Provisioned Concurrency for latency-sensitive functions, or consider whether VPC attachment is truly necessary for your use case.

Best Practices

  • One function, one role. Never share IAM execution roles across Lambda functions. Each function should have the minimum permissions it needs and nothing more. Use IAM Access Analyzer to identify and remove unused permissions.

  • Validate everything, trust nothing. Treat every event source as untrusted input, including SQS messages and DynamoDB streams. Use schema validation libraries like Joi to enforce strict contracts on all inputs.

  • Never store secrets in environment variables or code. Use Secrets Manager or SSM Parameter Store with encryption. Cache secrets in memory with a TTL to balance security with performance.

  • Pin your dependencies and verify the supply chain. Use package-lock.json, run npm audit in CI, and monitor for unexpected changes in transitive dependencies. Consider setting ignore-scripts=true in .npmrc to prevent malicious install scripts.

  • Emit structured audit logs with every request. Include the request ID, user ID, action, outcome, and duration in every log entry. Redact sensitive fields before logging. Set explicit log retention periods.

  • Add security headers to every API response. Include Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, and Cache-Control: no-store on responses containing sensitive data.

  • Encrypt everything at rest with customer-managed KMS keys. Default AWS encryption is better than nothing, but customer-managed keys give you rotation control, audit trails, and the ability to revoke access immediately.

  • Use VPC endpoints instead of NAT Gateways for AWS service access. VPC endpoints are cheaper, faster, and do not route traffic over the public internet. Create endpoints for every AWS service your Lambda functions call.

  • Monitor runtime behavior with custom CloudWatch metrics. Track execution duration, payload sizes, error rates, and authentication failures. Set up alarms to catch anomalies early.

References

Powered by Contentful