Services Process Packages Blog Resources Free Assessment Let's talk

GitHub Actions to AWS Without Access Keys: OIDC Federation Step-by-Step

GitHub Actions to AWS Without Access Keys: OIDC Federation Step-by-Step

That AWS_ACCESS_KEY_ID sitting in your GitHub Secrets? It's a static credential that never expires, has more permissions than it needs, and lives in a system you don't fully control. There's a better way.

Why Static Keys Are Dangerous

Every time I audit a CI/CD pipeline, the same thing comes up. A pair of IAM access keys created two years ago by someone who left the company. Full admin permissions because "it was easier." No rotation schedule. No monitoring. Just vibes.

Here's what actually goes wrong with static keys in CI/CD:

Key leaks in logs. Someone adds --debug to an AWS CLI command and suddenly the credentials are printed to the GitHub Actions log. That log is visible to everyone with repo read access. I've seen this happen at three different companies.

Overprivileged keys. The person who set up the pipeline created an IAM user with AdministratorAccess because the deployment kept failing with more restrictive policies. They meant to scope it down later. They never did.

Rotation debt. AWS best practice says rotate keys every 90 days. Nobody does this. The keys sit there for years. When someone finally tries to rotate them, they discover the keys are used in four other repos and a cron job nobody remembers creating.

Blast radius. If those keys leak, an attacker has persistent access to your AWS account until you notice and revoke them. There's no expiration. There's no automatic revocation. The keys work until you delete them.

Static keys in CI/CD are a liability. Full stop.

How OIDC Federation Works

The fix is OIDC (OpenID Connect) federation. The concept is straightforward.

GitHub Actions has its own identity provider. When a workflow runs, GitHub can generate a short-lived token that says "I am repo your-org/your-repo, running on branch main, triggered by workflow deploy.yml." That token is cryptographically signed by GitHub.

You configure AWS to trust GitHub's identity provider. When your workflow needs AWS access, it presents that signed token to AWS STS. AWS verifies the signature, checks the claims against your trust policy, and hands back temporary credentials. Those credentials expire in one hour by default.

No keys stored anywhere. No secrets to rotate. No permanent credentials that can leak. The credentials exist only for the duration of the job.

Step-by-Step: Terraform for the AWS Side

Here's the complete Terraform configuration. This is not pseudocode. You can drop this into a file and run terraform apply.

# oidc.tf

data "tls_certificate" "github" {
  url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
}

resource "aws_iam_openid_connect_provider" "github" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [data.tls_certificate.github.certificates[0].sha1_fingerprint]
}

data "aws_iam_policy_document" "github_actions_assume_role" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    effect  = "Allow"

    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }

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

    condition {
      test     = "StringLike"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:your-org/your-repo:ref:refs/heads/main"]
    }
  }
}

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

resource "aws_iam_role_policy_attachment" "deploy_permissions" {
  role       = aws_iam_role.github_actions.name
  policy_arn = aws_iam_policy.deploy.arn
}

resource "aws_iam_policy" "deploy" {
  name        = "github-actions-deploy-policy"
  description = "Scoped permissions for CI/CD deployment"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:PutObject",
          "s3:GetObject",
          "s3:ListBucket",
          "s3:DeleteObject"
        ]
        Resource = [
          "arn:aws:s3:::your-deploy-bucket",
          "arn:aws:s3:::your-deploy-bucket/*"
        ]
      },
      {
        Effect = "Allow"
        Action = [
          "ecs:UpdateService",
          "ecs:DescribeServices",
          "ecs:DescribeTaskDefinition",
          "ecs:RegisterTaskDefinition"
        ]
        Resource = "*"
        Condition = {
          StringEquals = {
            "aws:RequestedRegion" = "us-east-1"
          }
        }
      },
      {
        Effect = "Allow"
        Action = [
          "ecr:GetAuthorizationToken",
          "ecr:BatchCheckLayerAvailability",
          "ecr:PutImage",
          "ecr:InitiateLayerUpload",
          "ecr:UploadLayerPart",
          "ecr:CompleteLayerUpload"
        ]
        Resource = "*"
      },
      {
        Effect   = "Allow"
        Action   = "iam:PassRole"
        Resource = "arn:aws:iam::*:role/ecs-task-execution-role"
      }
    ]
  })
}

Replace your-org/your-repo with your actual GitHub org and repo name. Replace the S3 bucket and ECS references with your actual resources. The point is to scope permissions to EXACTLY what the deployment needs.

Step-by-Step: GitHub Actions Workflow

Here's the workflow file. Save this as .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        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-deploy
          aws-region: us-east-1

      - name: Login to Amazon ECR
        id: ecr-login
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build and push Docker image
        env:
          ECR_REGISTRY: ${{ steps.ecr-login.outputs.registry }}
          ECR_REPOSITORY: your-app
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

      - name: Deploy to ECS
        run: |
          aws ecs update-service \
            --cluster your-cluster \
            --service your-service \
            --force-new-deployment \
            --region us-east-1

Two things to notice. First, the permissions block at the top. You NEED id-token: write for the OIDC token exchange to work. This is the most common reason setups fail. Second, there's no aws-access-key-id or aws-secret-access-key in the configure-aws-credentials step. Just the role ARN. That's the whole point.

Before and After

Before (Static Keys) After (OIDC Federation)
Permanent credentials stored in GitHub Secrets No credentials stored anywhere
Manual key rotation (if it happens at all) Automatic — credentials expire in 1 hour
Keys work from any context Scoped to specific repo and branch
Key compromise = persistent access Token compromise = 1 hour window max
No native audit trail for key usage Every assumption logged in CloudTrail
Shared keys across environments Separate roles per repo/branch/environment

Common Pitfalls

I set this up wrong the first time. Spent an hour staring at "Not authorized to perform sts:AssumeRoleWithWebIdentity" before I figured out what I did. Here's what trips people up:

Wrong audience in the trust policy. The aud claim must be sts.amazonaws.com. Not https://github.com. Not your repo URL. Exactly sts.amazonaws.com. AWS and GitHub agreed on this value. Use it.

Missing or overly broad subject claim restrictions. If you set the sub condition to repo:your-org/your-repo:*, any branch, any tag, any PR from a fork can assume that role. Lock it down to repo:your-org/your-repo:ref:refs/heads/main for production deployments. Use separate roles for staging and production.

Overprivileged IAM role. OIDC federation solves the credential storage problem. It does NOT solve the permissions problem. If you attach AdministratorAccess to your OIDC role, you've just made it slightly harder to abuse, not impossible. Scope the IAM policy to exactly the actions and resources your deployment needs.

Missing the permissions block. If you forget id-token: write in your workflow's permissions block, GitHub won't generate the OIDC token and the credential exchange will fail silently. If you're setting explicit permissions, you also need contents: read or your checkout step will fail.

Thumbprint validation. AWS requires a thumbprint of the OIDC provider's TLS certificate. The Terraform tls_certificate data source handles this automatically. If you're setting this up via the console or CLI, you'll need to fetch the thumbprint manually — AWS has docs for this, but the Terraform approach eliminates the hassle.

Wrap Up

That's it. One terraform apply and a workflow update. Your CI/CD pipeline no longer has permanent access to your AWS account.

The credentials are temporary. They're scoped to a specific repo and branch. Every assumption is logged in CloudTrail. There are no secrets to rotate because there are no secrets.

I migrate every client pipeline to OIDC federation during the first week of an engagement. It's the highest-impact, lowest-effort security improvement you can make to a CI/CD pipeline. Takes about 20 minutes to set up. Eliminates an entire class of credential exposure risk permanently.

Go delete those IAM access keys.

Secure your stack

Get our free 65-item SaaS Security Checklist delivered to your inbox.

No spam. Just the checklist + practical security tips.

Check your inbox!

EI

Evan Ippolito

DevSecOps consultant, 6+ years at Nike, ZeroFox, IDX. Helping SaaS teams ship secure.

Want help securing your infrastructure?

I help B2B SaaS teams eliminate static credentials, harden containers, and lock down CI/CD pipelines — delivered as code you keep.

Let's talk
Originally published on zerocreds.io