Most Serverless Framework services we audit have the same two security problems: one fat IAM role shared by every function, and secrets that were pasted into serverless.yml or an .env file that made it into git. Neither is hard to fix, and neither requires a new tool. This tutorial walks the changes we make on a typical Serverless Framework V4 service, in the order we make them.
Why the shared role is the problem
By default, Serverless Framework creates a single IAM role and attaches it to every function in the service. Anything you grant for one handler is granted to all of them. A service with an order-processing function that writes to DynamoDB, a thumbnail function that reads S3, and a webhook receiver that verifies signatures ends up with all three able to do all three things. If the webhook receiver is compromised through a bad dependency, the blast radius is the whole service.
Splitting the role per function costs a few lines of YAML and turns a service-wide compromise into a single-function one.
Step 1: Give each function its own role
In V4, provider.iam.role.statements is the service-wide grant. Keep it empty, or restrict it to things every function genuinely needs, then use per-function iamRoleStatements via the serverless-iam-roles-per-function plugin:
service: orders
plugins:
- serverless-iam-roles-per-function
provider:
name: aws
runtime: nodejs22.x
architecture: arm64
iam:
role:
statements: [] # nothing shared by default
functions:
createOrder:
handler: src/orders/create.handler
iamRoleStatementsName: ${self:service}-${sls:stage}-createOrder
iamRoleStatements:
- Effect: Allow
Action:
- dynamodb:PutItem
- dynamodb:ConditionCheckItem
Resource: !GetAtt OrdersTable.Arn
resizeThumbnail:
handler: src/media/resize.handler
iamRoleStatements:
- Effect: Allow
Action: s3:GetObject
Resource: !Sub '${MediaBucket.Arn}/uploads/*'
- Effect: Allow
Action: s3:PutObject
Resource: !Sub '${MediaBucket.Arn}/thumbnails/*'
Three habits worth keeping:
- Name the actions, never
dynamodb:*. Write the four or five verbs the handler actually calls. If you are not sure, deploy with the narrow set and let the failures tell you. - Reference ARNs, do not type them.
!GetAtt OrdersTable.Arnand!Subkeep the policy correct across stages and accounts. Hard-coded ARNs are how a staging function ends up with a production grant. - Scope down to prefixes and indexes. S3 prefixes as above; for DynamoDB indexes you need a second resource line,
!Sub '${OrdersTable.Arn}/index/*', because index reads are not covered by the table ARN.
The plugin still adds the CloudWatch Logs statements each function needs, so you do not have to write those yourself.
Step 2: Narrow a policy with real data
Guessing at permissions produces policies that are either broken or too wide. Two AWS features remove the guessing:
IAM Access Analyzer policy generation. Point it at the role and a CloudTrail trail covering a week of traffic and it generates a policy from the calls the role actually made. Treat the output as a starting draft, not a final answer, since anything that only runs monthly will be missing.
Last-accessed data. In the IAM console, a role's "Access Advisor" tab shows which services the role has touched and when. Any service listed as never accessed is a statement you can delete today.
Run both before you promise a client that a role is least-privilege.
Step 3: Stop putting secrets in your template
The rule: a secret value should never appear in serverless.yml, in a Lambda environment variable, or in the CloudFormation template that Serverless Framework uploads. Environment variables are visible to anyone with lambda:GetFunctionConfiguration, and template parameters resolved at deploy time end up in the stack in plain text.
That rules out the tempting one-liner:
# DON'T: resolves at deploy time and bakes the value into the template
environment:
STRIPE_KEY: ${ssm:/orders/prod/stripe-key}
Instead, pass the name of the parameter and let the function fetch the value at runtime:
functions:
createOrder:
handler: src/orders/create.handler
environment:
STRIPE_SECRET_NAME: /orders/${sls:stage}/stripe-key
iamRoleStatements:
- Effect: Allow
Action: ssm:GetParameter
Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/orders/${sls:stage}/*'
- Effect: Allow
Action: kms:Decrypt
Resource: !GetAtt SecretsKey.Arn
On the handler side, use the Powertools Parameters utility, or the Lambda Parameters and Secrets extension, so values are cached between invocations instead of fetched on every request:
import { getParameter } from '@aws-lambda-powertools/parameters/ssm';
let stripe;
export const handler = async (event) => {
if (!stripe) {
const key = await getParameter(process.env.STRIPE_SECRET_NAME, {
decrypt: true,
maxAge: 300, // seconds; survives across warm invocations
});
stripe = createStripeClient(key);
}
// ...
};
A 300-second cache means a function handling steady traffic makes roughly one SSM call every five minutes rather than one per request — which matters, because SSM GetParameter is throttled and metered.
Use Secrets Manager instead of SSM when you need automatic rotation (RDS credentials, third-party keys with a rotation Lambda) and SSM Parameter Store SecureString when you just need an encrypted value cheaply. Both are fine; mixing them arbitrarily across a codebase is not.
Step 4: Lock down the deploy role
Per-function roles do nothing if your CI pipeline assumes an admin role. Two changes:
Use OIDC, not stored keys. GitHub Actions can assume a role via an OIDC trust policy with no long-lived credentials in secrets.
Give CloudFormation the power, not the pipeline. Create a deployment role that can only call CloudFormation and pass a separate execution role, then point Serverless Framework at it:
provider:
iam:
deploymentRole: arn:aws:iam::${aws:accountId}:role/CfnDeployRole-${sls:stage}
CloudFormation assumes CfnDeployRole to create resources, so the credentials your pipeline holds cannot be used to create IAM users or read production data directly. Scope the trust policy on the OIDC role to the specific repository and branch — repo:acme/orders:ref:refs/heads/main — so a pull request from a fork cannot deploy to production.
Step 5: Make it stick
One-off cleanups drift back within a quarter unless the checks run automatically:
cfn-lintandcheckovon the packaged template in CI (serverless packagewrites it to.serverless/). Both flag wildcard actions and unencrypted resources.- Dependency scanning —
npm audit --audit-level=highor Dependabot — because the compromise path into a Lambda is usually a transitive package. - A
denyguardrail at the org level via a service control policy for things nobody should ever do, like creating IAM users or disabling CloudTrail. - Quarterly Access Advisor review on every function role, deleting anything unused.
A realistic order of work
On a service with 20 functions, doing this well takes two or three days: half a day to split the roles, a day to narrow them with Access Analyzer and fix the fallout in a staging stage, half a day to move secrets to runtime lookups, and half a day for the deploy role and CI checks. The result is a service where a compromised dependency in one handler reaches one table prefix instead of your whole account.
If you would rather not spend those days, our Serverless Security Consulting practice does exactly this review, and the DevOps for Serverless Apps team wires the guardrails into your pipeline afterwards. Get in touch with your serverless.yml and we will tell you what we would change first.