Ready for new challenges
Dat Quoc Nguyen

# DevOps Engineer | Cloud Architect

A highly motivated and results-oriented DevOps Engineer with approximately 4 years of experience in designing, implementing, and optimizing cloud-based systems. Proven expertise in AWS, Azure, Kubernetes, and CI/CD tools. Seeking to leverage my skills to build and scale secure, efficient, and innovative cloud environments.

Keyless CI/CD on AWS: GitHub Actions + OIDC Instead of Long-Lived Access Keys

2026-11-04 · 8 min read · Nguyen Quoc Dat

For years, the default way to let a CI pipeline deploy to AWS was simple: create an IAM user, generate an access key, paste AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY into GitHub secrets, and move on. It works — until that key is leaked in a log, copied to a fork, or still valid two years after the engineer who created it left.

GitHub Actions supports OpenID Connect (OIDC), and AWS IAM can trust it directly. That means your workflow can get short-lived credentials for each job without storing any AWS secret in GitHub at all. Here's how I set it up and what to watch out for.

How it works

  1. When a job starts, GitHub can issue a signed JWT (the OIDC token) describing the run: repository, branch or tag, environment, workflow, and so on.
  2. The job calls AWS STS AssumeRoleWithWebIdentity with that token.
  3. AWS validates the signature against the GitHub OIDC provider and checks the claims against the role's trust policy.
  4. If everything matches, STS returns temporary credentials (by default valid for one hour).

There is nothing long-lived to rotate, and nothing useful to steal from your repository settings.

Step 1: Register GitHub as an identity provider

Create an IAM OIDC identity provider once per AWS account:

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

With Terraform/OpenTofu, that's an aws_iam_openid_connect_provider resource. AWS now validates GitHub's certificate chain itself, so the thumbprint is no longer the headache it used to be.

Step 2: Create a role with a tight trust policy

This is the part that actually matters for security. The trust policy decides which workflows can assume the role:

{
  "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:my-org/my-app:environment:production"
        }
      }
    }
  ]
}

A few rules I follow:

  • Always pin sub. A trust policy that only checks aud lets any repository on GitHub assume your role. This is the most common — and most dangerous — mistake.
  • Prefer environments over branches for production roles (repo:org/app:environment:production). Combined with GitHub environment protection rules (required reviewers, branch restrictions), you get an approval gate in front of your AWS credentials.
  • Use StringLike with wildcards carefully. repo:my-org/* is almost always too broad.
  • One role per purpose. A read-only role for terraform plan on pull requests, a separate deploy role for apply on the protected environment.

Step 3: Use it in the workflow

name: deploy
on:
  push:
    branches: [main]

permissions:
  id-token: write   # required to request the OIDC token
  contents: read

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

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-production
          aws-region: ap-southeast-1

      - run: aws sts get-caller-identity
      - run: ./scripts/deploy.sh

The key line is id-token: write. Without it, the job cannot request a token and configure-aws-credentials will fail with a fairly unhelpful error.

Common pitfalls

  • Not authorized to perform sts:AssumeRoleWithWebIdentity — almost always a sub mismatch. Print the claim format you expect: a job with environment: gets ...:environment:<name>, not ...:ref:refs/heads/main.
  • Pull requests from forks don't get an OIDC token with write-level trust by default — which is exactly what you want. Don't work around it with pull_request_target unless you really understand the risk.
  • Reusable workflows change the claims you might want to check (job_workflow_ref). If you centralize deploy logic, consider pinning that too.
  • Session duration: keep the default one hour unless a job genuinely needs more; raise the role's max session duration explicitly if so.

Migrating from access keys

  1. Create the OIDC provider and the new roles alongside the existing IAM user.
  2. Switch workflows one by one to role-to-assume.
  3. Watch CloudTrail: once the old access key shows no LastUsed activity, deactivate it, wait, then delete it.
  4. Add a guardrail — for example, an SCP or AWS Config rule that flags new IAM user access keys — so long-lived keys don't creep back in.

Why it's worth it

Beyond fewer secrets, you get a much cleaner audit story. Every CloudTrail event carries the role session, and the trust policy documents exactly which repository and environment may deploy where. For teams working under HIPAA or ISO 27001, "no long-lived credentials in CI" is an easy control to explain and an easy one to prove.

If your pipelines still hold static AWS keys, this is one of the highest-value, lowest-effort security improvements you can make this sprint.