Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 045 / 100

OIDC from GitHub Actions to AWS: lessons from production

No more access keys in CI.

Expona Admin7 min read

The problem with long-lived credentials

We all know the drill: generate an AWS access key, paste it into GitHub secrets, and hope nobody commits it by accident. Yet every team that's scaled beyond a handful of repos has felt that knot in their stomach when a key gets exposed—or worse, when you can't remember which service is using which credential. The solution has been sitting in AWS and GitHub for years, but adoption is still slower than it should be. OIDC from GitHub Actions to AWS: lessons from production replaces that static key model entirely, issuing short-lived credentials on a per-job basis instead.

Once you've lived with OIDC in production, going back feels reckless. No more rotation schedules, no more leaked-key incidents, no more access keys cluttering your secrets manager. Just time-limited tokens issued by GitHub, validated by AWS, and automatically cleaned up when your workflow finishes.

Why OIDC beats access keys

The traditional approach—static access keys stored as secrets—has a few fundamental weaknesses. Keys don't expire automatically. They grant the same permissions regardless of which repo, branch, or workflow triggered the action. If a key leaks, you have minutes to notice and revoke before damage spreads. And auditing which service used which key becomes a detective story rather than a logged fact.

OIDC flips the model. GitHub Actions generates a short-lived JSON Web Token (JWT) with claims about the repo, branch, commit, and workflow. AWS validates that token against GitHub's public keys, and if the claims match your trust policy, issues temporary credentials. The whole exchange is cryptographic and auditable. No keys in your secrets. No standing access. Just time-bound delegation of authority.

Teams we've worked with see an immediate security posture improvement: fewer secrets to rotate, tighter scope per job, and audit trails that actually tell you what ran and when.

Create an IAM OIDC provider for GitHub

Start by registering GitHub as an OIDC provider in your AWS account. AWS needs to know it can trust tokens signed by GitHub's private key. This is a one-time setup per account.

hcl
resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = ["6938fd4d98bab03faadb97b34396831e3780aea1"]
}

That thumbprint is GitHub's certificate thumbprint and changes rarely, but verify it against the current value in AWS documentation. The client ID is always sts.amazonaws.com for AWS OIDC flows.

Once the provider is created, AWS trusts any JWT signed by GitHub. But that's not enough—you still need to lock down which repos and branches can assume roles.

Role trust policy limits repo and branch

Here's where production discipline matters. You don't want every repo in your GitHub org assuming your production deployment role. A malicious contributor or leaked workflow file could escalate to production access. Instead, define precise trust conditions in your role's assume role policy.

hcl
data "aws_caller_identity" "current" {}

resource "aws_iam_role" "github_actions" {
  name = "github-actions-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Principal = {
          Federated = "arn:aws:iam::${data.aws_caller_identity.current.account_id}: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/your-repo:ref:refs/heads/main"
          }
        }
      }
    ]
  })
}

The key fields are:

  • Federated: Points to your OIDC provider.
  • aud (audience): Must be sts.amazonaws.com.
  • sub (subject): Encodes the repo, branch, or other constraints. For production, pin to a specific branch like main. For staging, you might allow ref:refs/heads/develop or even all branches with repo:your-org/your-repo:*.

That StringLike condition is your firewall. Without it, any GitHub Actions workflow in your org could assume this role. We've seen teams learn this lesson the hard way after a junior contributor accidentally pushed a deploy script to a feature branch.

Attach permissions to the role based on what the workflow needs:

hcl
resource "aws_iam_role_policy" "github_actions_policy" {
  name = "github-actions-policy"
  role = aws_iam_role.github_actions.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject"
        ]
        Resource = "arn:aws:s3:::my-deployment-bucket/*"
      },
      {
        Effect = "Allow"
        Action = [
          "cloudformation:DescribeStacks",
          "cloudformation:UpdateStack"
        ]
        Resource = "arn:aws:cloudformation:*:*:stack/my-stack/*"
      }
    ]
  })
}

Keep these permissions as narrow as possible. If your CI only deploys to S3 and updates one CloudFormation stack, that's all it should be able to do.

aws-actions/configure-aws-credentials in your workflow

On the GitHub Actions side, the configure-aws-credentials action does the heavy lifting. It exchanges the GitHub-generated JWT for temporary AWS credentials and injects them into the job's environment.

hcl
name: Deploy to AWS

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4

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

      - name: Deploy
        run: |
          aws s3 cp dist/ s3://my-deployment-bucket/ --recursive
          aws cloudformation update-stack --stack-name my-stack --template-body file://template.yaml

The critical lines are:

  • permissions.id-token: write grants the workflow permission to request a token from GitHub.
  • role-to-assume specifies the IAM role ARN created above.
  • aws-region sets where the credentials are valid.

The action uses GitHub's OIDC endpoint to get a JWT, posts it to AWS STS with the role ARN, and receives temporary credentials. Those credentials are then exported as environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) and automatically cleaned up when the job completes.

One detail: the action can also assume a role in a different account (cross-account deployment) by specifying role-duration-seconds and role-session-name. For multi-environment deploys, this is powerful:

hcl
      - name: Configure AWS credentials for production
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::999888777666:role/github-actions-prod
          aws-region: us-east-1
          role-duration-seconds: 1800
          role-session-name: github-actions-${{ github.run_id }}

Short-lived credentials per job

The whole point of OIDC is that credentials are ephemeral. By default, configure-aws-credentials requests credentials valid for one hour. That's enough for most CI jobs, but if your deploy takes three hours, you can extend it. The maximum is 12 hours for web identity federation.

Every job gets its own session. The credentials are tied to that specific GitHub Actions run ID. If someone steals the credentials and tries to use them after the job ends, they're already expired. No revocation needed, no manual cleanup—AWS automatically invalidates them.

This is also why you don't need to rotate GitHub OIDC credentials like you would access keys. The token itself is valid for only five minutes. You can't use it outside its immediate context (the GitHub Actions runner), and it's signed by GitHub, so you can't forge one.

From an operational perspective, this means your audit logs are cleaner too. Each CloudTrail entry shows which GitHub run ID made the call, which repo triggered it, and which branch it ran on. Tracing a production change back to the commit is instant.

Common mistakes

Forgetting the permissions block. If your workflow doesn't include permissions.id-token: write, the action can't request a token, and the assume role call fails silently or with a cryptic error. Add it to every workflow.

Being too permissive with the subject claim. We've seen teams use "token.actions.githubusercontent.com:sub" = "repo:your-org/*" to allow any repo. That defeats most of OIDC's security value. Pin to specific repos and branches where possible. If you have many repos, create separate roles for different environments (staging vs. production) and tie each to its relevant repos.

Not setting a session name. If multiple workflows run in parallel, they all assume the same role with the same default session name. CloudTrail can't distinguish which workflow made which call. Use role-session-name: github-actions-${{ github.run_id }} to make each session unique and traceable.

Hardcoding the role ARN. Put the role ARN in a GitHub Actions secret or pass it as a repository variable. This makes it easy to update without editing the workflow file and reduces the risk of copy-paste errors across multiple workflows.

Assuming credentials are instantly revoked. They're short-lived, but they exist for their duration. Don't treat OIDC as a silver bullet for lateral movement within that window. Still practice least privilege: if a workflow only needs to read from S3, don't give it CloudFormation permissions.

Key takeaways

  • OIDC replaces static AWS access keys with time-bound tokens issued per GitHub Actions job. No more secrets to rotate or accidentally commit.
  • AWS OIDC provider setup is one-line infrastructure code; the real discipline is in the role trust policy—lock it down to specific repos and branches.
  • The configure-aws-credentials action handles the token exchange; just add permissions.id-token: write to your workflow and specify the role ARN.
  • Credentials are valid for one hour by default and automatically expired after the job ends. Each session is unique and auditable in CloudTrail.
  • Pin trust policies to production branches and repos. Use session names to tie each assume role call back to its GitHub run for accountability.
  • Still practice least privilege on the role permissions. OIDC is a delivery mechanism, not a substitute for tight IAM policies.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles