+1 (726) 207-9872

CI/CD for Serverless Framework Apps with GitHub Actions and OIDC (No Stored AWS Keys)

If your GitHub Actions workflow has AWS_ACCESS_KEY_ID in its secrets, you have a long-lived credential with deploy rights sitting in a third-party system. In 2026 there is no reason for that. GitHub Actions can obtain short-lived AWS credentials through OpenID Connect (OIDC): the workflow presents a signed token, AWS verifies it against a trust policy, and STS hands back credentials that expire in an hour. No stored keys, no rotation, and a trust policy that can restrict deploys to one repository and one branch.

This tutorial sets that up for a Serverless Framework V4 project, including the V4 license requirement that trips people up on their first CI run.

1. Create the OIDC identity provider in AWS

Once per AWS account:

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com

If the account already has one (check with aws iam list-open-id-connect-providers), skip this.

2. Create a deploy role with a tight trust policy

The trust policy is where the security lives. Restrict it to your organization, repository, and the branches or environments allowed to deploy.

trust-policy.json:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:your-org/notes-api:ref:refs/heads/main"
        }
      }
    }
  ]
}
aws iam create-role --role-name github-deploy-notes-api \
  --assume-role-policy-document file://trust-policy.json

For a staging role, change the sub to repo:your-org/notes-api:ref:refs/heads/staging or, if you use GitHub Environments, repo:your-org/notes-api:environment:staging. Use one role per environment; do not let the staging workflow assume the production role.

3. Give the role the permissions Serverless Framework needs

The framework deploys through CloudFormation, uploads artifacts to an S3 deployment bucket, and creates whatever your stack defines. A least-privilege policy is specific to your stack. A practical starting point that is still far narrower than AdministratorAccess:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["cloudformation:*"],
      "Resource": "arn:aws:cloudformation:us-east-1:123456789012:stack/notes-api-*/*"
    },
    {
      "Effect": "Allow",
      "Action": ["s3:*"],
      "Resource": ["arn:aws:s3:::notes-api-*", "arn:aws:s3:::notes-api-*/*"]
    },
    {
      "Effect": "Allow",
      "Action": [
        "lambda:*", "apigateway:*", "logs:*", "events:*",
        "dynamodb:*", "iam:GetRole", "iam:PassRole",
        "iam:CreateRole", "iam:DeleteRole", "iam:PutRolePolicy",
        "iam:DeleteRolePolicy", "iam:AttachRolePolicy", "iam:DetachRolePolicy",
        "iam:TagRole"
      ],
      "Resource": "*"
    }
  ]
}

Tighten the Resource entries once the stack is stable; CloudTrail will show you exactly which ARNs the deploy touched.

aws iam put-role-policy --role-name github-deploy-notes-api \
  --policy-name deploy --policy-document file://deploy-policy.json

4. Handle the V4 license in CI

Serverless Framework V4 refuses to run unauthenticated. In CI that means one environment variable. Generate it from the Serverless Dashboard (an access key) or use your organization's license key, per the license keys guide, and add it as a GitHub repository or environment secret:

  • SERVERLESS_ACCESS_KEY, or
  • SERVERLESS_LICENSE_KEY

This is the only secret the workflow will hold, and it grants no AWS permissions.

5. The workflow

.github/workflows/deploy.yml:

name: deploy

on:
  push:
    branches: [main, staging]
  pull_request:

permissions:
  id-token: write   # required for OIDC
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test
      - run: npx serverless package --stage ci
        env:
          SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }}

  deploy:
    needs: test
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: ${{ github.ref_name == 'main' && 'production' || 'staging' }}
    concurrency: deploy-${{ github.ref_name }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
          aws-region: us-east-1
      - run: npx serverless deploy --stage ${{ github.ref_name == 'main' && 'prod' || 'staging' }} --verbose
        env:
          SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }}

Points worth noting:

  • permissions: id-token: write is what lets the job request an OIDC token. Without it, configure-aws-credentials fails with a cryptic error.
  • vars.AWS_DEPLOY_ROLE_ARN is a GitHub environment variable, set differently on the production and staging environments, so each deploys with its own role. Environment protection rules (required reviewers) give you a manual approval gate on production for free.
  • concurrency prevents two pushes from running CloudFormation updates against the same stack simultaneously.
  • Pull requests run tests and a package (which validates configuration and bundles code) but never deploy and never get AWS credentials, because the deploy job is gated on push.
  • The package step still needs the Serverless access key; V4 authenticates even for offline commands.

6. Verify

Push to staging and watch the job. The configure-aws-credentials step prints the assumed role ARN; serverless deploy --verbose prints the stack events. Confirm in CloudTrail that the AssumeRoleWithWebIdentity event carries your repository in the sub claim.

Then delete the old IAM user's access keys. That is the point of the exercise.

Common failures

  • Not authorized to perform sts:AssumeRoleWithWebIdentity: the sub condition does not match. Print the claims by temporarily loosening to repo:your-org/notes-api:*, read the CloudTrail event, then tighten again.
  • Serverless Framework requires authentication (or similar): the access or license key secret is missing from the job's env. Note that secrets are not passed to steps automatically.
  • Deploy fails on IAM role creation: the deploy role needs iam:CreateRole and iam:PassRole for the Lambda execution roles the framework creates.

Going further

Add a serverless remove --stage pr-${{ github.event.number }} job on pull_request: closed if you deploy ephemeral stages per PR; under V4 each stage is a licensed Service Instance, so tearing them down matters for the bill as well as hygiene. And once deploys are credential-free, the next step is making them observable: see Observability for Serverless in 2026.

We set up pipelines like this as part of our CI/CD for Serverless service, usually alongside a V4 upgrade. Contact us if you would like help.