+1 (726) 207-9872

Least-Privilege IAM and Secrets Management for Serverless Framework Apps

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.Arn and !Sub keep 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-lint and checkov on the packaged template in CI (serverless package writes it to .serverless/). Both flag wildcard actions and unencrypted resources.
  • Dependency scanningnpm audit --audit-level=high or Dependabot — because the compromise path into a Lambda is usually a transitive package.
  • A deny guardrail 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.