Serverless didn't make security someone else's problem — it redistributed the problem into places most teams weren't watching. The provider now patches the OS and runtime, but you still own the function code, its dependency tree, its execution role, and every event source that can trigger it. The mistake teams make is trying to bolt a traditional VM security model onto functions that live for 200 milliseconds and leave no host to instrument. A better model follows the function through its lifecycle — build, deploy, invoke, runtime — and applies the right control at each stage. This guide takes that approach across AWS Lambda, Azure Functions, and Google Cloud Functions.
Build Phase: The Dependency Tree Is the Attack Surface
A serverless deployment package is a frozen snapshot. Whatever node_modules, site-packages, or JAR files you bundle at build time run unchanged in production until the next deploy — which might be months away. That makes the build the single most important place to scan.
The lesson of Log4Shell (CVE-2021-44228) applies directly: log4j was almost never a direct dependency, so teams without a software bill of materials couldn't answer "which functions are affected?" for days. Generate an SBOM for every function at build and fail the build on unresolved high-severity findings.
# GitHub Actions: scan the function bundle before it ships
- name: Install and build function
run: npm ci --omit=dev
- name: Scan dependencies before deploy
run: safeguard scan --path . --fail-on high
Pin dependencies with a lockfile so the artifact you scanned is the artifact you ship, and prune dev dependencies out of the bundle — they inflate the attack surface for no runtime benefit.
Deploy Phase: Least-Privilege Execution Roles
Every function assumes an identity: an IAM execution role on Lambda, a managed identity on Azure Functions, a service account on Cloud Functions. The default temptation is to reuse one broad role across a whole service. Don't. One role per function contains the blast radius — a compromised function can only reach what its own role allows.
# One narrowly scoped role for one function
data "aws_iam_policy_document" "orders_reader" {
statement {
actions = ["dynamodb:GetItem", "dynamodb:Query"]
resources = [aws_dynamodb_table.orders.arn]
}
}
resource "aws_iam_role_policy" "orders_fn" {
role = aws_iam_role.orders_fn.id
policy = data.aws_iam_policy_document.orders_reader.json
}
Replace wildcard resource ARNs with explicit ones, and never attach managed policies like AWSLambda_FullAccess to a function role. On Azure and GCP, grant the workload identity a single scoped role on the exact resource it touches. These role definitions live in your IaC, where infrastructure-as-code scanning can flag a wildcard before it merges.
Invoke Phase: Every Event Is Untrusted Input
Functions are triggered by events — HTTP payloads, object-storage notifications, queue messages, database streams — and each of those payloads is attacker-influenced input. OWASP's serverless guidance calls out event-data injection specifically because a filename in an S3 event or a field in an SQS message can flow straight into a shell command or query if the handler trusts it.
- Validate at the top of the handler, before the payload reaches any downstream call. Use a schema validator, not ad-hoc string checks.
- Enforce request validation at the gateway (API Gateway models, Azure API Management policies) so malformed payloads are rejected before they cost you an invocation.
- Remember the chain. Functions daisy-chain — gateway → function → queue → function — so an unvalidated field can propagate several hops before anything logs it.
Runtime Phase: Secrets and Observability
The function is running; two things matter now. First, secrets: pull them from a secrets manager at invocation time rather than baking them into environment variables. Environment variables are readable by anyone with configuration-read permissions and are captured in IaC state files. Second, observability: the provider's default logs record that a function was invoked, not what it did. Use the Lambda Extensions API, Azure Functions diagnostic settings, or Cloud Functions logging to capture runtime behavior, and set concurrency limits so a runaway or abused function can't exhaust the account.
Serverless Lifecycle Checklist
| Phase | Control |
|---|---|
| Build | SBOM generated; dependency scan fails build on high severity; lockfile pinned; dev deps pruned |
| Deploy | One scoped role per function; explicit resource ARNs; no *FullAccess policies |
| Invoke | Handler-level input validation; gateway request validation; least-exposed triggers |
| Runtime | Secrets from a vault, not env vars; runtime telemetry captured; concurrency limits set |
How Safeguard Helps
Safeguard secures the phases where serverless risk actually lives. At build time it generates an SBOM for each function through software composition analysis and runs reachability analysis to determine whether a vulnerable package — a transitive log4j or lodash, say — is actually invoked in the function's code path, so you patch what attackers can reach rather than chasing every flagged CVE. The execution roles and event-source configurations in your Terraform are checked by infrastructure-as-code scanning, which flags wildcard permissions and public triggers before merge. Griffin, Safeguard's AI triage engine, factors over-privileged roles and public event sources into its scoring so a low-severity CVE on an internet-facing function is ranked ahead of the same CVE buried behind a private queue. Because it runs through the pipeline-native Safeguard CLI, you can enforce all of this as a merge gate. If you're comparing this build-time model to a runtime-only serverless scanner, the Safeguard vs Snyk comparison lays out the difference.
Secure your functions from the first commit — create a free account or read the documentation.