Serverless & Edge Security
Every other page in this course eventually points you at something with a filesystem: a container image to scan, a node to harden, a cluster boundary to draw a policy around. A Lambda-style function has none of that — no OS you patch, no long-lived process an EDR agent can watch, often no persistent disk at all. An edge/CDN function goes a step further and removes the one thing serverless still kept: your own account boundary, replacing it with a vendor's global network of points of presence you'll never SSH into. This page is about what fills that vacuum — the execution role that becomes the actual perimeter, the deployment package that becomes the only artifact left to scan, the dozen untrusted event sources that replace one HTTP ingress, and the specific supply-chain exposure of code that runs on infrastructure you don't operate, closer to your users than your own logging ever reaches.
Imagine your house has no walls, no doors, and no locks — just a single ID badge that decides what you're allowed to touch once you're inside. There's nothing to bolt down, so a burglar can't pick a lock that doesn't exist. But now the badge is everything: if the badge opens every room instead of just the kitchen, the missing walls don't matter at all. Serverless is a house with no walls. The badge — who this specific piece of code is allowed to be, and exactly what it's allowed to touch — is the only security left standing.
What actually disappears — and what quietly takes its place
☺ Like you're 10: Knock down the walls of a house and you don't need locks on the bedroom doors anymore — but now every window, vent, and mail slot is a way in, and you'd better know all of them.
It's tempting to read "no container, no host" as "less attack surface, less work." Some of that is genuinely true: a managed FaaS runtime means the cloud provider patches the underlying OS and language runtime on a schedule you never see and never own, and there's no long-lived kernel for a rootkit to persist in because the process backing any single execution is torn down and replaced constantly. But security work doesn't vanish — it relocates. The container runtime security and Kubernetes security pages in this course are both about controls that assume a host: seccomp profiles, read-only root filesystems, node-level admission, runtime sensors watching syscalls. None of those controls have anywhere to attach in a Lambda-style function, because there's no node to attach them to.
What replaces the host-hardening checklist is a much shorter, much higher-stakes list: the execution role (what this specific code is allowed to do), the deployment package (the only artifact anyone will ever get to scan, since there's no running container to re-inspect later), the event source that triggered this invocation (which may or may not be trustworthy), and — for anything running at the edge — the vendor's own isolation model, which you don't get to choose the internals of. PureSec's original research, later folded into the OWASP Serverless Top 10, catalogued this shift as its own category of risk rather than a subset of the OWASP Top 10 for web apps; if you want the full canonical list it's worth pulling up OWASP's own current page, since the project has evolved since its 2017 draft, but the shape hasn't changed: event-data injection, broken authentication, insecure deployment configuration, over-privileged function roles, inadequate monitoring, insecure third-party dependencies, and secrets mishandling account for most of what actually goes wrong.
Function-level IAM: one function, one role — not one shared executor
☺ Like you're 10: A key that opens the supply closet shouldn't also open the safe next door, even if it's the same person carrying both keys around all day.
Since the execution role is the perimeter, the single most consequential design decision in a serverless architecture is how narrowly that role is scoped — and the default outcome, left unmanaged, is almost always too broad. A team's first Lambda function gets a hand-written execution role with exactly what it needs. Its second function gets the same role, because reusing it is faster than writing a new one. By the twentieth function, that role has accreted permissions from every function that ever shared it, and nobody can safely remove any single grant because nobody's certain which of the twenty functions still depends on it. This is the exact god-role failure mode workload identity & pipeline IAM describes for CI pipelines — except here it's not a deploy job that inherits the blast radius, it's whatever request happened to reach whichever function shares the role, at 3am, from an event source nobody's watching closely.
The fix is the same discipline applied one level down: one execution role per function, scoped to the specific resources and actions that function's code actually calls. AWS's IAM Access Analyzer can generate a draft least-privilege policy from a function's own CloudTrail activity — run the function under a broad role for a representative period, then let Access Analyzer propose the narrower policy that would have covered everything it actually did, turning "figure out the minimal permission set by reading the code" into a diff you review rather than an afternoon of manual audit.
# One execution role, scoped to exactly what order-processor needs —
# not the shared "lambda-execution-role" every other function in the account reuses.
OrderProcessorRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal: { Service: lambda.amazonaws.com }
Action: sts:AssumeRole
Policies:
- PolicyName: order-processor-inline
PolicyDocument:
Statement:
- Effect: Allow
Action: [dynamodb:PutItem, dynamodb:GetItem]
Resource: arn:aws:dynamodb:us-east-1:111122223333:table/orders
- Effect: Allow
Action: sqs:SendMessage
Resource: arn:aws:sqs:us-east-1:111122223333:fulfillment-queue
# No s3:*, no dynamodb:*, no access to any table or queue this function
# doesn't call by name. If this function is compromised, this is the whole blast radius.Scoping what the function can do is only half of it — you also have to scope who's allowed to invoke it, and that's a separate policy entirely: a resource-based policy attached to the function itself, not the execution role. Mixing these two up is how the confused deputy problem shows up in serverless specifically: if a function's resource policy grants lambda:InvokeFunction to the S3 service principal without also constraining which S3 bucket may trigger it, then any AWS customer's S3 bucket configured to notify your function's ARN can invoke it — not just the bucket you intended. AWS added the aws:SourceArn and aws:SourceAccount condition keys specifically to close this gap.
# Grant invoke permission, but only to THIS bucket, in THIS account — # not "any S3 bucket anywhere that happens to know this function's ARN." aws lambda add-permission \ --function-name thumbnail-generator \ --statement-id s3-invoke \ --action lambda:InvokeFunction \ --principal s3.amazonaws.com \ --source-arn arn:aws:s3:::uploads-bucket \ --source-account 111122223333
| Concept | AWS Lambda | GCP Cloud Functions | Azure Functions |
|---|---|---|---|
| Runtime identity | Execution role (IAM role) | Runtime service account | Managed identity (system- or user-assigned) |
| Per-function scoping | One role per function ARN | One service account per function (or shared — same anti-pattern applies) | One managed identity per Function App |
| Who-can-invoke policy | Resource-based policy + SourceArn/SourceAccount | Cloud IAM binding on the function resource + roles/cloudfunctions.invoker | Function keys, or Azure AD auth via App Registration |
| Least-privilege generator | IAM Access Analyzer | Policy Analyzer / Recommender | Azure AD access reviews |
A resource-based policy and an execution-role policy answer two completely different questions — "who may call this function" versus "what may this function do once called" — and a review that only checks one of them is checking half the door. A tightly-scoped execution role behind a wide-open resource policy still lets an unintended caller trigger real (if limited) side effects; a tight resource policy behind a broad execution role still hands a legitimate caller far more blast radius than the use case needs. Least privilege in serverless means auditing both policies together, not either one alone.
The event-driven attack surface: a dozen untrusted doors, not one
☺ Like you're 10: A castle with one gate only needs one guard. A castle with a gate, six windows, a mail slot, and a laundry chute needs a guard at every single one, because attackers don't announce which one they're using.
A traditional web application has one ingress worth obsessing over: the HTTP endpoint, sitting behind a WAF, fronted by whatever validation the framework enforces before a request reaches application code. A Lambda-style function commonly has half a dozen or more distinct event sources wired directly to it, and each one carries a different trust level and a different shape of untrusted input: API Gateway (HTTP requests, at least passing through whatever the gateway validates), an S3 ObjectCreated event (a filename and metadata an attacker fully controls if they can upload the object), an SQS message, an SNS notification, an EventBridge rule, a DynamoDB Streams record, a Cognito trigger. A WAF sitting in front of API Gateway protects exactly one of those doors.
The SQS/SNS bypass case is worth sitting with because it's easy to miss in review: a team builds careful request validation into the API Gateway → Lambda path — schema checks, size limits, auth — then wires a second Lambda to consume messages directly from an SQS queue that the first function writes to. If the queue's own access policy doesn't restrict which principals may call sqs:SendMessage, anyone with credentials to the AWS account (or, if the policy is truly wide open, anyone at all) can send a message directly into that queue, skipping every validation the API Gateway path enforces entirely. The queue's resource policy — not the application code — is what decides whether that shortcut exists.
// Bad: any AWS principal can send a message straight into this queue,
// skipping every validation the API Gateway path in front of it would enforce.
{
"Effect": "Allow",
"Principal": "*",
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:111122223333:order-queue"
}
// Good: only messages relayed by this specific SNS topic may land here.
{
"Effect": "Allow",
"Principal": { "Service": "sns.amazonaws.com" },
"Action": "sqs:SendMessage",
"Resource": "arn:aws:sqs:us-east-1:111122223333:order-queue",
"Condition": { "ArnEquals": { "aws:SourceArn": "arn:aws:sns:us-east-1:111122223333:orders-topic" } }
}The broader lesson is that function event-data injection — treating a deserialized event payload as though it were pre-validated, because in a traditional app something upstream usually was — is the serverless-specific twist on an old bug class. An S3 key, a DynamoDB record, or an EventBridge detail field passed unsanitized into a shell command, a SQL query, or a downstream API call is the same injection vulnerability this course covers in SAST, DAST & SCA, wearing a costume built from a source most reviewers don't habitually think of as untrusted input.
Cold starts, dependency bloat, and the security cost of a fat package
☺ Like you're 10: Packing every tool you own "just in case" makes the suitcase heavier and slower to search — and now there's more in it a thief could actually want.
A cold start is what happens when a function is invoked and no warm execution environment is available: the platform provisions a fresh one, downloads and unpacks the deployment package, runs any module-level initialization code, and only then calls the handler. A warm start skips straight to the handler, reusing an execution environment left over from a previous invocation. Package size feeds directly into cold-start latency — a larger zip takes longer to download and unpack, and more module-level code means more work before the handler even starts — which creates a real engineering incentive to bundle broadly ("vendor the whole SDK so I never hit a missing-module error at runtime") that runs directly against the security incentive to ship the smallest, most auditable artifact possible. AWS Lambda's own limits make the tension concrete: 50 MB zipped for a direct upload, 250 MB unzipped including layers, up to 10 GB for a container-image-packaged function — check AWS's current quotas page before you design around these numbers, since service limits do shift, but the shape of the trade-off doesn't.
# Tree-shaking with esbuild: bundle only what the handler actually imports, # and mark the platform's preinstalled SDK external instead of vendoring a second copy. esbuild src/handler.ts --bundle --minify --platform=node --target=node20 \ --external:aws-sdk --outfile=dist/handler.js # Compare what actually ships against what "just npm install" would have shipped — # the gap between these two numbers is dependency bloat, and it's all attack surface. du -sh dist/handler.js du -sh node_modules
Reused warm environments carry a subtler risk that a stateless mental model can miss entirely: the execution environment — including its /tmp directory (up to 10 GB of ephemeral storage on Lambda, configurable) and anything cached at module scope outside the handler function — persists across invocations that reuse it, and different invocations of a warm environment are not guaranteed to belong to the same request context, user, or even the same tenant in a multi-tenant application. Code that writes a request-scoped secret or a temp file to /tmp and doesn't explicitly clean it up assumes the next invocation into that same warm environment is safe to see it. Global-scope variables meant purely as a performance cache (a decoded JWT, a per-request auth token, a database connection carrying a specific tenant's credentials) can leak into a subsequent, unrelated invocation the same way a static variable in a long-running server can leak across requests if a developer forgets the server is shared — except here the "long-running server" is easy to forget even exists, because the mental model developers bring to serverless is usually "this runs once and disappears."
Provisioned concurrency — keeping a pool of execution environments permanently warm to eliminate cold-start latency for latency-sensitive functions — trades away exactly the property that made ephemeral compute attractive in the first place. A provisioned-concurrency environment is, for security purposes, a long-lived process again: it accumulates whatever state your code leaves in module scope or /tmp across far more invocations than an on-demand environment ever would, for far longer. If you turn provisioned concurrency on, treat that function's handling of module-scope state and /tmp with the same care you'd bring to a traditional long-running service — the ephemerality you were relying on for hygiene is gone.
The scanning consequence follows directly from all of this: there's no running container to re-inspect next Tuesday, so the deployment package is the only opportunity to catch a vulnerable dependency before it ships, and it has to happen at build time, every time. Software composition analysis in depth covers the general SCA discipline; the serverless-specific wrinkle is that the artifact under scan isn't a container layer with a clean base-image/app-layer split — it's a flat zip (or a container image, if you package that way) where your code and every one of its transitive dependencies sit at the same level, with no base-image maintainer patching the parts you didn't write.
Supply-chain exposure baked directly into the deployment package
☺ Like you're 10: If a stranger can slip a fake ingredient into your grocery order before it's delivered, it doesn't matter how carefully you cook once it's in your kitchen.
A container build at least offers a clean separation of blame: a base image someone else maintains and patches, and an application layer on top of it that's comparatively small and easier to review. A Lambda-style deployment package collapses that separation — your handler code and the entire dependency tree it pulls in during npm install or pip install are bundled into one flat artifact, with nothing acting as a trusted, independently-maintained floor underneath it. That makes dependency confusion — Alex Birsan's 2021 research showing that a public package registry entry with the same name as an internal-only package can get resolved and installed instead of the intended internal one, if the build tooling isn't configured to prefer the private registry explicitly — a direct path into a production function, because the CI step that runs npm install immediately before zipping the package is the deploy pipeline, with no separate "build once, promote the same artifact through every stage" gate forcing a second look.
The 2018 event-stream npm incident is the canonical illustration of what a single compromised transitive dependency can do once it's bundled straight into a deployable artifact: a popular package's maintainer, no longer actively working on it, transferred publish rights to a new contributor who added a malicious sub-dependency (flatmap-stream) designed to exfiltrate wallet keys from a specific downstream application. Nothing about that compromise required breaching the target directly — it required only that thousands of projects, including any serverless function that happened to depend on event-stream transitively, would pull the new version automatically on the next npm install.
| Control | What it stops |
|---|---|
Lockfile + npm ci / pip install --require-hashes | Installs exactly the versions and content hashes recorded at review time — no silent upgrade to a compromised or typosquatted release during a later build. |
| Private registry with explicit scoping/proxying | Closes the dependency-confusion gap by making the build tool unable to resolve an internal package name against the public registry at all. |
| SBOM generated per function, not per repo | A shared monorepo can have one function pull a vulnerable package another function never touches — a per-repo SBOM hides that; a per-artifact SBOM (via SBOMs, generated with Syft) doesn't. |
| Code-signed deployment package | Blocks an artifact from deploying at all if it wasn't produced and signed by your own pipeline — the control covered next. |
AWS Lambda's code signing feature, built on AWS Signer, is the most direct answer available on that platform: attach a CodeSigningConfig to a function, and the platform refuses to deploy any package that isn't signed by an approved signing profile — with an Enforce policy, an unsigned or tampered zip is rejected before it ever runs, not flagged after the fact.
# Require every deployment to order-processor to carry a valid signature # from this specific signing profile — reject anything else outright. aws lambda create-code-signing-config \ --allowed-publishers SigningProfileVersionArns=arn:aws:signer:us-east-1:111122223333:/signing-profiles/order_processor_profile \ --code-signing-policies UntrustedArtifactOnDeployment=Enforce aws lambda update-function-code-signing-config \ --function-name order-processor \ --code-signing-config-arn arn:aws:lambda:us-east-1:111122223333:code-signing-config:csc-abc123
For platforms without a first-party code-signing feature — or for edge functions that aren't Lambda at all — cosign's sign-blob/verify-blob commands do the same job for an arbitrary artifact, not just an OCI image, keyed off the same keyless OIDC identity workload identity & pipeline IAM already covers for cloud credentials:
cosign sign-blob --yes \ --output-signature order-processor.zip.sig \ --output-certificate order-processor.zip.pem \ dist/order-processor.zip cosign verify-blob \ --certificate order-processor.zip.pem \ --signature order-processor.zip.sig \ --certificate-identity-regexp "https://github.com/acme/.*" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ dist/order-processor.zip
Edge/CDN compute: same code, no cluster boundary at all
☺ Like you're 10: A house with a fence at least has a fence. Code running at the edge is more like a note pinned up in every post office in the world at once — you don't own any of the buildings it's sitting in.
Edge compute — Cloudflare Workers, AWS Lambda@Edge and CloudFront Functions, Fastly Compute, Akamai EdgeWorkers — pushes execution out of your account entirely and onto a CDN vendor's global network of points of presence, running your code physically close to whoever's requesting it. That buys latency, and it removes an entire category of control you had in even the most minimal Lambda setup: there's no VPC, no security group, no account boundary of any kind, because your code isn't running in your account — it's running on infrastructure the vendor operates, replicated to hundreds of locations you have no visibility into individually. A misconfigured deploy to a regional Lambda function is a regional incident. A misconfigured deploy to an edge function is instantly global.
The isolation model underneath also changes in a way worth understanding rather than taking on faith. AWS Lambda's execution environments run on Firecracker, an open-sourced microVM technology that gives each execution environment actual hardware-virtualized isolation — a real, if lightweight, VM boundary. Cloudflare Workers instead run each piece of code in a V8 isolate: a lightweight sandbox inside a single shared V8 JavaScript engine process, closer in spirit to how a browser tab is isolated from other tabs than to a virtual machine boundary. That's a deliberate, well-engineered trade — isolates start in sub-millisecond time precisely because they skip VM boot entirely — but it is a different, and by most security researchers' accounting a somewhat weaker, isolation primitive than a hardware-virtualized microVM, and Cloudflare's own engineering team has publicly documented the mitigations this requires: disabling SharedArrayBuffer and reducing timer precision to blunt Spectre-class side-channel attacks that could otherwise let one tenant's code infer data from another tenant sharing the same V8 process. Fastly Compute takes a third approach, compiling code to WebAssembly and relying on Wasm's linear-memory sandboxing model, which is memory-safe by construction in a way that neither a microVM nor a bare V8 isolate is inherently.
| Platform | Isolation unit | Cold start | Where code runs |
|---|---|---|---|
| AWS Lambda (regional) | Firecracker microVM | Tens to hundreds of ms, varies by runtime | A single AWS region you choose |
| Lambda@Edge | Firecracker microVM | Similar to regional Lambda, replicated per edge location | CloudFront edge locations |
| CloudFront Functions | Lightweight JS-only sandbox (restricted language subset, no full Lambda runtime) | Sub-millisecond | Every CloudFront edge location |
| Cloudflare Workers | V8 isolate | Sub-millisecond to low single-digit ms | Cloudflare's global network |
| Fastly Compute | WebAssembly sandbox (Wasmtime) | Sub-millisecond | Fastly's global network |
Choosing an edge platform is partly a security decision, not purely a latency one. Weigh the isolation model against what you're actually running there: rendering a static page fragment or rewriting a header is a reasonable fit for a lighter isolate-based sandbox; anything touching a secret, a signed cookie, or a decision that gates access to sensitive data deserves the same scrutiny you'd give a decision about where else in your stack that logic is allowed to run.
The supply-chain exposure at the edge is where this section earns its place in a course otherwise focused on things you build and deploy yourself. In February 2024, ownership of the polyfill.io domain and its associated GitHub account changed hands to a new operator; by June 2024, security researchers had documented that the CDN was injecting malicious redirects into the JavaScript it served, conditioned on device and referrer, to an estimated 100,000-plus sites that had embedded <script src="https://cdn.polyfill.io/..."> — a single line, added years earlier by teams who had no ongoing relationship with the code and no code-review gate on it, because the whole point of a CDN-hosted polyfill is that it updates itself without anyone touching the embedding site again. Cloudflare and Fastly both responded by automatically rewriting requests to that domain toward a safe mirror for sites on their networks — a save that depended entirely on sitting in front of the request as a CDN, not on anything the embedding site itself had done.
The lesson generalizes past this one incident, and it rhymes with the supply-chain story in the SolarWinds case study: a dependency you don't vendor, don't scan, and don't re-review after the day you added it is still a dependency, and outsourcing it to "just a CDN tag" doesn't reduce that — if anything it removes the one control (a pinned version in a lockfile, reviewed on every bump) that would normally catch exactly this. Treat a third-party edge script the way dependency & license risk management already tells you to treat an npm package: pin it to a specific, immutable version or subresource-integrity hash rather than a mutable "latest" alias, self-host or mirror it if the risk warrants, and put someone's name on the calendar to re-review it periodically rather than treating "it's just three lines of markup" as a reason it doesn't need the same rigor as a package.json entry.
Subresource Integrity (integrity="sha384-…" on a <script> tag) stops a tampered file from executing if the CDN serves different bytes than what you pinned — but it only helps if the script is loaded from a URL and hash you actually chose and reviewed. A polyfill or analytics snippet that self-updates by design, the way cdn.polyfill.io was meant to, defeats SRI entirely: the whole point of that integration pattern was to avoid pinning a version, which is precisely the property that turned a domain-ownership change into a silent, unreviewed compromise for every site still pointing at it.
Observability and forensics when there's no host left to inspect
☺ Like you're 10: You can't search a room for clues after the room has already been demolished — you have to have written down what happened while it still existed.
Traditional incident response assumes a host you can reach: SSH in, pull a memory image, check what processes are running, read the auth log. None of that is available for a Lambda-style function, and it's categorically unavailable for an edge function — there's no shell to open on infrastructure you don't operate, execution environments are torn down and recycled on the platform's own schedule, and in-memory state that wasn't explicitly written out before the handler returns is simply gone. This isn't a gap you can close after the fact with a better tool; it has to be closed by instrumenting the code and the pipeline before anything runs, because there is no "go look at the live system" fallback the way there is with a compromised VM.
What that instrumentation looks like in practice: structured logs (CloudWatch Logs or equivalent) written synchronously before the handler returns, since anything logged via a fire-and-forget call after the response is sent can be lost if the execution environment freezes before it flushes; distributed tracing (AWS X-Ray, or OpenTelemetry via a Lambda layer) to reconstruct a request's path across what's typically dozens of small functions instead of one large application, because a stack trace from a single process doesn't exist when the "process" is fifteen different functions chained by event sources; and the Lambda Extensions API, which lets an observability or security vendor's agent run as a second process inside the same execution environment, receiving lifecycle events (INVOKE, SHUTDOWN) without needing host-level access — this is how tools like Datadog's and Lumigo's Lambda integrations get runtime visibility without a host to install an agent on.
Falco and other eBPF-based runtime sensors — the tools container runtime security covers in depth — generally can't run in a FaaS environment at all, because eBPF requires kernel access the platform doesn't expose to your function. This is a real, structural coverage gap, not a configuration problem to fix: if your threat model for a serverless workload assumed "we'll catch anomalous syscalls the same way we do in Kubernetes," that assumption doesn't carry over, and the gap has to be filled by extension-based instrumentation and tight IAM scoping instead, not by trying to force a host-based tool where there's no host.
CloudTrail (and the GCP/Azure equivalents) still records every AssumeRole, every Invoke, and every API call the execution role makes downstream — that's a detection signal that doesn't depend on host access at all, and it's exactly the audit trail detection engineering & security observability covers turning into an actual alert. The practical shift this whole section amounts to: for a host, you can always go inspect it after the fact, even if nobody thought to instrument it well beforehand. For a function, you only ever get what you decided in advance to write down — nothing more, and there's no second chance to go collect what you didn't.
Take one existing Lambda-style function in a sandbox account. First, pull its execution role and list every permission it has versus every AWS API call it's actually made in the last 30 days of CloudTrail — by hand, or by running IAM Access Analyzer's policy generation against it. Note the gap. Then check its resource-based policy: does the SourceArn condition actually pin to the one event source you intend, or is it wider than that? Finally, run npm ls (or the pip equivalent) inside the deployment package and cross-reference it against the SBOM your pipeline generated, if it generates one at all. Most teams doing this for the first time find at least one surprise in all three places.
Benny the Beaver: No container this time — just zip it up and ship it. Less to build, less to secure. I like this one.
Timmy the Turtle: Less to scan doesn't mean less to worry about, Benny. Did anything actually check what's inside that zip before it deployed?
Pip the Hummingbird: I did — and three of your dependencies were pulled fresh from the public registry during this build, not from the lockfile. One of those three didn't exist on our internal registry an hour ago.
Ellie the Elephant: And this function's execution role — it's the shared one again, isn't it? The one with s3:* and dynamodb:*, because eleven other functions grew into it over two years and nobody's dared trim it since.
Rocky the Raccoon: I don't need to break into a container that isn't there. I just need one over-permissioned role and one dependency nobody double-checked. Turns out you've handed me both.
Foxy: Fine — say it does get compromised. Who's watching it once it's running, if there's no host to log into?
Pip the Hummingbird: Nobody watches the host, Foxy — because there isn't one. You watch what it wrote out before the environment froze, and nothing else. Which is exactly why it has to be written out every single time, not just when we remember.
1. Name three host-hardening controls that simply have no equivalent target in a Lambda-style function, and explain what replaces them as the effective perimeter. 2. What's the difference between a function's execution role and its resource-based policy, and why does auditing only one of them leave half the door unlocked? 3. Walk through the SQS-bypass scenario: how can an attacker skip validation an API Gateway path enforces, and what single policy change closes that gap? 4. Why does provisioned concurrency partially undo the security benefit of ephemeral compute? 5. What made the polyfill.io incident possible that a normal, lockfile-pinned npm dependency would have prevented? 6. Why can't a tool like Falco run inside most FaaS environments, and what replaces it?
Check your answers
- OS patching, container image scanning, and host-based EDR/IDS all lack a target — there's no OS you manage, no container image in the traditional sense (for a zip-packaged function), and no long-lived process for an agent to watch. What replaces them: the vendor's own runtime patching (invisible to you), scanning the deployment package itself before it ships, and the execution role plus event-source validation as the effective control points.
- The execution role governs what the function is allowed to do once invoked (its outbound permissions); the resource-based policy governs who is allowed to invoke the function at all (its inbound permissions), and includes conditions like
SourceArn. A tightly-scoped execution role behind a wide-open resource policy still lets unintended callers trigger real side effects; a tight resource policy behind a broad execution role still hands a legitimate caller far more blast radius than needed. Both have to be reviewed together. - If a second function consumes messages directly from an SQS queue, and that queue's own access policy doesn't restrict which principals may call
sqs:SendMessage, anyone with access to send a message into that queue can trigger the downstream function directly — skipping every validation the API-Gateway-fronted path would have enforced. The fix is a queue resource policy with an explicitCondition(e.g.aws:SourceArnrestricted to the specific SNS topic or role that should be the only sender), not just relying on IAM permissions elsewhere in the account. - Ephemeral compute's security value comes largely from state not persisting between invocations for long — a compromised or leaky execution environment is quickly recycled. Provisioned concurrency keeps a pool of environments permanently warm to eliminate cold-start latency, which means module-scope state and anything left in
/tmpnow persists across far more invocations, for far longer — turning the environment back into something closer to a long-running process for security purposes, even though it's still marketed and billed as "serverless." - A normal npm dependency, pinned in a lockfile with content hashes and installed via
npm ci, only changes when a developer deliberately bumps the version and that bump goes through code review. The polyfill.io script was embedded as a live, self-updating CDN reference specifically so it would never need a version bump or a re-review — which is exactly what let a change in domain ownership modify the code served to every embedding site instantly, with no lockfile, no pinned hash, and no review gate anywhere in the loop to catch it. - Falco and similar tools rely on eBPF, which requires kernel-level access that FaaS platforms don't expose inside a function's sandboxed execution environment — there's no kernel you're allowed to attach a sensor to. What replaces it is instrumentation built for the platform's own extension model instead of the kernel: the Lambda Extensions API (letting an agent run as a second process inside the same execution environment) combined with structured logging, distributed tracing, and tight IAM scoping so that what the role could have done stays bounded even without a runtime sensor watching every syscall.