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, orSERVERLESS_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: writeis what lets the job request an OIDC token. Without it,configure-aws-credentialsfails with a cryptic error.vars.AWS_DEPLOY_ROLE_ARNis a GitHub environment variable, set differently on theproductionandstagingenvironments, so each deploys with its own role. Environment protection rules (required reviewers) give you a manual approval gate on production for free.concurrencyprevents 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 thedeployjob is gated onpush. - The
packagestep 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: thesubcondition does not match. Print the claims by temporarily loosening torepo: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'senv. Note that secrets are not passed to steps automatically.- Deploy fails on IAM role creation: the deploy role needs
iam:CreateRoleandiam:PassRolefor 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.