DEV Community

Cover image for Serverless Framework GitHub Actions: least-privilege deploys
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Serverless Framework GitHub Actions: least-privilege deploys

If you combine the Serverless Framework and GitHub Actions, permissions quickly become an uncomfortable topic. OIDC is set up, no more access keys in the secrets, all good. And then the deploy role still carries AdministratorAccess “because the framework needs so much”. I keep running into this pattern in examples, and I get it: the official docs don’t offer a ready-made least-privilege policy.

In my article on GitHub Actions OIDC I built the trust policy with AWS CDK and only hinted at where the Serverless Framework works differently. Today I’m filling that gap: how to cut the deploy role for the Serverless Framework so that a hijacked workflow can’t rebuild half of your account. At the end you’ll have a bootstrap template, a matching serverless.yml and a workflow you can adopt right away.

In short

With the Serverless Framework, the deploy role from GitHub Actions should only drive CloudFormation, upload artifacts and pass exactly one service role. CloudFormation creates the actual resources with that service role, and every function role gets a permissions boundary. The biggest pitfall: once a stack has a service role, you can’t remove it again, so plan it properly from the start.

What you will learn in this article

  • Why OIDC alone doesn’t mean least privilege with the Serverless Framework
  • How deploy role, CloudFormation service role and permissions boundary work together
  • A working bootstrap template, serverless.yml and GitHub workflow
  • Which error messages are typical and how to fix them precisely

Why Serverless Framework and GitHub Actions need their own permission model

With AWS CDK things are fairly relaxed. The CLI assumes the roles from the bootstrap stack, and your pipeline essentially only needs sts:AssumeRole on those roles. The Serverless Framework works differently. It calls CloudFormation and S3 directly with whatever credentials are currently active. In GitHub Actions, those are the credentials of your OIDC role.

Without further precautions, this is what happens: CloudFormation creates every resource on behalf of that role. If the stack is supposed to create a Lambda function, an IAM role and an HTTP API, the deploy role itself must be allowed to do all of that. For the function’s IAM role this means iam:CreateRole and iam:PutRolePolicy. Anyone holding those two permissions without restrictions can build themselves a role with arbitrary permissions. That’s why many teams end up reaching for the admin policy after all, and the security gain from OIDC shrinks to “at least there’s no key in the secret”.

That’s not nothing. Still, the second step pays off, because a workflow is code, and code can be changed. A compromised dependency in the build or a carelessly merged pull request is enough for an attacker to work with exactly these permissions.

The target picture: three roles with clear boundaries

The solution spreads the permissions across three levels. Each level may only do what it really needs, and each one limits the next.

Diagram: GitHub workflow, deploy role, CloudFormation service role and function role with boundary, connected by arrows

The deploy role only drives CloudFormation, the service role creates resources, and the boundary caps every function role.

The deploy role is assumed by the workflow via OIDC. It may create and update your service’s stack, write artifacts to the deployment bucket and pass exactly one role to CloudFormation. It may not create Lambda functions or IAM roles itself.

The CloudFormation service role is the role CloudFormation uses to actually create the resources. It may manage Lambda functions, HTTP APIs and log groups, but only with your service’s name prefix. It may only create IAM roles if a specific permissions boundary is attached.

The function role is generated by the Serverless Framework from your serverless.yml as usual. It gets the boundary, and the boundary defines the maximum this role can ever be allowed to do. Even if someone adds generous statements to serverless.yml, only the intersection takes effect.

Why a CloudFormation service role?

A service role (in serverless.yml it’s called deploymentRole) separates two questions: who may trigger a deploy, and what may be created along the way? Without a service role, both answers are the same policy. With a service role, the deploy role answers only the first question and the service role the second.

The key is the cloudformation:RoleARN condition in the deploy role. With it, the pipeline may only create or change stacks if it passes exactly this service role. A deploy without the service role, which would then run with the pipeline’s own permissions, fails immediately.

What the permissions boundary prevents

A permissions boundary is a managed IAM policy attached to a role as an upper limit. It grants no permissions by itself. Only what both the boundary and the role’s policies allow is effective. The service role may only create new roles with exactly this boundary and may not remove it afterwards. As a result, “may create IAM roles” turns into a manageable “may create roles that can at most write logs, access their own tables and send traces”.

🎬 Serverless Framework GitHub Actions: how a deploy flows – this animated diagram only runs on my site: watch it on sascha-ms.de

Set up Serverless Framework with GitHub Actions step by step

The example is a small API for a bike rental: a Lambda function behind an HTTP API that returns free bikes per station. It’s deliberately small so that the permissions stay in focus. For larger services you extend the service role, while the deploy role stays the same.

Prerequisites

  • An AWS account and credentials with admin permissions for the one-time bootstrap (locally or via IAM Identity Center)
  • AWS CLI v2 and the GitHub CLI gh
  • Node.js 24 and a repository on GitHub, here example-org/bike-rental-api
  • An account for Serverless Framework v4 and an access key from the dashboard (Settings → Access Keys)

A word on licensing: since version 4, the CLI requires you to sign in. According to the pricing page, it stays free for organizations that earned at most 2 million US dollars in their last fiscal year. Above that, you need a subscription. How to get to v4 in the first place is covered in my article on migrating to Serverless Framework v4.

Step 1: Create the bootstrap template

You deploy the template once per account and service with admin permissions. It contains the OIDC provider (optional, in case it already exists), a dedicated deployment bucket, the boundary, the service role and the deploy role. I deliberately use plain CloudFormation here, so it works regardless of the tool you build the service itself with.

# bootstrap/deploy-access.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: Deploy access for GitHub Actions and the Serverless Framework (once per account and service)

Parameters:
  ServiceName:
    Type: String
    Default: bike-rental-api
    AllowedPattern: '^[a-z0-9-]{3,30}$'
  GitHubSubject:
    Type: String
    Description: Exact sub claim, e.g. repo:example-org/bike-rental-api:environment:prod
  CreateOidcProvider:
    Type: String
    AllowedValues: ['true', 'false']
    Default: 'true'

Conditions:
  CreateProvider: !Equals [!Ref CreateOidcProvider, 'true']

Resources:
  GitHubOidcProvider:
    Type: AWS::IAM::OIDCProvider
    Condition: CreateProvider
    Properties:
      Url: https://token.actions.githubusercontent.com
      ClientIdList:
        - sts.amazonaws.com

  # Own deployment bucket instead of the auto-created one
  DeploymentBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub '${ServiceName}-deploy-${AWS::AccountId}-${AWS::Region}'
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerEnforced
      LifecycleConfiguration:
        Rules:
          - Id: expire-old-versions
            Status: Enabled
            NoncurrentVersionExpiration:
              NoncurrentDays: 30

  DeploymentBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref DeploymentBucket
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: TlsOnly
            Effect: Deny
            Principal: '*'
            Action: 's3:*'
            Resource:
              - !GetAtt DeploymentBucket.Arn
              - !Sub '${DeploymentBucket.Arn}/*'
            Condition:
              Bool:
                aws:SecureTransport: 'false'

  # Upper limit for all function roles the service creates
  FunctionBoundary:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      ManagedPolicyName: !Sub 'deploy-${ServiceName}-function-boundary'
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: Logs
            Effect: Allow
            Action:
              - logs:CreateLogGroup
              - logs:CreateLogStream
              - logs:PutLogEvents
              - logs:TagResource
            Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${ServiceName}-*'
          - Sid: Tables
            Effect: Allow
            Action:
              - dynamodb:GetItem
              - dynamodb:PutItem
              - dynamodb:UpdateItem
              - dynamodb:DeleteItem
              - dynamodb:Query
            Resource: !Sub 'arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${ServiceName}-*'
          - Sid: Tracing
            Effect: Allow
            Action:
              - xray:PutTraceSegments
              - xray:PutTelemetryRecords
            Resource: '*'

  # CloudFormation uses this role to create the resources
  CfnExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub 'deploy-${ServiceName}-cfn-exec'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: cloudformation.amazonaws.com
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
      Policies:
        - PolicyName: service-resources
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: Functions
                Effect: Allow
                Action: 'lambda:*'
                Resource:
                  - !Sub 'arn:aws:lambda:${AWS::Region}:${AWS::AccountId}:function:${ServiceName}-*'
              - Sid: HttpApi
                Effect: Allow
                Action:
                  - apigateway:GET
                  - apigateway:POST
                  - apigateway:PUT
                  - apigateway:PATCH
                  - apigateway:DELETE
                Resource:
                  - !Sub 'arn:aws:apigateway:${AWS::Region}::/apis'
                  - !Sub 'arn:aws:apigateway:${AWS::Region}::/apis/*'
                  - !Sub 'arn:aws:apigateway:${AWS::Region}::/tags/*'
              - Sid: LogGroups
                Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:DeleteLogGroup
                  - logs:PutRetentionPolicy
                  - logs:DeleteRetentionPolicy
                  - logs:TagResource
                  - logs:UntagResource
                  - logs:ListTagsForResource
                Resource: !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/lambda/${ServiceName}-*'
              - Sid: DescribeLogGroups
                Effect: Allow
                Action: logs:DescribeLogGroups
                Resource: '*'
              - Sid: RolesOnlyWithBoundary
                Effect: Allow
                Action:
                  - iam:CreateRole
                  - iam:PutRolePolicy
                  - iam:DeleteRolePolicy
                  - iam:AttachRolePolicy
                  - iam:DetachRolePolicy
                  - iam:PutRolePermissionsBoundary
                Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/${ServiceName}-*'
                Condition:
                  StringEquals:
                    iam:PermissionsBoundary: !Ref FunctionBoundary
              - Sid: ManageRoles
                Effect: Allow
                Action:
                  - iam:GetRole
                  - iam:GetRolePolicy
                  - iam:DeleteRole
                  - iam:TagRole
                  - iam:UntagRole
                Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/${ServiceName}-*'
              - Sid: PassRolesToLambda
                Effect: Allow
                Action: iam:PassRole
                Resource: !Sub 'arn:aws:iam::${AWS::AccountId}:role/${ServiceName}-*'
                Condition:
                  StringEquals:
                    iam:PassedToService: lambda.amazonaws.com
              - Sid: KeepBoundary
                Effect: Deny
                Action: iam:DeleteRolePermissionsBoundary
                Resource: '*'
              - Sid: ReadCode
                Effect: Allow
                Action: s3:GetObject
                Resource: !Sub '${DeploymentBucket.Arn}/*'

  # The GitHub workflow assumes this role via OIDC
  GitHubDeployRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub 'deploy-${ServiceName}-github'
      MaxSessionDuration: 3600
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Federated: !If
                - CreateProvider
                - !Ref GitHubOidcProvider
                - !Sub 'arn:aws:iam::${AWS::AccountId}:oidc-provider/token.actions.githubusercontent.com'
            Action: sts:AssumeRoleWithWebIdentity
            Condition:
              StringEquals:
                token.actions.githubusercontent.com:aud: sts.amazonaws.com
                token.actions.githubusercontent.com:sub: !Ref GitHubSubject
      Policies:
        - PolicyName: serverless-deploy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: StackOnlyWithServiceRole
                Effect: Allow
                Action:
                  - cloudformation:CreateStack
                  - cloudformation:UpdateStack
                  - cloudformation:CreateChangeSet
                Resource: !Sub 'arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${ServiceName}-*/*'
                Condition:
                  StringEquals:
                    cloudformation:RoleARN: !GetAtt CfnExecutionRole.Arn
              - Sid: ReadStack
                Effect: Allow
                Action:
                  - cloudformation:DescribeStacks
                  - cloudformation:DescribeStackEvents
                  - cloudformation:DescribeStackResource
                  - cloudformation:DescribeStackResources
                  - cloudformation:ListStackResources
                  - cloudformation:GetTemplate
                  - cloudformation:DescribeChangeSet
                  - cloudformation:ExecuteChangeSet
                  - cloudformation:DeleteChangeSet
                Resource: !Sub 'arn:aws:cloudformation:${AWS::Region}:${AWS::AccountId}:stack/${ServiceName}-*/*'
              - Sid: ValidateTemplate
                Effect: Allow
                Action: cloudformation:ValidateTemplate
                Resource: '*'
              - Sid: ReadBucket
                Effect: Allow
                Action:
                  - s3:ListBucket
                  - s3:GetBucketLocation
                Resource: !GetAtt DeploymentBucket.Arn
              - Sid: WriteArtifacts
                Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:PutObject
                  - s3:DeleteObject
                Resource: !Sub '${DeploymentBucket.Arn}/serverless/${ServiceName}/*'
              - Sid: PassOnlyTheServiceRole
                Effect: Allow
                Action: iam:PassRole
                Resource: !GetAtt CfnExecutionRole.Arn
                Condition:
                  StringEquals:
                    iam:PassedToService: cloudformation.amazonaws.com
              - Sid: ReadFrameworkParameters
                Effect: Allow
                Action: ssm:GetParameter
                Resource: !Sub 'arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/serverless-framework/*'

Outputs:
  DeploymentBucketName:
    Value: !Ref DeploymentBucket
  CfnExecutionRoleArn:
    Value: !GetAtt CfnExecutionRole.Arn
  FunctionBoundaryArn:
    Value: !Ref FunctionBoundary
  GitHubDeployRoleArn:
    Value: !GetAtt GitHubDeployRole.Arn
Enter fullscreen mode Exit fullscreen mode

A few details matter to me. The template’s roles start with deploy- and therefore don’t fall under the bike-rental-api-* prefix the service role may manage. Otherwise the service role could delete or rebuild the deploy role. The dedicated bucket replaces the shared bucket the framework would otherwise create automatically. That way, the deploy role needs neither s3:CreateBucket nor write access to bucket policies. I checked the template with cfn-lint. Incidentally, that revealed that API Gateway doesn’t know apigateway:TagResource; tags there go through the regular HTTP verbs.

Step 2: Deploy the template once

For the GitHubSubject parameter, enter exactly the sub claim your repository sends. For repositories created after July 15, 2026, it also contains numeric IDs. How to check the format is explained in the OIDC article. If the OIDC provider already exists in the account, set CreateOidcProvider=false.

aws cloudformation deploy \
  --stack-name deploy-bike-rental-api \
  --template-file bootstrap/deploy-access.yml \
  --capabilities CAPABILITY_NAMED_IAM \
  --region eu-central-1 \
  --parameter-overrides \
    GitHubSubject=repo:example-org/bike-rental-api:environment:prod \
    CreateOidcProvider=true

aws cloudformation describe-stacks \
  --stack-name deploy-bike-rental-api \
  --region eu-central-1 \
  --query "Stacks[0].Outputs" --output table
Enter fullscreen mode Exit fullscreen mode

The output shows four values: bucket name, service role ARN, boundary ARN and deploy role ARN. You’ll need them in GitHub in a moment.

Step 3: Adjust serverless.yml

In serverless.yml you reference the dedicated bucket, the service role and the boundary. I read the values from environment variables. If one of them is missing, the framework stops with a clear message instead of silently working with a different bucket.

# serverless.yml
service: bike-rental-api
frameworkVersion: '4'

provider:
  name: aws
  runtime: nodejs24.x
  architecture: arm64
  region: eu-central-1
  stage: ${opt:stage, 'dev'}
  memorySize: 256
  logRetentionInDays: 14
  deploymentBucket:
    name: ${env:DEPLOYMENT_BUCKET}
  iam:
    # CloudFormation creates the resources with this role
    deploymentRole: ${env:CFN_EXECUTION_ROLE_ARN}
    role:
      # Every function role gets the upper limit from the bootstrap
      permissionsBoundary: ${env:FUNCTION_BOUNDARY_ARN}

functions:
  stations:
    handler: src/stations.handler
    events:
      - httpApi:
          path: /stations
          method: get
Enter fullscreen mode Exit fullscreen mode

With permissionsBoundary under provider.iam.role, the framework attaches the boundary to the generated function role. Without this line, IAM rejects the role creation, because the service role may only create roles with a boundary. That’s intentional: the safeguard doesn’t depend on someone remembering the line.

Step 4: The function

The function itself is unspectacular. Serverless Framework v4 builds TypeScript handlers with esbuild out of the box, so you don’t need an extra plugin.

// src/stations.ts
import type { APIGatewayProxyHandlerV2 } from 'aws-lambda';

interface Station {
  id: string;
  name: string;
  freeBikes: number;
}

const stations: Station[] = [
  { id: 'central', name: 'Central Station', freeBikes: 7 },
  { id: 'harbour', name: 'Harbour', freeBikes: 2 },
  { id: 'uni', name: 'University', freeBikes: 0 },
];

export const handler: APIGatewayProxyHandlerV2 = async () => {
  const available = stations.filter((s) => s.freeBikes > 0);
  return {
    statusCode: 200,
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ stations: available }),
  };
};
Enter fullscreen mode Exit fullscreen mode

Step 5: Store variables and the secret in GitHub

Create an environment prod in GitHub and store the four values there as variables. The ARNs aren’t secrets, so variables are enough. Only the access key for the Serverless Framework belongs in a secret. Use a dedicated key with a descriptive name such as github-ci, not your personal one. Your personal key stops working as soon as you leave the organization.

gh api -X PUT repos/example-org/bike-rental-api/environments/prod

gh variable set AWS_DEPLOY_ROLE_ARN --env prod --body "arn:aws:iam::111122223333:role/deploy-bike-rental-api-github"
gh variable set DEPLOYMENT_BUCKET --env prod --body "bike-rental-api-deploy-111122223333-eu-central-1"
gh variable set CFN_EXECUTION_ROLE_ARN --env prod --body "arn:aws:iam::111122223333:role/deploy-bike-rental-api-cfn-exec"
gh variable set FUNCTION_BOUNDARY_ARN --env prod --body "arn:aws:iam::111122223333:policy/deploy-bike-rental-api-function-boundary"

gh secret set SERVERLESS_ACCESS_KEY --env prod
Enter fullscreen mode Exit fullscreen mode

Protection rules for the environment (for example approval by a second person, or only the main branch) give you a second safeguard. The role’s trust policy accepts tokens from exactly this environment anyway.

Step 6: The workflow

The workflow exchanges the OIDC token for credentials of the deploy role and starts the deploy. The workflow’s own permissions are limited to id-token: write and contents: read.

# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

concurrency:
  group: deploy-prod
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: prod
    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm

      - run: npm ci
      - run: npx tsc --noEmit

      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
          role-session-name: gh-deploy-${{ github.run_id }}
          aws-region: eu-central-1

      - run: npx serverless deploy --stage prod
        env:
          SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }}
          DEPLOYMENT_BUCKET: ${{ vars.DEPLOYMENT_BUCKET }}
          CFN_EXECUTION_ROLE_ARN: ${{ vars.CFN_EXECUTION_ROLE_ARN }}
          FUNCTION_BOUNDARY_ARN: ${{ vars.FUNCTION_BOUNDARY_ARN }}
Enter fullscreen mode Exit fullscreen mode

Step 7: Check the result

After the first run, the framework reports, roughly speaking, that the service was deployed to the stack bike-rental-api-prod and shows the HTTP API endpoint. Two checks are worth it:

aws cloudformation describe-stacks \
  --stack-name bike-rental-api-prod \
  --region eu-central-1 \
  --query "Stacks[0].RoleARN" --output text

curl -s https://abc123xyz.execute-api.eu-central-1.amazonaws.com/stations
Enter fullscreen mode Exit fullscreen mode

The first command must print the ARN of deploy-bike-rental-api-cfn-exec. If it says None, the stack runs without a service role. The second returns the stations with free bikes as JSON, so Central Station and Harbour. Also take a look at the function role in the IAM console: under “Permissions boundary” you should see the boundary from the bootstrap.

What the deploy role may do and what it may not

The following overview helps me whenever someone asks why a particular action is in the policy. It also shows what is deliberately missing.

Permission Purpose Scope
cloudformation:CreateStack, UpdateStack, CreateChangeSet Create and update the stack only prefixed stacks and only with the service role
cloudformation:Describe*, GetTemplate Follow progress, read outputs only prefixed stacks
s3:PutObject, GetObject, DeleteObject Upload packages, compare hashes, clean up old artifacts only the path serverless/bike-rental-api/
iam:PassRole Pass the service role to CloudFormation one role, only to CloudFormation
ssm:GetParameter Read framework parameters, e.g. a license key only /serverless-framework/*
not included lambda:*, iam:CreateRole, cloudformation:DeleteStack handled by the service role or a human

DeleteStack is missing on purpose. No workflow should be able to run a serverless remove against production on the side. For short-lived environments, say per pull request, you’re better off creating a separate role with its own stack prefix.

To be clear, this policy is a good starting point, not a guarantee for every configuration. Depending on the framework version, plugins and variables such as ${cf:...}, additional read calls may come up. The approach is always the same: run the deploy, read the action from the error message or from CloudTrail, and add it specifically, scoped to the matching resource.

Common errors and how to fix them

AccessDenied on cloudformation:CreateStack

Usually deploymentRole is missing from serverless.yml or the environment variable is empty. Then the framework calls CreateStack without a service role, and the cloudformation:RoleARN condition kicks in. Check the workflow log to see whether all four variables are set.

Not authorized to perform iam:PassRole

If the message names the role deploy-bike-rental-api-cfn-exec, the ARN in the variable doesn’t match the role from the bootstrap. If instead a role with the prefix bike-rental-api- shows up, the error comes from CloudFormation: the service role then lacks the permission to pass the function role to Lambda, for instance because the service name doesn’t match the prefix.

iam:CreateRole is denied

This happens when permissionsBoundary is missing or points to a different policy. The stack then rolls back, and the stack events show the denied action. Compare the ARN with the FunctionBoundaryArn output.

New resource types in the stack

If you add a DynamoDB table or an SQS queue under resources, the service role needs matching permissions, such as dynamodb:CreateTable on table/bike-rental-api-*. The deploy role stays unchanged. That’s the real benefit of this setup: changes to the service affect only one place, and it doesn’t live in the service’s repository.

Existing stack without a service role

An existing stack can be updated with a service role on its next update. The reverse doesn’t work: according to the AWS documentation, you can’t remove a service role from a stack once it has been set. In addition, anyone with permissions on the stack can use this role, even without their own iam:PassRole. So keep the service role really narrow.

Costs and security

IAM roles, boundaries and OIDC cost nothing. Depending on package size, the deployment bucket costs a few cents per month. The lifecycle rule deletes old object versions after 30 days, and the framework itself keeps the last five deployments by default. With the Serverless Framework, the access key is the real weak spot, because it’s tied to a user. Rotate it regularly and limit the secret to the prod environment so that workflows from other branches can’t even get to it.

My take

I like the Serverless Framework for its concise serverless.yml, and I don’t want to make it look worse than it is. When it comes to permissions, though, you can tell it comes from a time when “admin in the dev account” was normal. CDK has the better foundation with its bootstrap concept. With a service role and a boundary, however, the Serverless Framework catches up almost completely, and the effort is a one-off.

Where I disagree: to me, “least privilege” doesn’t mean filing every single action down to the minimum until every new plugin version breaks the deploy. What matters more to me is that the dangerous permissions (creating roles, passing roles, deleting stacks) are cleanly limited. I sleep well with lambda:* on functions with a fixed prefix. With iam:* on *, I don’t.

FAQ

Do I need a license key for Serverless Framework v4 in GitHub Actions?

No, an access key from the dashboard is enough; you store it as SERVERLESS_ACCESS_KEY. A license key is an alternative that isn’t tied to a user, but it requires an active subscription.

Can I keep using the automatically created deployment bucket?

Yes, but then the deploy role also needs permissions to create the bucket and for the SSM parameter where the framework stores the bucket name. A dedicated bucket from the bootstrap keeps the deploy role leaner.

Does this work with multiple stages?

Yes. The cleanest option is one account per stage with its own bootstrap. Within the same account, create a separate deploy role per stage with its own GitHub environment in the sub claim.

Do I have to specify the service role on every deploy?

CloudFormation remembers the role on the stack. The framework passes it on every deploy anyway, and because of the condition in the deploy role, it has to.

Your next step

Take a look at the deploy role of your most important Serverless Framework service. If it carries AdministratorAccess or iam:*, grab the bootstrap template, adapt the service role to your resources and roll it out in a test account first. Which action did you have to add first? Feel free to share it in the comments, I’m collecting the cases.

Sources

This post was created with AI assistance.


👉 Next step: Working with AWS CDK? Grab my printable AWS CDK cheat sheet with commands, concepts and a stack skeleton.

📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)