DEV Community

Cover image for Stop Storing Access Keys in GitHub Secrets: Use OIDC for AWS
Ajinkya
Ajinkya

Posted on

Stop Storing Access Keys in GitHub Secrets: Use OIDC for AWS

If your GitHub Actions pipeline deploys to AWS, there's a good chance you've seen (or written) this:

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Enter fullscreen mode Exit fullscreen mode

It works. It's also a quiet liability sitting in your repo settings.

There's a better way: no static keys, no rotation headaches, no credentials sitting in GitHub Secrets. Let's look at why the old way hurts, and how OIDC fixes it.

Index

  1. The problem with static AWS credentials
  2. What is AWS OIDC?
  3. Heads up: GitHub just changed the token format
  4. Build it with Terraform
  5. The workflow
  6. Debugging: see what GitHub actually sends
  7. Why this is secure
  8. Wrapping up

The problem with static AWS credentials

A traditional workflow looks like this:

GitHub Actions
      |
      | AWS_ACCESS_KEY_ID
      | AWS_SECRET_ACCESS_KEY
      v
     AWS
Enter fullscreen mode Exit fullscreen mode

Those two values are long-lived credentials. They don't expire when the job ends. They don't care which repo, branch, or machine uses them. Whoever holds them is your pipeline, today, next month, and next year.

Even when they're stored "securely" as GitHub Secrets, you still have to:

  • Rotate them periodically (and remember to)
  • Manage their lifecycle across every repo that uses them
  • Make sure they never leak, whether in logs, forks, a pasted screenshot, or a compromised dependency
  • Revoke them when a project or teammate is gone
  • Keep the IAM user's permissions tight, forever

Think about what a leak looks like: a key copied into a chat, printed by a debug step, or lifted by a malicious action. It works from any laptop on the planet until someone notices. Many keys never get rotated at all.

Here's the thing, though: a CI/CD pipeline doesn't need permanent credentials. It needs credentials that exist only while the pipeline is running.

That's exactly what OIDC gives you.

What is AWS OIDC?

OpenID Connect (OIDC) lets GitHub Actions prove its identity to AWS and receive temporary credentials, with no stored AWS keys.

                    GitHub
                      |
                GitHub Actions
                      |
                      | OIDC Token
                      v
                AWS IAM / STS
                      |
                      | AssumeRoleWithWebIdentity
                      v
                 IAM Role
                      |
                      | Temporary Credentials
                      v
                 AWS Resources
Enter fullscreen mode Exit fullscreen mode

GitHub never hands you an access key to manage. Instead, for every workflow run, it generates a signed OIDC token that says who is asking: which repo, which branch, which run. AWS verifies the signature, checks your role's trust policy, and returns credentials that expire on their own (one hour by default).

Static access keys OIDC
Stored in GitHub Secrets Nothing stored
Valid until rotated (often never) Expire in about an hour
A leak works from anywhere Token only works for your repo and branch
Manual rotation Nothing to rotate

The API behind it is AssumeRoleWithWebIdentity. The name is historical (it was built for web and mobile logins), but it's the same call used for GitHub, GitLab, and EKS. The caller needs no AWS credentials at all, because the signed token is the proof.

Heads up: GitHub just changed the token format

This is the part that's breaking older tutorials. The sub (subject) claim in the token used to look like this:

repo:octo-org/octo-repo:ref:refs/heads/main
Enter fullscreen mode Exit fullscreen mode

For repositories created after July 15, 2026, it now includes immutable numeric IDs:

repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main
Enter fullscreen mode Exit fullscreen mode

123456 is the owner ID and 456789 is the repo ID. The @ separator is used because it can't appear in a GitHub username or repo name.

  • New repos get the new format automatically.
  • Renamed or transferred repos after that date also move to it.
  • Older repos keep the old format unless you opt in (Settings → Actions → OIDC).
  • GitHub Enterprise Server isn't part of the rollout.

If your trust policy expects the old format and your repo sends the new one, AWS answers with Not authorized to perform sts:AssumeRoleWithWebIdentity. Most guides online still show the old format, so this catches a lot of people.

Why GitHub changed it

The old sub was built from names, and names can be reused:

  1. You own alice/deploy-tools, and your AWS role trusts that exact name.
  2. You delete or rename the repo.
  3. An attacker registers the same name and creates the same repo.
  4. Their workflows produce the same sub as yours, and your role accepts them.

Numeric IDs are assigned once and never reused. A recreated repo gets different IDs, so its sub no longer matches your trust policy.

Build it with Terraform

1. Get your IDs

gh api repos/YOUR_USERNAME/YOUR_REPO --jq '{repo_id: .id, owner_id: .owner.id}'
Enter fullscreen mode Exit fullscreen mode

Or open https://api.github.com/repos/YOUR_USERNAME/YOUR_REPO in a browser (public repos) and read id and owner.id.

2. Variables

variable "github_username" { type = string }
variable "github_repo"     { type = string }

variable "github_branch" {
  type    = string
  default = "main"
}

variable "github_owner_id" {
  type    = string
  default = ""
}

variable "github_repo_id" {
  type    = string
  default = ""
}

variable "create_oidc_provider" {
  type    = bool
  default = true
}
Enter fullscreen mode Exit fullscreen mode

3. Provider and subject

resource "aws_iam_openid_connect_provider" "github" {
  count          = var.create_oidc_provider ? 1 : 0
  url            = "https://token.actions.githubusercontent.com"
  client_id_list = ["sts.amazonaws.com"]
}

data "aws_iam_openid_connect_provider" "github" {
  count = var.create_oidc_provider ? 0 : 1
  url   = "https://token.actions.githubusercontent.com"
}

locals {
  oidc_provider_arn = var.create_oidc_provider ? aws_iam_openid_connect_provider.github[0].arn : data.aws_iam_openid_connect_provider.github[0].arn

  use_ids = var.github_owner_id != "" && var.github_repo_id != ""

  github_sub = local.use_ids ? (
    "repo:${var.github_username}@${var.github_owner_id}/${var.github_repo}@${var.github_repo_id}:ref:refs/heads/${var.github_branch}"
  ) : (
    "repo:${var.github_username}/${var.github_repo}:ref:refs/heads/${var.github_branch}"
  )
}
Enter fullscreen mode Exit fullscreen mode

AWS allows only one GitHub OIDC provider per account, so set create_oidc_provider = false if yours already exists. The thumbprint can be omitted on AWS provider 5.81 or newer. The conditional builds the new format when you pass both IDs and falls back to the old one otherwise.

4. Trust policy, role, and permissions

data "aws_iam_policy_document" "trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]

    principals {
      type        = "Federated"
      identifiers = [local.oidc_provider_arn]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = [local.github_sub]
    }
  }
}

resource "aws_iam_role" "github_actions" {
  name               = "github-actions-deploy"
  assume_role_policy = data.aws_iam_policy_document.trust.json
}

resource "aws_iam_role_policy_attachment" "s3" {
  role       = aws_iam_role.github_actions.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}

output "github_actions_role_arn" {
  value = aws_iam_role.github_actions.arn
}

output "allowed_subject" {
  value = local.github_sub
}
Enter fullscreen mode Exit fullscreen mode

The trust policy answers who can become the role. The policy attachment answers what the role can do. They're separate on purpose.

AmazonS3FullAccess is fine for a demo, but it allows every S3 action on every bucket. For real use, swap it for a policy scoped to the your need and the resource you need.

terraform init
terraform apply
terraform output
Enter fullscreen mode Exit fullscreen mode

The workflow

Save the role ARN as a repository secret or variable named AWS_ROLE_ARN. Notice what's missing: no access key, no secret key.

name: OIDC Test

on:
  workflow_dispatch: {}

permissions:
  id-token: write      # without this, no token, no login
  contents: read

jobs:
  OIDC-Test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: ap-south-1

      - run: aws sts get-caller-identity
Enter fullscreen mode Exit fullscreen mode

The classic mistake is forgetting id-token: write.

Debugging: see what GitHub actually sends

If you hit Not authorized to perform sts:AssumeRoleWithWebIdentity, add this step before the AWS step:

      - name: Print OIDC claims
        run: |
          TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
          payload=$(echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+')
          while [ $(( ${#payload} % 4 )) -ne 0 ]; do payload="${payload}="; done
          echo "$payload" | base64 -d | jq '{iss, aud, sub, repository}'
Enter fullscreen mode Exit fullscreen mode

Compare the printed sub with terraform output allowed_subject, character for character. That's how I found my own bug: my token showed repo:USER@191673596/REPO@1400242638:... while my trust policy expected the old format. Adding the IDs fixed it instantly.

Other quick checks:

  • Case: usernames and repo names are case-sensitive.
  • Branch: a manual run uses whichever branch you picked in the dropdown.
  • Credentials could not be loaded: role-to-assume was empty. Use secrets. for secrets and vars. for variables.
  • Assuming role with OIDC repeated: the action is retrying a rejected call. The real error is on the line after.

Why this is secure

  • Signed token: AWS verifies the signature, so it can't be forged.
  • Audience check: tokens meant for other services are rejected.
  • Exact sub match: only your repo and branch get in.
  • Immutable IDs: a recycled name can't impersonate you.
  • Short-lived credentials: they expire on their own.
  • Nothing stored: nothing to steal from settings, logs, or forks.

A few habits make it stronger. Never use wildcards like repo:OWNER/* in the sub. Use one role per purpose. Protect production with a GitHub Environment and required reviewers. And remember that every new repo needs its own IDs in its trust policy.

Wrapping up

Static keys in GitHub Secrets were always a compromise. With OIDC your pipeline gets credentials only when it runs, only for the repo and branch you chose, and they vanish on their own. Just watch the new subject format, because older tutorials won't match what your new repos send.

If you're still pasting access keys into secrets, try this on one repo this week. You'll wonder why you waited.

Sources: GitHub Docs (OIDC reference, "Immutable subject claims") and the GitHub Changelog announcement on immutable subject claims.

Top comments (0)