Skip to content

Securing Serverless Functions: Defense Patterns

Understand the security challenges unique to serverless architectures and apply practical defense patterns for AWS Lambda, Azure Functions and friends.

5 min read
Security shield diagram showing serverless function attack surfaces and defensive layers

Serverless doesn't mean security-less. While cloud providers handle infrastructure security, the application layer is entirely your responsibility. Serverless functions introduce unique attack surfaces that traditional security models don't address well.

The ephemeral nature of functions, shared execution environments, and event-driven invocation patterns create security challenges that require different thinking. You can't just port your container security playbook—you need strategies designed for this paradigm.

The Serverless Attack Surface

Serverless functions receive events from diverse sources: HTTP requests, message queues, storage triggers, scheduled events. Each source is a potential attack vector, and many developers only validate HTTP inputs.

tstypescript
// ❌ Only considering HTTP input validation
export const handler = async (event: APIGatewayEvent) => {
  const body = JSON.parse(event.body ?? "{}");
  // Only validates API Gateway events
  const name = body.name;
  return { statusCode: 200, body: `Hello ${name}` };
};
tstypescript
// ✅ Validating all event sources
import { z } from "zod";
 
const UserInputSchema = z.object({
  name: z.string().min(1).max(100).regex(/^[\w\s-]+$/),
  email: z.string().email().max(254),
  action: z.enum(["create", "update", "delete"]),
});
 
type ValidatedInput = z.infer<typeof UserInputSchema>;
 
function extractInput(event: unknown): Record<string, unknown> {
  if (isApiGatewayEvent(event)) {
    return JSON.parse((event as any).body ?? "{}");
  }
  if (isSqsEvent(event)) {
    const records = (event as any).Records ?? [];
    return JSON.parse(records[0]?.body ?? "{}");
  }
  if (isS3Event(event)) {
    return {
      bucket: (event as any).Records?.[0]?.s3?.bucket?.name,
      key: (event as any).Records?.[0]?.s3?.object?.key,
    };
  }
  throw new Error("Unknown event source");
}
 
export const handler = async (event: unknown) => {
  const rawInput = extractInput(event);
  const input = UserInputSchema.parse(rawInput);
  return processValidatedInput(input);
};
 
function isApiGatewayEvent(event: unknown): boolean {
  return typeof event === "object" && event !== null && "httpMethod" in event;
}
 
function isSqsEvent(event: unknown): boolean {
  return typeof event === "object" && event !== null &&
    "Records" in event &&
    Array.isArray((event as any).Records) &&
    (event as any).Records[0]?.eventSource === "aws:sqs";
}
 
function isS3Event(event: unknown): boolean {
  return typeof event === "object" && event !== null &&
    "Records" in event &&
    Array.isArray((event as any).Records) &&
    (event as any).Records[0]?.eventSource === "aws:s3";
}
 
async function processValidatedInput(input: ValidatedInput) {
  return { statusCode: 200, body: JSON.stringify({ success: true }) };
}

Every event source deserves the same validation rigor. An SQS message crafted by a compromised upstream service is just as dangerous as a malicious HTTP request.

Implementing Least Privilege IAM Policies

The most common serverless security mistake is overly permissive IAM roles. A function that reads from one DynamoDB table shouldn't have write access to every table in your account.

ymlyaml
# ❌ Overly permissive policy
Resources:
  ProcessOrderFunction:
    Type: AWS::Serverless::Function
    Properties:
      Policies:
        - AmazonDynamoDBFullAccess  # Access to ALL tables
        - AmazonS3FullAccess        # Access to ALL buckets
        - AmazonSQSFullAccess       # Access to ALL queues
ymlyaml
# ✅ Least privilege per function
Resources:
  ProcessOrderFunction:
    Type: AWS::Serverless::Function
    Properties:
      Policies:
        - Statement:
            - Effect: Allow
              Action:
                - dynamodb:GetItem
                - dynamodb:PutItem
                - dynamodb:UpdateItem
              Resource:
                - !GetAtt OrdersTable.Arn
            - Effect: Allow
              Action:
                - s3:GetObject
              Resource:
                - !Sub "${OrderBucketArn}/*"
            - Effect: Allow
              Action:
                - sqs:SendMessage
              Resource:
                - !GetAtt NotificationQueue.Arn

Each function gets its own role with only the permissions it needs. This limits blast radius—if one function is compromised, the attacker can only access that function's resources.

Protecting Against Injection in Serverless Contexts

Injection attacks in serverless go beyond SQL. NoSQL injection, OS command injection through runtime exec calls, and even event injection through malformed payloads are all real threats.

tstypescript
// ❌ Vulnerable to NoSQL injection
import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb";
 
const client = new DynamoDBClient({});
 
export const handler = async (event: any) => {
  const userId = JSON.parse(event.body).userId;
 
  // If userId contains special characters or operators,
  // this could return unintended data
  const command = new QueryCommand({
    TableName: "Users",
    KeyConditionExpression: `userId = :uid`,
    ExpressionAttributeValues: {
      ":uid": { S: userId }, // Unsanitized input
    },
  });
 
  return client.send(command);
};
tstypescript
// ✅ Sanitized and validated input with parameterized queries
import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb";
import { z } from "zod";
 
const client = new DynamoDBClient({});
 
const QuerySchema = z.object({
  userId: z
    .string()
    .uuid()
    .max(36),
});
 
export const handler = async (event: { body?: string }) => {
  const parsed = QuerySchema.safeParse(
    JSON.parse(event.body ?? "{}")
  );
 
  if (!parsed.success) {
    return {
      statusCode: 400,
      body: JSON.stringify({ error: "Invalid input" }),
    };
  }
 
  const command = new QueryCommand({
    TableName: "Users",
    KeyConditionExpression: "userId = :uid",
    ExpressionAttributeValues: {
      ":uid": { S: parsed.data.userId },
    },
  });
 
  const result = await client.send(command);
  return {
    statusCode: 200,
    body: JSON.stringify(result.Items),
  };
};

The pattern is consistent: parse and validate before any operation. Zod schemas at function boundaries ensure only expected data shapes reach your business logic.

Managing Secrets in Serverless Environments

Environment variables are the default for secrets in serverless, but they're visible in the console, logged in deployment outputs, and shared across all invocations. Use a secrets manager instead.

tstypescript
// ❌ Secrets in environment variables
const API_KEY = process.env.THIRD_PARTY_API_KEY;
// Visible in CloudWatch logs if accidentally logged
// Exposed in CloudFormation outputs
// Rotated by redeploying every function
 
// ✅ Secrets from AWS Secrets Manager with caching
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
 
const secretsClient = new SecretsManagerClient({});
 
let cachedSecrets: Record<string, string> | null = null;
let cacheExpiry = 0;
 
async function getSecret(secretName: string): Promise<string> {
  const now = Date.now();
 
  if (cachedSecrets && now < cacheExpiry) {
    const value = cachedSecrets[secretName];
    if (value !== undefined) return value;
  }
 
  const command = new GetSecretValueCommand({
    SecretId: secretName,
  });
 
  const response = await secretsClient.send(command);
  const secretValue = response.SecretString;
 
  if (!secretValue) {
    throw new Error(`Secret ${secretName} not found`);
  }
 
  cachedSecrets = JSON.parse(secretValue);
  cacheExpiry = now + 5 * 60 * 1000; // Cache for 5 minutes
 
  const result = cachedSecrets?.[secretName];
  if (!result) {
    throw new Error(`Key ${secretName} not in secret`);
  }
  return result;
}

Caching secrets across warm invocations is essential for performance. Without caching, every invocation adds latency for the secrets API call. The 5-minute cache window balances freshness with performance.

Rate Limiting and Abuse Prevention

Serverless auto-scaling is a double-edged sword. An attacker can trigger thousands of invocations, driving up costs and potentially exhausting downstream resources.

tstypescript
import { DynamoDBClient, UpdateItemCommand } from "@aws-sdk/client-dynamodb";
 
const dynamoClient = new DynamoDBClient({});
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
async function checkRateLimit(
  clientId: string,
  maxRequests: number = 100,
  windowSeconds: number = 60
): Promise<RateLimitResult> {
  const windowStart = Math.floor(Date.now() / 1000 / windowSeconds);
  const key = `${clientId}:${windowStart}`;
  const ttl = windowStart * windowSeconds + windowSeconds * 2;
 
  const command = new UpdateItemCommand({
    TableName: "RateLimits",
    Key: { pk: { S: key } },
    UpdateExpression:
      "SET requestCount = if_not_exists(requestCount, :zero) + :inc, " +
      "expiresAt = :ttl",
    ExpressionAttributeValues: {
      ":zero": { N: "0" },
      ":inc": { N: "1" },
      ":ttl": { N: String(ttl) },
    },
    ReturnValues: "ALL_NEW",
  });
 
  const result = await dynamoClient.send(command);
  const count = parseInt(
    result.Attributes?.requestCount?.N ?? "0", 10
  );
 
  return {
    allowed: count <= maxRequests,
    remaining: Math.max(0, maxRequests - count),
    resetAt: (windowStart + 1) * windowSeconds,
  };
}
 
export const handler = async (event: {
  requestContext?: { identity?: { sourceIp?: string } };
  body?: string;
}) => {
  const clientIp = event.requestContext?.identity?.sourceIp ?? "unknown";
  const rateLimit = await checkRateLimit(clientIp);
 
  if (!rateLimit.allowed) {
    return {
      statusCode: 429,
      headers: {
        "Retry-After": String(
          rateLimit.resetAt - Math.floor(Date.now() / 1000)
        ),
      },
      body: JSON.stringify({ error: "Rate limit exceeded" }),
    };
  }
 
  // Process request normally
  return { statusCode: 200, body: JSON.stringify({ success: true }) };
};

DynamoDB-based rate limiting works well for serverless because it's itself serverless—no additional infrastructure to manage. The TTL attribute automatically cleans up expired windows.

Securing Function Dependencies

Your function's security is only as strong as its weakest dependency. Supply chain attacks target npm packages that serverless functions commonly use.

jsonjson
{
  "scripts": {
    "audit": "npm audit --production",
    "audit:fix": "npm audit fix --production",
    "preinstall": "npx npm-force-resolutions",
    "deps:check": "npx depcheck --ignores='@types/*'"
  }
}
tstypescript
// Automated dependency scanning in CI
// .github/workflows/security.yml equivalent logic
 
interface DependencyCheckResult {
  package: string;
  version: string;
  severity: "low" | "moderate" | "high" | "critical";
  advisory: string;
  fixAvailable: boolean;
}
 
function shouldBlockDeploy(
  results: DependencyCheckResult[]
): boolean {
  const criticalOrHigh = results.filter(
    r => r.severity === "critical" || r.severity === "high"
  );
 
  if (criticalOrHigh.length > 0) {
    console.error("Blocking deploy due to vulnerabilities:");
    for (const vuln of criticalOrHigh) {
      console.error(
        `  ${vuln.package}@${vuln.version}: ${vuln.severity} - ${vuln.advisory}`
      );
    }
    return true;
  }
 
  return false;
}

Lock files matter even more in serverless than in traditional applications. A compromised dependency in a serverless function has direct access to your IAM role's permissions—making supply chain attacks particularly dangerous in this context.

Key Takeaways

Serverless security requires a shift in mindset from perimeter defense to per-function defense. Every function is an independent security boundary with its own IAM role, its own input validation, and its own attack surface. The shared responsibility model means the cloud provider secures the infrastructure, but you secure everything from the function code upward.

Start with least-privilege IAM policies and strict input validation on every event source—not just HTTP. Add secrets management through dedicated services rather than environment variables. Implement rate limiting to prevent cost-based denial of service. And treat your dependency supply chain as an attack vector that deserves continuous monitoring. The serverless model gives you powerful scalability, but only disciplined security practices keep that power from becoming a liability.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX