The standard way most teams configure GitHub Actions to deploy to AWS is to create an IAM user, generate an access key, and paste it into GitHub Secrets as AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. It works. It is also a security liability that has caused real incidents at real companies.

OIDC eliminates those credentials entirely. Here is how it works and how to set it up.

What is wrong with long-lived access keys

Long-lived AWS access keys have several properties that make them risky in CI/CD pipelines. They do not expire — once created, they are valid until explicitly rotated or deleted. They are stored as static secrets, which means they exist as plaintext values in GitHub's secrets store, in your shell history if you ever paste them, and potentially in debug logs if a workflow step accidentally prints environment variables.

They also have broad scope by default. The IAM user that owns the key may have more permissions than any single workflow needs — because it was created once and shared across multiple use cases.

The real riskA leaked AWS access key gives an attacker persistent access to your AWS account until someone notices and rotates it. With OIDC, there is nothing to leak — no key exists.

How OIDC works

OpenID Connect is a standard for identity federation. Instead of giving GitHub a credential to use, you configure AWS to trust GitHub's identity provider. When a workflow runs, GitHub generates a short-lived OIDC token — a signed JWT that says "this is a workflow in repository X, on branch Y, triggered by event Z." GitHub exchanges that token with AWS for temporary credentials scoped to a specific IAM role.

Those temporary credentials expire in 15 minutes to 1 hour. There is no static secret. Nothing to rotate. Nothing to leak. If an attacker somehow intercepts a token, it is already expired by the time they could use it.

# The OIDC flow — simplified 1. Workflow triggers 2. GitHub generates short-lived OIDC token (JWT) └─ contains: repo, branch, event, sha 3. GitHub calls AWS STS:AssumeRoleWithWebIdentity └─ presents the OIDC token 4. AWS validates token against GitHub's OIDC provider └─ checks trust policy conditions 5. AWS returns temporary credentials (15min–1hr) 6. Workflow uses credentials for S3 sync + CF invalidation 7. Credentials expire — nothing to clean up

Setting it up — step by step

01
Create the OIDC provider in AWSIn IAM → Identity providers, add a new OpenID Connect provider. The provider URL is https://token.actions.githubusercontent.com. The audience is sts.amazonaws.com. This tells AWS to trust tokens issued by GitHub.
02
Create an IAM role with a trust policyThe trust policy defines which GitHub workflows can assume this role. You must be specific — too permissive and any GitHub repo can assume your role.
03
Attach a least-privilege permission policyThe role should only have the permissions the workflow actually needs: s3:PutObject, s3:DeleteObject, s3:ListBucket, and cloudfront:CreateInvalidation. Nothing more.
04
Configure the workflow to use OIDCAdd the id-token permission and use aws-actions/configure-aws-credentials with role-to-assume instead of access keys.

The trust policy — get this right

The trust policy is where most mistakes happen. Here is what a correct one looks like:

{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::ACCOUNT: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:hugdora/cloud-native-portfolio:ref:refs/heads/main" } } } ] }

The highlighted condition is critical. It locks the role to a specific repository and branch. Without it, any public GitHub repository could assume your role. The sub claim in the OIDC token contains the repo name and ref — always validate both.

The workflow configuration

name: Deploy on: push: branches: [main] permissions: id-token: write # Required for OIDC contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::ACCOUNT:role/github-deploy-role aws-region: eu-west-2 # No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY - name: Deploy to S3 run: aws s3 sync out/ s3://your-bucket --delete - name: Invalidate CloudFront run: | aws cloudfront create-invalidation --distribution-id {{ secrets.CF_DIST_ID {"}}"} --paths "/*"
Key detailThe id-token: write permission must be explicitly declared in the workflow. Without it, GitHub does not generate an OIDC token for the run, and the credential exchange fails with a cryptic error.

What I learned the hard way

The trust policy condition must exactly match the sub claim that GitHub sends. If your condition says refs/heads/main but the workflow runs on a pull request, the sub claim will be different and the assume-role call will fail. Test on the exact branch and trigger type you intend to use.

Also: you still need to store the CloudFront distribution ID in GitHub Secrets — but that is not a credential. It is a resource identifier. Even if it leaks, an attacker cannot do anything with a distribution ID alone. The IAM role controls what actions are permitted.

The Terraform that provisions the IAM OIDC provider and role is in the repository. The full pipeline implementation is on the CI/CD Pipeline project page.