Serverless Security: Least-Privilege Lambda, Event Injection, and Secrets Handling
Serverless removes the host you used to patch, but it does not remove the attack surface. This is how working engineers harden Lambda execution roles, untrusted event sources, secrets, and Function URLs against real exploitation.

Lambda erases the patchable host, but the blast radius just moves. In a serverless app the real perimeter is the execution role, the event payload, and the secrets a function can reach at runtime. A single over-scoped role attached to a function that parses attacker-controlled input is a straight line from a malformed event to data exfiltration. This is a practitioner walkthrough of the failure modes that actually get exploited in AWS serverless workloads in 2026, and the concrete controls that contain them.
Over-privileged execution roles are the default failure
Every Lambda assumes an execution role, and that role is the identity any code running inside the function inherits, including injected code. The common anti-pattern is a role with managed policies like AmazonDynamoDBFullAccess or s3:* on Resource "*", copied from a tutorial and never tightened. When a dependency is compromised or an event is injected, the attacker does not get the function's narrow job; they get everything the role can do across the account. Treat the execution role as the actual security boundary and scope it to exact ARNs and exact actions. One role per function, never a shared role across a fleet, so a compromise of one handler cannot pivot through the permissions of another.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOrdersTableOnly",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:Query"
],
"Resource": "arn:aws:dynamodb:us-east-1:111122223333:table/orders"
},
{
"Sid": "ReadOneSecretOnly",
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:111122223333:secret:prod/orders/db-Ab12Cd"
},
{
"Sid": "ScopedLogsOnly",
"Effect": "Allow",
"Action": [
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:us-east-1:111122223333:log-group:/aws/lambda/orders-read:*"
}
]
}Note what is absent: no wildcard actions, no Resource "*", and write access to DynamoDB is simply not granted because this handler only reads. The log permissions are scoped to this function's own log group rather than the account-wide logs:* that the default policy hands out. If you must allow a broad action, constrain it with a condition key instead of leaving Resource open.
Event injection from untrusted sources
Lambda's event object is attacker-influenced far more often than teams assume. An API Gateway or Function URL request, an SQS message, an S3 object key, an SNS notification, a DynamoDB Stream record, an EventBridge event, an SES inbound email, an IoT message: every one of these can carry data shaped by an external party. The handler that blindly interpolates event fields into a database query, a shell command, an S3 key, or a downstream HTTP call is the serverless equivalent of classic injection. The fact that there is no long-lived server does not make string concatenation of untrusted input safe.
- Validate and type-check every event against a strict schema at the top of the handler; reject anything that does not match rather than coercing it.
- Never pass event-derived strings into os.system, child_process, eval, or dynamic import paths.
- Use parameterized queries and SDK parameter objects, never string-built query expressions, for DynamoDB, RDS Data API, or Athena.
- Treat object keys, file names, and S3 paths from events as hostile; canonicalize and allowlist before using them in further API calls.
- Set a conservative function timeout and reserved or maximum concurrency so an injection cannot be amplified into runaway invocation cost or downstream overload.
Secrets: environment variables are not a vault
Plaintext secrets in Lambda environment variables remain one of the most common findings in serverless audits. Environment variables are visible to anyone with lambda:GetFunctionConfiguration, they are captured in CloudFormation and Terraform state, they leak into logs and error traces, and any code running in the function (including an injected dependency) can read the entire process environment in one line. Encrypting them with a customer-managed KMS key helps at rest but does nothing once the value is decrypted into the runtime environment. Fetch secrets at runtime from Secrets Manager or SSM Parameter Store SecureString instead, scope GetSecretValue to the single secret ARN as shown above, and cache the result for the lifetime of the execution environment to avoid per-invocation API calls and throttling. The AWS Parameters and Secrets Lambda Extension gives you a local cache without writing the caching logic yourself.
Rule of thumb: if a value would cause an incident when leaked, it does not belong in a Lambda environment variable. Resolve it at runtime from Secrets Manager or Parameter Store, grant access to exactly one secret ARN, and rotate on a schedule. Encrypted env vars still end up in plaintext in the running process.
Function URLs, auth, and the public-by-mistake trap
Lambda Function URLs give a function a dedicated HTTPS endpoint with no API Gateway in front. They have two auth modes, and the dangerous one is easy to pick by accident. AuthType NONE makes the endpoint publicly invokable by anyone on the internet, which is appropriate only for a genuinely public, well-validated handler and never for anything touching internal data. AuthType AWS_IAM requires the caller to sign the request with SigV4 and to hold lambda:InvokeFunctionUrl permission. If you do expose a NONE endpoint, you own all of authentication, authorization, input validation, and abuse protection inside the function, and you should front it with WAF and tight throttling. For invocation by another AWS service or account, control access with a resource-based policy that pins the source, rather than opening the function up.
{
"Version": "2012-10-17",
"Id": "restrict-invoke-by-source",
"Statement": [
{
"Sid": "AllowS3BucketInvokeOnly",
"Effect": "Allow",
"Principal": { "Service": "s3.amazonaws.com" },
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:111122223333:function:orders-read",
"Condition": {
"StringEquals": { "aws:SourceAccount": "111122223333" },
"ArnLike": {
"aws:SourceArn": "arn:aws:s3:::orders-intake-bucket"
}
}
}
]
}The aws:SourceArn condition is what stops a confused-deputy invocation: even though the S3 service principal is allowed to invoke, only events originating from that one bucket in that one account are permitted. Apply the same pattern for SNS, EventBridge, and cross-account callers, always pinning aws:SourceArn and aws:SourceAccount.
Dependency and supply-chain risk
A serverless function is mostly third-party code. Layers and the deployment package pull in dozens of transitive npm or PyPI dependencies, any of which can be typosquatted, hijacked, or backdoored, and that code runs with the function's execution role. Pin exact versions and commit a lockfile; do not float on latest. Generate an SBOM and scan the package and any layers in CI, for example with Amazon Inspector, which covers Lambda functions and layers for known CVEs. Vet shared layers like any other dependency, since a single poisoned layer is inherited by every function that attaches it. Build in a clean CI pipeline rather than from a developer laptop, and prefer minimal runtimes so there is less code to audit and exploit. Combined with a tightly scoped role, supply-chain compromise is contained: malicious code still cannot exceed the function's permissions.
Detection: CloudTrail Invoke and GuardDuty Lambda Protection
Least privilege limits the blast radius; detection tells you when someone is testing its edges. Lambda management-plane calls (CreateFunction, UpdateFunctionCode, UpdateFunctionConfiguration, AddPermission) are logged by CloudTrail management events by default, and unexpected UpdateFunctionCode or AddPermission outside a deploy pipeline is a strong tamper signal worth alerting on. The Invoke API itself is a data event, so you must explicitly enable Lambda data events in CloudTrail to see who is invoking which function and how often. Turn on GuardDuty Lambda Protection, which analyzes VPC flow telemetry from your functions to flag behavior such as a function beaconing to a known command-and-control endpoint or performing crypto-mining DNS lookups, a strong indicator of post-exploitation. Wire those findings into EventBridge and an automated response so a compromised function can be throttled to zero concurrency or have its role detached quickly.
None of these controls is exotic; the discipline is applying all of them together, per function, on every deploy. Scope each execution role to exact ARNs, validate every event as hostile input, pull secrets at runtime instead of baking them into the environment, lock down Function URLs and resource-based policies with source conditions, scan your dependencies and layers, and watch CloudTrail and GuardDuty for the moment an attacker starts probing. Do that and a single compromised function stays a single compromised function instead of an account-wide breach.