All articles
Tutorials

How to Eliminate Long-Lived Cloud Keys in CI/CD Using OIDC

Learn how to replace static AWS access keys in GitHub Actions with short-lived OIDC credentials, configure strict IAM trust policies, and secure multi-environment deployment pipelines.

How to Eliminate Long-Lived Cloud Keys in CI/CD Using OIDC cover
11 min read

TL;DR

  • Long-lived AKIA keys in GitHub Secrets are standing privilege: every workflow that reads the secret inherits cloud access until someone rotates keys.
  • OIDC federation issues short-lived STS credentials bound to repository, branch, or environment claims in the JWT sub and aud fields.
  • Configure the GitHub OIDC provider once per AWS account, scope an IAM role trust policy tightly, and set permissions: id-token: write in the workflow.
  • Use separate roles for staging and production; bind production to GitHub Environments with required reviewers.
  • Keep fork PR workflows off deploy roles; split read-only CI roles from deploy roles.

If your deployment pipeline authenticates to AWS with access keys stored in GitHub Secrets, you are carrying credentials that outlive every job. Those keys are often over-permissioned, rotated manually (if ever), and visible to every workflow that can read the secret. When a workflow is compromised through a poisoned dependency, a typosquatted action, or a fork PR exfiltration trick, the blast radius is your cloud account until someone notices.

OpenID Connect (OIDC) federation changes the credential shape. CI jobs receive short-lived tokens minted for a specific repository, branch, and environment, bound to an IAM role with least privilege. No static keys in GitHub. No shared deploy user in a password manager.

This guide is for platform and DevSecOps engineers running GitHub Actions against AWS. The same pattern generalizes to GCP Workload Identity and Azure Federated Credentials. You will configure an IAM OIDC identity provider, a trust-scoped IAM role, and a deploy workflow that assumes the role without secrets.

What do you need before you start?

  • AWS account access with permission to create IAM OIDC providers and roles (IAM admin or equivalent).
  • GitHub repository admin access to update workflows and delete legacy secrets.
  • Familiarity with IAM trust policies and GitHub Actions permissions blocks.
  • A target deploy surface (for example S3, EKS, or Lambda) so you can scope the role permissions policy.

Why do long-lived CI keys fail in production?

Teams adopt access keys because they work on day one. The costs show up later:

SymptomLikely causeWhat good looks like
Keys in five repos "for now"Copy-paste deploy userOne role per repo or environment, OIDC-bound
Rotation ticket keeps slippingManual process, fear of outageNo keys to rotate; tokens expire in minutes
Auditor asks who can assume PowerUserShared IAM userCloudTrail shows AssumeRoleWithWebIdentity from a known repo
Fork PR suddenly wants AWS credspull_request workflows with secretsOIDC sub claim scoped; no secrets on untrusted forks

Static keys conflict with zero-trust thinking: the CI system is not a person and should not carry a permanent password. Implementing zero-trust in SaaS frames identity around continuous verification; OIDC applies that verification to the deploy path, not only the VPN you retired.

What does the naive GitHub Actions deploy workflow look like?

The first workflow most teams ship works in staging and becomes a liability in production. It stores an IAM user's access keys in GitHub Secrets and injects them into every deploy job:

Label: Anti-pattern for illustration (do not use in production).

.github/workflows/deploy-aws-naive.yml
name: Deploy to AWS
 
on:
  push:
    branches: [main]
  pull_request:
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1
 
      - name: Sync static assets
        run: aws s3 sync ./dist s3://my-app-artifacts/production/ --delete

Three problems stack on top of each other:

ProblemWhy it hurts
Long-lived keys in SecretsEvery workflow with secret access inherits the same cloud identity until someone rotates keys
Shared IAM user across reposOne copied secret in five repositories means five blast radii tied to one AKIA user
pull_request trigger with cloud credsFork PRs can exfiltrate secrets through malicious workflow changes unless you block untrusted runs

The deploy step itself is fine. The credential model is the failure: permanent keys, broad scope, and no binding to a specific repository, branch, or environment. OIDC replaces that model without changing what the deploy step runs.

How does OIDC authentication work in GitHub Actions?

  1. GitHub Actions requests an OIDC JWT from GitHub (the ACTIONS_ID_TOKEN_REQUEST_TOKEN flow, handled by aws-actions/configure-aws-credentials when configured).
  2. The AWS IAM OIDC identity provider trusts GitHub's issuer (token.actions.githubusercontent.com).
  3. Your IAM role trust policy checks JWT claims (sub, aud, repository, ref) before allowing sts:AssumeRoleWithWebIdentity.
  4. The workflow receives temporary AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN values valid for the job only.

The trust boundary is claim-based: only workflows matching your filters can assume the role. Most production incidents come from a too loose StringLike on sub, not from OIDC being inherently weak.

OIDC federation flow: GitHub Workflow sends an OIDC JWT to the IAM OIDC Provider, which validates trust policy claims before AssumeRoleWithWebIdentity issues a Deploy Role session that reaches S3 or EKS
DimensionLong-lived access keysOIDC-federated role
LifetimeUntil rotated or deletedMinutes (STS session)
StorageGitHub Secret, often duplicatedNo cloud secret in GitHub
ScopeWhatever the IAM user hasPer role, per repo, branch, or environment
AuditAccessKeyId in CloudTrailAssumeRoleWithWebIdentity plus sub claim
Compromise recoveryRotate all copies, hunt reposTighten trust policy; sessions expire
Setup costLow on day oneOne-time IAM and workflow change

How do you configure OIDC federation between GitHub Actions and AWS?

Add the GitHub OIDC provider in AWS

Run once per AWS account (console: IAM → Identity providers → Add provider → OpenID Connect):

FieldValue
Provider URLhttps://token.actions.githubusercontent.com
Audiencests.amazonaws.com

Label: Production-ready CLI example (replace thumbprint if AWS documentation requires an update).

aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03fa02197ae0c8ae26c8b69dd77

Tip

Thumbprints change rarely. If provider creation fails, verify the current thumbprint in the AWS IAM OIDC thumbprint guide.

Verify: aws iam list-open-id-connect-providers shows the GitHub issuer ARN.

Create an IAM role with a tight trust policy

Replace ORG, REPO, and the account ID. This example allows only the main branch of one repository:

Label: Simplified trust policy for illustration (replace placeholders before production use).

trust-policy-main-branch.json
{
  "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:ORG/REPO:ref:refs/heads/main"
        }
      }
    }
  ]
}

Attach a least-privilege permissions policy. Example for S3 deploy to one bucket prefix:

permissions-policy-s3-deploy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DeployArtifacts",
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:ListBucket", "s3:DeleteObject"],
      "Resource": [
        "arn:aws:s3:::my-app-artifacts",
        "arn:aws:s3:::my-app-artifacts/production/*"
      ]
    }
  ]
}

For multiple environments, create deploy-staging and deploy-production roles. Scope sub with ref:refs/heads/main versus environment:production (see the next step).

Verify: In IAM → Roles → Trust relationships, the federated principal matches your repository slug exactly (case-sensitive).

Wire GitHub Actions to assume the role without secrets

Label: Production-ready workflow pattern (update role ARN, region, and S3 paths).

.github/workflows/deploy-aws.yml
name: Deploy to AWS
 
on:
  push:
    branches: [main]
 
permissions:
  id-token: write
  contents: read
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-deploy-production
          aws-region: us-east-1
 
      - name: Sync static assets
        run: aws s3 sync ./dist s3://my-app-artifacts/production/ --delete

Delete legacy secrets AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. If anything still works after deletion, you found another copy in the org or a reusable workflow.

Verify: The workflow log shows a successful role assumption. In CloudTrail, filter EventName = AssumeRoleWithWebIdentity and confirm userIdentity.sessionContext.sessionIssuer points at your role.

Bind production deploys to GitHub Environments

For production, align human approval with claim scoping. Trust policy fragment:

trust-policy-environment-production.json
"StringEquals": {
  "token.actions.githubusercontent.com:sub": "repo:ORG/REPO:environment:production"
}

Workflow:

deploy-job-environment.yml
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      # ... same configure-aws-credentials step with the production role ARN

Required reviewers on the production environment keep identity and process control aligned.

What errors appear when OIDC trust is misconfigured?

Error or symptomFix
Could not assume role with OIDC: Not authorized to perform sts:AssumeRoleWithWebIdentityTrust policy sub or aud mismatch; confirm repository name case and branch ref
Credentials could not be loaded, please check your action inputsMissing role-to-assume or id-token: write not set
Access Denied on s3:PutObject after successful assumePermissions policy on the role, not OIDC; trust worked
Works on main but not on a feature branchTrust policy only allows refs/heads/main; add a staging role or environment claim
Intermittent failures on self-hosted runnersRunner must reach sts.amazonaws.com and the GitHub OIDC endpoint; corporate SSL inspection can break JWT exchange

Missing id-token: write: configure-aws-credentials cannot mint a JWT. Set the permission on the workflow or org default.

sub mismatch from typos: repo:MyOrg/my-app versus repo:myorg/My-App fails closed. Use CloudTrail AssumeRoleWithWebIdentity events to compare the sub claim with your trust policy. Do not print OIDC tokens in workflow logs.

Fork PR workflows: Do not grant OIDC-capable production roles to pull_request from forks unless you fully understand pull_request_target and claim spoofing. Prefer OIDC on push to protected branches or workflow_dispatch.

Over-broad StringLike: repo:ORG/* lets any repository in the org assume the role. Scope to one repository; split roles per service.

Same role for build and deploy: Unit test jobs should not assume deploy roles. Use ci-readonly (ECR pull, artifact read) versus deploy-production.

CloudTrail spot-check: Confirm userIdentity.sessionContext.webIdFederationData.federatedProvider shows GitHub. If you still see AKIA users from GitHub Actions IP ranges, a workflow still uses keys.

How do you deploy to EKS with an OIDC role?

OIDC roles often need eks:DescribeCluster plus RBAC mapped to the role:

eks-describe-cluster-policy.json
{
  "Effect": "Allow",
  "Action": ["eks:DescribeCluster"],
  "Resource": "arn:aws:eks:us-east-1:123456789012:cluster/prod"
}

Map the role into the aws-auth ConfigMap or EKS access entries once. Avoid embedding cluster admin in the GitHub role; prefer namespace-scoped RBAC and service account impersonation where possible.

When should you not rely on OIDC for CI/CD?

OIDC is the right default for GitHub Actions to AWS, but it is not universal:

  • CI platforms without OIDC: Older or self-hosted runners that cannot request a GitHub OIDC JWT need another pattern (instance roles on self-hosted infra, or short-lived credentials from a secrets manager with tight rotation).
  • Org-wide shared deploy users: If you cannot split roles per repository or environment, OIDC still helps, but loose sub patterns recreate the blast radius you are trying to remove.
  • Untrusted fork PRs needing cloud access: OIDC should not widen trust for fork workflows. Use read-only checks without cloud credentials, or isolated sandboxes.
  • Break-glass human access: Emergency admin belongs in a separate human-assumable role, not in the CI deploy role. The CI role should not escalate to account admin.
  • One-shot scripts outside Actions: Local deploy scripts still need their own credential model; OIDC does not replace human MFA for console work.

Framed for developers, OIDC is also deploy reliability: fewer rotation outages, clearer CloudTrail, and PR checks that do not need production keys. That is the same adoption logic as structured API errors and idempotent webhook handlers: make the secure path the easiest path.

How do you roll out OIDC across an organization?

  1. Ship a reference workflow and Terraform module (OIDC provider plus role template) in a platform repository.
  2. Require OIDC for new repositories via org policy; grandfather existing repos with a sunset date for AKIA keys.
  3. Run a monthly IAM credential report and scan org secrets for AWS_ACCESS_KEY_ID.
  4. Pair migration with secret scanning on PRs so keys do not return while teams move.

How do you harden an OIDC deploy pipeline?

  • No AKIA* access keys in GitHub Secrets for CI
  • Trust policy uses StringEquals on aud and narrow StringLike on sub
  • Separate IAM roles per environment; production uses GitHub Environment protection
  • CloudTrail alerts on AssumeRoleWithWebIdentity from unexpected repositories
  • Pin third-party actions to full commit SHAs for deploy workflows
  • permissions: defaults to minimum; id-token: write only where needed
  • Document break-glass human role separately; the CI role cannot escalate to admin

OIDC does not replace scanning, policy-as-code, or runtime detection. It removes a durable credential class that attackers hunt first. Combined with short-lived tokens, you shrink mean time to containment when a pipeline is abused: stolen session credentials expire with the job.

For authoritative setup steps, see GitHub: Configuring OpenID Connect in AWS, AWS IAM OIDC identity providers, and NIST SP 800-207 for zero-trust context.

Frequently asked questions

Share𝕏

Writer

  • Ilyas Rufai

    Technical content writer and DevSecOps specialist focused on cloud-native security and developer experience

Need help with your technical content?

We help B2B SaaS teams turn complex products into clear documentation and content that developers actually use.

Book a call
How to Eliminate Long-Lived Cloud Keys in CI/CD Using OIDC | Reclear