Deploy to AWS from GitHub Actions with OIDC, no stored keys

How to deploy to AWS from GitHub Actions with OIDC: the IAM identity provider, a role trust policy scoped to one repo and branch, and the workflow that uses it.

By Hamza Ghouri

The Buffdudes backend used to deploy the way many GitHub Actions pipelines to AWS do: a stored AWS access key in the repository secrets, used by the workflow to push a new version to Elastic Beanstalk. It worked. It also meant a long-lived credential with deploy rights lived in a place I had to trust indefinitely. I recently moved those deploys to GitHub Actions OIDC, so the workflow asks AWS for short-lived credentials on every run and no AWS key is stored anywhere. Here is how the setup works and the order I'd follow to switch.

Why a stored AWS key is a liability

A static access key has three problems. It never expires on its own, so a leak stays useful until someone notices. Rotating it is a manual chore that tends to get skipped. And anything that can read the secret, including a compromised third-party action in the workflow, can copy it out and use it from anywhere.

OIDC (OpenID Connect) flips the model. GitHub runs an identity provider that can issue a signed token for a specific workflow run. AWS is configured to trust that provider, checks the token's claims, and hands back temporary credentials through STS. The credentials expire after the session, usually an hour, and they only work for the role you allowed.

Step 1: add GitHub as an identity provider in IAM

In the IAM console, add an OpenID Connect identity provider with:

  • Provider URL: https://token.actions.githubusercontent.com
  • Audience: sts.amazonaws.com

You do this once per AWS account. Every repository that deploys to the account shares the same provider, and the per-repo control lives in the roles.

Step 2: create a role with a tight trust policy

The trust policy decides which workflows may assume the role. This is the part that needs care, because a loose condition lets any repository on GitHub try its luck.

{
  "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",
          "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
        }
      }
    }
  ]
}

The aud condition confirms the token was minted for AWS STS. The sub condition is the real lock: it pins the role to one repository and one branch. A workflow on a feature branch, a fork, or a different repo gets a token with a different sub and is refused.

Two details catch people:

  • Environments change the subject. If the deploy job declares environment: production, the token's sub becomes repo:your-org/your-repo:environment:production and the branch condition above stops matching. Pick one format and write the condition for it.
  • Wildcards widen the door. StringLike with repo:your-org/* allows every repo in the organisation. I'd keep StringEquals with the exact value unless you have a reason.

Then attach a permissions policy that only covers what the deploy does. For Elastic Beanstalk that means uploading the bundle to its S3 bucket, creating an application version and updating the environment, and Beanstalk calls a few other services on your behalf during an update. Start from what the deploy actually needs and use CloudTrail to tighten it.

Step 3: the workflow

The workflow needs permission to request an OIDC token, which is off by default. Then aws-actions/configure-aws-credentials exchanges that token for credentials using role-to-assume.

name: deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy-backend
          aws-region: us-east-1

      - name: Confirm identity
        run: aws sts get-caller-identity

      - name: Deploy to Elastic Beanstalk
        run: |
          VERSION="${GITHUB_SHA::12}"
          zip -r "app-$VERSION.zip" . -x '.git/*'
          aws s3 cp "app-$VERSION.zip" "s3://your-eb-bucket/app-$VERSION.zip"
          aws elasticbeanstalk create-application-version \
            --application-name your-app \
            --version-label "$VERSION" \
            --source-bundle S3Bucket=your-eb-bucket,S3Key="app-$VERSION.zip"
          aws elasticbeanstalk update-environment \
            --environment-name your-app-prod \
            --version-label "$VERSION"

Setting a permissions block replaces the defaults, so any scope you leave out is turned off. That is why contents: read is listed: checkout needs it. The sts get-caller-identity step costs nothing and prints the assumed role ARN, which makes the first runs easy to debug.

The bundle and Beanstalk side of this pipeline, including Nginx and Redis in front of the app, is covered in deploying Node.js to Elastic Beanstalk with CI/CD. OIDC only changes how the job authenticates; the deploy steps stay the same.

Switching without breaking deploys

The order matters more than the config. Here is how I'd do it:

  1. Create the provider and the role while the old key still works.
  2. Add the OIDC steps on a branch and point a temporary trust condition at that branch, or run the job manually, to confirm the role can be assumed and the deploy succeeds.
  3. Set the trust condition to the real deploy branch and merge.
  4. Watch one or two normal deploys go through.
  5. Delete the access key secrets from the repository, then deactivate and delete the key on the IAM user. If the user existed only for CI, delete the user.

Step 5 is the one people skip. Until the old key is gone, you have added a safer path without removing the risky one.

What OIDC leaves to you

OIDC removes the stored credential. An over-permissioned role is still dangerous, so the permissions policy deserves the same review as before. Application secrets like database passwords are a separate problem and belong in your environment configuration or a secrets manager. And branch protection matters more now: whoever can push to the trusted branch can deploy, so require reviews on it.

If you run several services, give each its own role with its own trust condition. One shared deploy role quietly lets every repo deploy everything. This is the same discipline I apply when deploying AI-backed Node.js apps to AWS, where the roles also need access to queues and storage.

The short version

Add token.actions.githubusercontent.com as an IAM identity provider, create a role whose trust policy checks aud and an exact sub for your repo and branch, grant the job id-token: write, and use configure-aws-credentials with role-to-assume. Then delete the old key. If you want this kind of pipeline set up for your own backend, my services page covers how I work.

Want this kind of work on your product?

Get in touch