AWS SAM is the fastest way to ship a Lambda function. It is also the fastest way to ship a Lambda function with a wide-open IAM role, a public API Gateway, and a dead-letter queue that nobody monitors. The SAM transform is designed to be friendly to first-time serverless developers, and the defaults reflect that. For a team running hundreds of SAM-based functions in production, those same defaults become a liability.
I have done SAM template reviews for seven teams over the last year and the failure modes are remarkably consistent. This post walks through them in roughly the order they tend to appear, with specific template fixes.
Why SAM templates are harder to review than they look
When you write a SAM template, you write a short AWS::Serverless::Function resource. When SAM processes that template, it expands it into a full CloudFormation stack that may contain a Lambda function, an IAM role, an execution role policy, a Lambda version, an alias, an API Gateway REST API, multiple API Gateway methods, a CloudWatch log group, a Lambda permission, and several other resources. The expansion happens through the SAM transform, which is a macro that runs on the CloudFormation side.
The security implication is that you are reviewing a template that does not fully describe the stack you are deploying. To see what will actually be created, you run sam validate --lint followed by sam build && sam package and inspect the expanded template. Many teams skip this. Their security reviews are therefore reviewing the five-line SAM resource, not the fifty-line CloudFormation expansion that will be deployed.
The first hardening step is operational: require that every SAM template under review has its expanded CloudFormation attached. This alone catches most of the issues below.
The policies shorthand
SAM provides a Policies property on AWS::Serverless::Function that accepts a list of policy templates. These are supposed to be friendly shortcuts for common patterns:
Policies:
- DynamoDBReadPolicy:
TableName: !Ref MyTable
- S3WritePolicy:
BucketName: !Ref MyBucket
These expand into IAM policies. The expansions are documented in the SAM policy template list, but they are generous. DynamoDBReadPolicy expands to include dynamodb:BatchGetItem, dynamodb:GetItem, dynamodb:Query, dynamodb:Scan, dynamodb:DescribeTable, and several others, all scoped to the table and its indexes. S3WritePolicy includes s3:PutObject, s3:PutObjectAcl, s3:DeleteObject, and s3:AbortMultipartUpload.
Two things to know:
-
The shortcuts are usually wider than what your function actually needs. If your function writes objects but never deletes them, you do not need
s3:DeleteObject. Using the shortcut is easier; writing the minimal policy is correct. For production functions, write the minimal policy. -
The Resource is not always scoped. Some SAM policy templates default to
Resource: "*"because the template author cannot know what resource ARN you want.LambdaInvokePolicywithout aFunctionNameparameter is one of the worst offenders; it grantslambda:InvokeFunctionon*. Always pass the resource-scoping parameter. If SAM allows you to omit it, assume the expansion is wildcarded and check the expanded template.
I generally recommend that teams running serverless at scale stop using SAM policy templates entirely and write explicit Policies arrays with full IAM policy documents. It is more typing and it produces better policies.
The implicit API
If your SAM function has an Events entry of type Api and no explicit RestApiId, SAM creates an implicit AWS::Serverless::Api resource for you. The implicit API has defaults: EDGE endpoint, no authorizer, no WAF, no resource policy, a stage named Prod with logging disabled.
This is fine for a prototype. It is not fine for production. The implicit API ships with no authentication. If your function is called from the implicit API and your code does not itself authenticate the request, the endpoint is anonymous on the public internet.
Make the API explicit. Define an AWS::Serverless::Api resource with the authentication, throttling, and logging properties you want, and reference it explicitly from each function's Events section. The explicit API also lets you attach a WAF web ACL, set a resource policy that restricts source IPs, and enable access logging to CloudWatch Logs with a defined log format.
Dead-letter queues, destinations, and the silent failure
SAM makes it easy to add a DeadLetterQueue property pointing to an SQS queue or SNS topic. Many teams do. Far fewer teams actually monitor the dead-letter queue. A year in, the queue has eight thousand messages, all of which represent failed invocations that nobody processed.
The pattern that works: every DLQ has a CloudWatch alarm on ApproximateNumberOfMessagesVisible greater than zero for more than five minutes, alerting to a real channel. If you cannot commit to monitoring a DLQ, do not create one. A silently failing queue is worse than a loudly failing function.
Similarly, Lambda destinations (OnSuccess and OnFailure) have become a common pattern. They have the same problem. Every destination should have ownership documented in the template (via tags or metadata) and every failure destination should have a consumer that alerts on backlog.
The permissions boundary
AWS IAM permissions boundaries are the single highest-leverage control for SAM stacks. Set a permissions boundary on the function role and on any IAM roles the template creates. Even if your SAM policy templates expand to something wildcarded, the permissions boundary caps the effective permissions.
In SAM:
Globals:
Function:
PermissionsBoundary: !Sub arn:aws:iam::${AWS::AccountId}:policy/ServerlessWorkloadBoundary
The ServerlessWorkloadBoundary is an IAM policy you manage centrally that defines the maximum permissions any serverless function in your organization can have. It should deny iam:*, organizations:*, kms:ScheduleKeyDeletion, and the handful of other operations that no reasonable workload needs. With this boundary in place, even a compromised function or a poorly scoped SAM policy template cannot escalate beyond the boundary.
Require the boundary via an SCP that denies iam:CreateRole and iam:AttachRolePolicy unless the iam:PermissionsBoundary condition key matches the approved boundary ARN.
Environment variables and secrets
The Environment.Variables map in a SAM template ends up as plaintext environment variables in the Lambda function. Every person with lambda:GetFunction can read them. Every CloudTrail entry for lambda:UpdateFunctionConfiguration contains them in the request parameters, which end up in CloudTrail logs.
Do not put secrets in Environment.Variables. Reference them from Secrets Manager or Parameter Store at runtime. The SAM-friendly pattern:
Environment:
Variables:
DB_SECRET_ARN: !Ref DatabaseCredentialsSecret
The function reads the ARN at startup, fetches the secret via the SDK, and caches it. The function role has secretsmanager:GetSecretValue on that specific secret ARN only, not on *.
CodeUri and what actually ships
The CodeUri property points to a local directory that SAM packages into a zip and uploads to S3. Everything in that directory ends up in the deployment package. This includes .git directories if you forgot a .gitignore. It includes .env files. It includes the node_modules that survived your last npm install --production, which may be larger and include more than you expect.
Review the packaged output. Run sam build locally and inspect .aws-sam/build/<FunctionName>/. The contents of that directory are exactly what ships to Lambda. If you see something there that surprises you, fix the .samignore or the build process.
How Safeguard Helps
Safeguard expands SAM templates through the transform and analyzes the resulting CloudFormation rather than the shorthand, so the review sees the actual IAM policies and API configurations that will be deployed. We flag implicit APIs without authorizers, DLQs without consumers, permissions boundaries missing from function roles, and SAM policy templates that expand to wider permissions than the function appears to need. For the deployment package itself, Safeguard scans the exact zip that will be uploaded to Lambda and alerts on secrets, large unexpected files, or dependencies that differ from the lockfile.