Skip to content

Serverless Security: Attack Surfaces and Mitigations

The security challenges unique to serverless: injection through event sources, over-permissioned functions and dependency risk, with practical mitigations.

5 min read
Serverless architecture diagram with attack surface annotations and security control points

The Serverless Security Misconception

Serverless does not mean secure. The shared responsibility model shifts infrastructure security to the cloud provider, but application security—input validation, authorization, secrets management, dependency hygiene—remains entirely your problem. In many ways, serverless introduces new attack surfaces that traditional architectures do not have.

Functions triggered by diverse event sources (API Gateway, S3 events, SQS messages, CloudWatch events) create injection opportunities that developers accustomed to HTTP-only input validation miss entirely. Each event source is an attack surface.

Event Source Injection Attacks

In traditional applications, input comes through HTTP requests. In serverless, input arrives through event objects from many different sources. Each source has different data shapes and different injection vectors.

tstypescript
// ❌ Trusting event data without validation
export async function handler(event: S3Event): Promise<void> {
  const bucket = event.Records[0].s3.bucket.name;
  const key = event.Records[0].s3.object.key;
 
  // Dangerous: key could contain path traversal characters
  const localPath = `/tmp/${key}`;
  await downloadFile(bucket, key, localPath);
 
  // Dangerous: passing unvalidated key to shell command
  await exec(`convert ${localPath} -resize 200x200 ${localPath}.thumb.jpg`);
}
 
// ✅ Validating and sanitizing event source data
import { z } from "zod";
import path from "path";
 
const s3EventSchema = z.object({
  Records: z.array(
    z.object({
      s3: z.object({
        bucket: z.object({
          name: z.string().regex(/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/),
        }),
        object: z.object({
          key: z.string().max(1024),
        }),
      }),
    })
  ),
});
 
export async function secureHandler(event: unknown): Promise<void> {
  const parsed = s3EventSchema.parse(event);
  const record = parsed.Records[0];
 
  const key = record.s3.object.key;
  const bucket = record.s3.bucket.name;
 
  // Validate key doesn't contain path traversal
  const sanitizedKey = path.basename(key);
  if (sanitizedKey !== key || key.includes("..")) {
    throw new Error(`Invalid S3 key: ${key}`);
  }
 
  // Validate file extension
  const allowedExtensions = [".jpg", ".jpeg", ".png", ".webp"];
  const ext = path.extname(sanitizedKey).toLowerCase();
  if (!allowedExtensions.includes(ext)) {
    throw new Error(`Unsupported file type: ${ext}`);
  }
 
  const localPath = path.join("/tmp", sanitizedKey);
  await downloadFile(bucket, sanitizedKey, localPath);
 
  // Use array form to avoid shell injection
  await execFile("convert", [
    localPath,
    "-resize",
    "200x200",
    `${localPath}.thumb.jpg`,
  ]);
}

The secure version validates the event structure, sanitizes the S3 key against path traversal, validates file extensions, and uses execFile instead of exec to prevent shell injection.

Least-Privilege IAM Policies

Over-permissioned Lambda functions are one of the most common serverless security issues. A function that processes images should not have access to DynamoDB, SES, or any service it does not use.

tstypescript
// ❌ Over-permissioned: function can do anything
const overPermissioned = {
  Effect: "Allow",
  Action: "*",
  Resource: "*",
};
 
// ✅ Least-privilege: only the permissions the function needs
interface LambdaPermission {
  functionName: string;
  permissions: {
    service: string;
    actions: string[];
    resources: string[];
    conditions?: Record<string, Record<string, string>>;
  }[];
}
 
const imageProcessorPermissions: LambdaPermission = {
  functionName: "image-processor",
  permissions: [
    {
      service: "s3",
      actions: ["s3:GetObject"],
      resources: ["arn:aws:s3:::upload-bucket/*"],
      conditions: {
        StringEquals: {
          "s3:ExistingObjectTag/validated": "true",
        },
      },
    },
    {
      service: "s3",
      actions: ["s3:PutObject"],
      resources: ["arn:aws:s3:::processed-bucket/*"],
      conditions: {
        StringEquals: {
          "s3:x-amz-server-side-encryption": "aws:kms",
        },
      },
    },
    {
      service: "logs",
      actions: [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
      ],
      resources: [
        "arn:aws:logs:us-east-1:123456789:log-group:/aws/lambda/image-processor:*",
      ],
    },
  ],
};

Each function gets only the permissions it needs, scoped to the specific resources it operates on. Conditions further restrict access—the image processor can only read objects tagged as validated and must use encryption when writing.

Secrets Management in Serverless

Environment variables are the default way to pass configuration to Lambda functions, but they are visible in the AWS console and in CloudFormation templates. Secrets should be fetched at runtime from a secrets manager.

tstypescript
import {
  SecretsManagerClient,
  GetSecretValueCommand,
} from "@aws-sdk/client-secrets-manager";
 
// Cache secrets to avoid calling Secrets Manager on every invocation
let cachedSecrets: Record<string, string> | null = null;
let cacheTimestamp = 0;
const CACHE_TTL = 300000; // 5 minutes
 
async function getSecrets(): Promise<Record<string, string>> {
  if (cachedSecrets && Date.now() - cacheTimestamp < CACHE_TTL) {
    return cachedSecrets;
  }
 
  const client = new SecretsManagerClient({});
  const command = new GetSecretValueCommand({
    SecretId: process.env.SECRET_ARN,
  });
 
  const response = await client.send(command);
 
  if (!response.SecretString) {
    throw new Error("Secret value is empty");
  }
 
  cachedSecrets = JSON.parse(response.SecretString);
  cacheTimestamp = Date.now();
  return cachedSecrets!;
}
 
// Usage in handler
export async function handler(event: APIGatewayEvent): Promise<APIGatewayResult> {
  const secrets = await getSecrets();
  const dbConnection = await connectToDatabase(secrets.DATABASE_URL);
  // Process request...
}

The function only has the ARN of the secret in its environment variables—the actual secret values are fetched at runtime and cached for the duration of the Lambda execution environment.

Function-Level Authorization

Each serverless function should independently verify authorization. Do not rely solely on API Gateway authorizers—defense in depth means every layer checks permissions.

tstypescript
interface AuthContext {
  userId: string;
  roles: string[];
  permissions: string[];
  tenantId: string;
}
 
function extractAuthContext(event: APIGatewayEvent): AuthContext {
  const claims = event.requestContext.authorizer?.claims;
  if (!claims) {
    throw new UnauthorizedError("No authorization context");
  }
 
  return {
    userId: claims.sub,
    roles: (claims["custom:roles"] || "").split(","),
    permissions: (claims["custom:permissions"] || "").split(","),
    tenantId: claims["custom:tenant_id"],
  };
}
 
function requirePermission(
  auth: AuthContext,
  permission: string
): void {
  if (!auth.permissions.includes(permission)) {
    throw new ForbiddenError(
      `Missing required permission: ${permission}`
    );
  }
}
 
function requireTenantAccess(
  auth: AuthContext,
  resourceTenantId: string
): void {
  if (auth.tenantId !== resourceTenantId) {
    throw new ForbiddenError("Cross-tenant access denied");
  }
}
 
// Usage in handler
export async function deleteUserHandler(
  event: APIGatewayEvent
): Promise<APIGatewayResult> {
  const auth = extractAuthContext(event);
  requirePermission(auth, "users:delete");
 
  const userId = event.pathParameters?.userId;
  if (!userId) {
    return { statusCode: 400, body: "Missing userId" };
  }
 
  const user = await getUserById(userId);
  requireTenantAccess(auth, user.tenantId);
 
  await deleteUser(userId);
  return { statusCode: 204, body: "" };
}

Dependency Vulnerability Management

Serverless functions often have minimal code but pull in dozens of dependencies. Each dependency is an attack surface.

tstypescript
// package.json auditing script
import { execSync } from "child_process";
 
interface AuditResult {
  vulnerabilities: {
    critical: number;
    high: number;
    moderate: number;
    low: number;
  };
}
 
function auditDependencies(): AuditResult {
  try {
    const output = execSync("npm audit --json", {
      encoding: "utf-8",
    });
    return JSON.parse(output);
  } catch (error: unknown) {
    // npm audit exits non-zero when vulnerabilities exist
    const err = error as { stdout?: string };
    if (err.stdout) {
      return JSON.parse(err.stdout);
    }
    throw error;
  }
}
 
function enforcePolicy(result: AuditResult): void {
  const { critical, high } = result.vulnerabilities;
 
  if (critical > 0) {
    throw new Error(
      `Deployment blocked: ${critical} critical vulnerabilities found`
    );
  }
 
  if (high > 0) {
    console.warn(
      `Warning: ${high} high-severity vulnerabilities found`
    );
  }
}
 
// Run as CI/CD gate
const audit = auditDependencies();
enforcePolicy(audit);
tstypescript
// Minimize dependencies by auditing what's actually used
import { readFileSync } from "fs";
 
function findUnusedDependencies(
  packageJsonPath: string,
  srcDir: string
): string[] {
  const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
  const deps = Object.keys(pkg.dependencies || {});
  const unused: string[] = [];
 
  for (const dep of deps) {
    const importPattern = new RegExp(
      `(?:require|from)\\s*['"(]${dep.replace("/", "\\/")}`,
    );
 
    const files = getAllSourceFiles(srcDir);
    const isUsed = files.some((file) => {
      const content = readFileSync(file, "utf-8");
      return importPattern.test(content);
    });
 
    if (!isUsed) {
      unused.push(dep);
    }
  }
 
  return unused;
}

Logging Without Leaking

Serverless function logs often contain sensitive data because developers add verbose logging during development and forget to remove it.

tstypescript
// ❌ Logging sensitive data
export async function badHandler(event: APIGatewayEvent) {
  console.log("Received event:", JSON.stringify(event));
  // Logs authorization headers, cookies, user tokens
 
  const user = await authenticateUser(event);
  console.log("Authenticated user:", JSON.stringify(user));
  // Logs email, password hash, session tokens
}
 
// ✅ Structured logging with sensitive field redaction
const SENSITIVE_FIELDS = new Set([
  "password",
  "token",
  "authorization",
  "cookie",
  "secret",
  "creditCard",
  "ssn",
]);
 
function sanitizeForLogging(obj: unknown): unknown {
  if (typeof obj !== "object" || obj === null) return obj;
  if (Array.isArray(obj)) return obj.map(sanitizeForLogging);
 
  const sanitized: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
    if (SENSITIVE_FIELDS.has(key.toLowerCase())) {
      sanitized[key] = "[REDACTED]";
    } else {
      sanitized[key] = sanitizeForLogging(value);
    }
  }
  return sanitized;
}
 
function secureLog(message: string, data?: unknown): void {
  const sanitized = data ? sanitizeForLogging(data) : undefined;
  console.log(JSON.stringify({ message, data: sanitized, timestamp: new Date().toISOString() }));
}

Key Takeaways

Serverless security requires a shift in thinking from perimeter defense to function-level defense. Every event source is an attack surface that needs input validation—not just HTTP requests. Apply least-privilege IAM policies scoped to specific resources and actions for each function.

Fetch secrets at runtime from a secrets manager rather than storing them in environment variables. Implement authorization checks within each function, not just at the API Gateway level. Audit dependencies continuously and block deployments when critical vulnerabilities are found. Sanitize all log output to prevent sensitive data leakage.

The serverless model removes many infrastructure security concerns but concentrates application security decisions into every function invocation. Treat each function as a security boundary.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX