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.ymland 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.
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 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 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 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 }), }; }; 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 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 }} 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 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. Th...
