DevOps & Platform

Securing CI Pipelines with OIDC: No Long-Lived Secrets

Intermediate35 min to complete9 min read

Replace static AWS/GCP credentials in CI with short-lived tokens via OpenID Connect. Your pipeline gets temporary credentials that expire automatically — no more rotating secrets, no more leaked tokens.

Before you begin

  • A GitHub Actions workflow that needs cloud access
  • AWS or GCP account with IAM permissions
  • Basic understanding of IAM roles and policies
CI/CD
Security
OIDC
GitHub Actions
AWS
GCP

The standard advice is "store your AWS_ACCESS_KEY_ID in GitHub Secrets." This works — until the key leaks, the rotation is overdue, or someone leaves the team. OIDC eliminates the credential entirely.

GitHub (and GitLab, CircleCI, and others) acts as an OIDC identity provider. AWS and GCP trust it. When a workflow runs, it requests a short-lived token from GitHub's OIDC endpoint and exchanges it for a cloud IAM role. The token expires in 15 minutes. There's nothing to rotate, nothing to leak.

How It Works

GitHub Actions job starts
  → GitHub issues a signed JWT (OIDC token) for this specific job
  → Workflow calls sts:AssumeRoleWithWebIdentity (AWS) or workloadIdentityPools (GCP)
  → Cloud validates the JWT signature against GitHub's OIDC discovery endpoint
  → Cloud returns temporary credentials scoped to the IAM role
  → Credentials expire when the job ends

The JWT contains claims that identify exactly where it came from:

  • sub: repo:your-org/your-repo:ref:refs/heads/main
  • repository: your-org/your-repo
  • environment: production (if using GitHub Environments)
  • workflow: deploy.yml

You configure trust conditions to only grant the role if specific claims match — so only your repo's main branch can assume the production role.

Part 1: AWS Setup

Step 1: Create the OIDC Identity Provider in AWS

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

You only need to do this once per AWS account.

Verify it exists:

bash
aws iam list-open-id-connect-providers

Step 2: Create the IAM Role with a Trust Policy

The trust policy says: "Allow this role to be assumed if the OIDC token comes from GitHub and matches these claims."

bash
GITHUB_ORG="your-org"
GITHUB_REPO="your-repo"
AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

cat > trust-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::${AWS_ACCOUNT_ID}: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:${GITHUB_ORG}/${GITHUB_REPO}:ref:refs/heads/main"
        }
      }
    }
  ]
}
EOF

aws iam create-role \
  --role-name GitHubActionsDeployRole \
  --assume-role-policy-document file://trust-policy.json

The sub claim repo:org/repo:ref:refs/heads/main restricts assumption to merges to main only. To allow any branch:

json
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:*"

To restrict to a specific GitHub Environment (requires GitHub Environments configured):

json
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:environment:production"

Step 3: Attach Permissions to the Role

Attach only what the pipeline needs:

bash
# For ECR push + EKS deploy
aws iam attach-role-policy \
  --role-name GitHubActionsDeployRole \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser

# Or create a minimal custom policy
cat > deploy-policy.json <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ecr:GetAuthorizationToken",
        "ecr:BatchCheckLayerAvailability",
        "ecr:PutImage",
        "ecr:InitiateLayerUpload",
        "ecr:UploadLayerPart",
        "ecr:CompleteLayerUpload"
      ],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "eks:DescribeCluster"
      ],
      "Resource": "arn:aws:eks:*:${AWS_ACCOUNT_ID}:cluster/my-cluster"
    }
  ]
}
EOF

aws iam put-role-policy \
  --role-name GitHubActionsDeployRole \
  --policy-name DeployPolicy \
  --policy-document file://deploy-policy.json

ROLE_ARN=$(aws iam get-role \
  --role-name GitHubActionsDeployRole \
  --query "Role.Arn" --output text)
echo "Role ARN: $ROLE_ARN"

Step 4: Configure the GitHub Actions Workflow

yaml
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required: lets the job request an OIDC token
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
          aws-region: ap-south-1

      - name: Verify identity
        run: aws sts get-caller-identity

      - name: Log in to ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

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

The permissions: id-token: write at the top is required. Without it, the job can't request an OIDC token and the credential exchange fails.

Part 2: GCP Setup

Step 1: Create a Workload Identity Pool

bash
PROJECT_ID=$(gcloud config get-value project)
POOL_NAME="github-actions-pool"

gcloud iam workload-identity-pools create $POOL_NAME \
  --project=$PROJECT_ID \
  --location=global \
  --display-name="GitHub Actions Pool"

Step 2: Create a Provider in the Pool

bash
GITHUB_ORG="your-org"
GITHUB_REPO="your-repo"

gcloud iam workload-identity-pools providers create-oidc github-provider \
  --project=$PROJECT_ID \
  --location=global \
  --workload-identity-pool=$POOL_NAME \
  --display-name="GitHub Provider" \
  --attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository" \
  --attribute-condition="assertion.repository=='${GITHUB_ORG}/${GITHUB_REPO}'" \
  --issuer-uri="https://token.actions.githubusercontent.com"

Step 3: Bind the Provider to a Service Account

bash
SA_NAME="github-actions-sa"

gcloud iam service-accounts create $SA_NAME \
  --project=$PROJECT_ID \
  --display-name="GitHub Actions Service Account"

POOL_ID=$(gcloud iam workload-identity-pools describe $POOL_NAME \
  --project=$PROJECT_ID \
  --location=global \
  --format="value(name)")

gcloud iam service-accounts add-iam-policy-binding \
  "${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" \
  --project=$PROJECT_ID \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/${POOL_ID}/attribute.repository/${GITHUB_ORG}/${GITHUB_REPO}"

# Grant the SA actual permissions
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/artifactregistry.writer"

Step 4: Configure the GitHub Actions Workflow for GCP

yaml
name: Deploy to GCP

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Authenticate to GCP
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github-actions-pool/providers/github-provider
          service_account: github-actions-sa@your-project.iam.gserviceaccount.com

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v2

      - name: Verify identity
        run: gcloud auth list

Verify No Secrets Are Stored

After this setup, check your repository's GitHub Secrets — there should be no AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or GCP service account JSON keys. The only configuration is the role ARN or workload identity provider path, which is not secret.

Run a workflow and check the logs — you'll see the OIDC token exchange happening transparently:

Assuming role arn:aws:iam::123456789012:role/GitHubActionsDeployRole
  with OIDC token for repo:your-org/your-repo:ref:refs/heads/main
Assumed role successfully. Credentials expire in 3600s.

Debugging Common Errors

Not authorized to perform sts:AssumeRoleWithWebIdentity — The trust policy condition doesn't match the token's sub claim. Print the actual sub: add a step run: echo $ACTIONS_ID_TOKEN_REQUEST_URL and decode the token at jwt.io to see the exact sub value.

permissions block missing — The job can't request an OIDC token. Add permissions: id-token: write to the job or workflow level.

Token expired — The OIDC token is only valid for the duration of the workflow step. Don't cache it between workflow runs.

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.