DEV Community

Oleksandr Kuryzhev
Oleksandr Kuryzhev

Posted on Originally published at kuryzhev.cloud

OIDC federation and what it actually does for CI on AWS

Originally published on kuryzhev.cloud


The scenario

OIDC federation shows up in almost every CI-to-AWS integration written since 2021. Even so, plenty of pipelines still run with a long-lived IAM access key sitting in a secrets manager, because nobody wanted to touch the trust policy syntax. A GitHub Actions workflow or a GitLab CI job needs to push a container image, deploy a Lambda function, or update an ECS service. Someone configured aws-actions/configure-aws-credentials with static keys two years ago, it worked, and nobody has revisited it since.

That static key has several problems:

  • It does not expire on its own.
  • It is readable by anyone who can edit workflows or reach the secret store.
  • If it leaks in a build log, it stays valid until someone manually rotates it.

OIDC federation removes that key entirely. Instead of storing a secret, the CI runner presents a signed JSON Web Token to AWS Security Token Service (STS). AWS exchanges that token for temporary credentials scoped to exactly one IAM role. By default those credentials last one hour, and you can configure anything from 15 minutes up to the role's maximum session duration.

The mechanics are worth understanding precisely. When the trust policy is wrong, the failure is a cryptic AccessDenied or InvalidIdentityToken error with almost no actionable detail. This walkthrough does three things:

  • Sets up OIDC federation between GitHub Actions and AWS.
  • Explains what each policy condition actually enforces.
  • Shows how to verify the trust boundary before it becomes an incident.

Prerequisites

Before touching IAM, confirm a few things:

  • AWS permissions. You need an AWS account with permission to create IAM identity providers and roles. This is an account-level action, not a project-level one.
  • A repository. You need a GitHub repository where the workflow will run. A GitLab project follows the same pattern with a different issuer URL and claim format.
  • A list of required actions. You need to know exactly which AWS actions the pipeline requires. The whole point of this exercise is a role scoped to the minimum, not a convenient AdministratorAccess attachment.

You will also want the AWS CLI installed locally so you can inspect the identity provider and role before wiring them into CI. Check the version with aws --version. A current AWS CLI v2 release supports every command used here.

Watch out for: the account may already have an OIDC provider for token.actions.githubusercontent.com from a previous project. In that case, creating a second one with the same URL fails with EntityAlreadyExists. Check first with aws iam list-open-id-connect-providers rather than assuming a clean slate.

Step 1: Register the OIDC identity provider

AWS needs to trust GitHub's token issuer before it will validate anything GitHub sends. This is a one-time setup per AWS account, not per repository or per role.

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com
  # --thumbprint-list is optional; if omitted, IAM retrieves
  # the thumbprint itself. For GitHub's issuer, AWS validates
  # the certificate against its trusted root CAs anyway.

The client-id-list value of sts.amazonaws.com must match the aud (audience) claim inside the JWT that GitHub issues. configure-aws-credentials requests that audience by default. If the token's audience is not on the provider's client ID list, STS rejects it with InvalidIdentityToken before the trust policy's repository condition matters.

Older guides hardcode a certificate thumbprint here. The thumbprint is now optional in the IAM API. For GitHub's issuer, AWS relies on its library of trusted root certificate authorities rather than the thumbprint you supply. Confirm current guidance in the AWS IAM OIDC identity provider documentation.

Step 2: Write a trust policy scoped to one repository and branch

This is the step most teams get loosely right and specifically wrong. The trust policy determines which GitHub workflows can assume the role. If the condition is wrong, you either lock out legitimate runs or, worse, let any repository in your GitHub organization assume production credentials.

{
  "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:ref:refs/heads/main"
        }
      }
    }
  ]
}

The sub claim is the actual security boundary. It encodes the organization, the repository, and the run context in one string. Its format depends on how the job runs:

  • A push to main produces repo:my-org/my-app:ref:refs/heads/main.
  • A job that references a GitHub environment produces repo:my-org/my-app:environment:production instead.
  • A pull_request run produces repo:my-org/my-app:pull_request.

If you protect production with an environment, pin the environment form. A ref-based pin will reject those jobs.

Watch out for: a common mistake is adding a wildcard sub condition like repo:my-org/* under StringLike "just to make it work" during setup, then forgetting to tighten it. That wildcard grants role assumption to every repository in the organization and every branch and event type within them. Use StringEquals with the exact repository and context. For anything touching production, add GitHub environments with required reviewers.

Step 3: Create the IAM role and attach a scoped permission policy

The role should carry only the permissions the pipeline actually exercises. Deploying a Lambda function does not need S3 delete permissions on unrelated buckets.

aws iam create-role \
  --role-name gha-deploy-my-app \
  --assume-role-policy-document file://trust-policy.json \
  --max-session-duration 3600
  # caps the session duration a caller may request (900s up to this
  # value); the credentials' lifetime is independent of the JWT's expiry

Attach a permission policy. Ideally this is a customer-managed policy scoped to specific resource ARNs and actions, not a broad AWS-managed policy.

Reference the role in your workflow with role-to-assume and no access key inputs at all:

name: deploy
on:
  push:
    branches: [main]
permissions:
  id-token: write   # required so GitHub issues the OIDC token
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-my-app
          aws-region: us-east-1
      - run: aws sts get-caller-identity

The id-token: write permission is easy to forget. Without it, the job cannot request a token at all, and the credentials step fails before any AWS call is made. The error message does not always name the missing permission, so check it first. This behavior is documented in GitHub's OIDC-to-AWS configuration guide.

Verify and test

Do not trust the trust policy until you have seen it work and seen it fail correctly.

Positive test. Run the workflow once on the intended branch. Confirm that CloudTrail records an AssumeRoleWithWebIdentity event for the role, and that the event's identity details show the expected sub value.

Negative tests. Deliberately test the cases that should fail:

  • Pull request from a same-repo branch. Its token carries repo:my-org/my-app:pull_request as the sub, so it should fail at the STS exchange with an access-denied error.
  • Pull request from a fork. This usually fails even earlier, because GitHub does not grant id-token: write to fork pull request runs by default.

If either run obtains credentials, the trust policy is broader than intended and needs re-checking.

Inspecting claims. GitHub only mints these tokens inside a running job, so you cannot fetch a real one on your laptop for local testing. To see the claims AWS will evaluate, add a temporary debug step inside a workflow that requests the token and prints only its decoded claims. Never print the raw token itself. Remove the step once you have confirmed the sub and aud values.

Ongoing review. Review IAM Access Analyzer's external access findings for the role periodically. Archive the finding for the expected trust relationship. A new finding after a policy change is a useful prompt to re-check the conditions, though it complements code review of the trust policy rather than replacing it.

The same STS mechanics sit underneath IRSA, where EKS pods federate to AWS IAM through the cluster's OIDC provider. Newer clusters can also use EKS Pod Identity, which follows a different setup. More infrastructure write-ups are available on kuryzhev.cloud.

OIDC federation does not make CI-to-AWS access magically secure by itself. It removes one specific, well-understood risk: a static credential sitting somewhere it can be copied. In its place is a trust policy that has to be written correctly to actually narrow access. Misconfigurations hide in three places: the token exchange, the audience check, and the subject-claim condition. Each one fails in a different, diagnosable way if you know where to look. Treat the trust policy as production configuration subject to the same review as application code, because in effect that is exactly what it is.

Related

Top comments (0)