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
1GITHUB_ORG="your-org"
2GITHUB_REPO="your-repo"
3AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
4
5cat > trust-policy.json <<EOF
6{
7  "Version": "2012-10-17",
8  "Statement": [
9    {
10      "Effect": "Allow",
11      "Principal": {
12        "Federated": "arn:aws:iam::${AWS_ACCOUNT_ID}:oidc-provider/token.actions.githubusercontent.com"
13      },
14      "Action": "sts:AssumeRoleWithWebIdentity",
15      "Condition": {
16        "StringEquals": {
17          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
18          "token.actions.githubusercontent.com:sub": "repo:${GITHUB_ORG}/${GITHUB_REPO}:ref:refs/heads/main"
19        }
20      }
21    }
22  ]
23}
24EOF
25
26aws iam create-role \
27  --role-name GitHubActionsDeployRole \
28  --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
1# For ECR push + EKS deploy
2aws iam attach-role-policy \
3  --role-name GitHubActionsDeployRole \
4  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser
5
6# Or create a minimal custom policy
7cat > deploy-policy.json <<EOF
8{
9  "Version": "2012-10-17",
10  "Statement": [
11    {
12      "Effect": "Allow",
13      "Action": [
14        "ecr:GetAuthorizationToken",
15        "ecr:BatchCheckLayerAvailability",
16        "ecr:PutImage",
17        "ecr:InitiateLayerUpload",
18        "ecr:UploadLayerPart",
19        "ecr:CompleteLayerUpload"
20      ],
21      "Resource": "*"
22    },
23    {
24      "Effect": "Allow",
25      "Action": [
26        "eks:DescribeCluster"
27      ],
28      "Resource": "arn:aws:eks:*:${AWS_ACCOUNT_ID}:cluster/my-cluster"
29    }
30  ]
31}
32EOF
33
34aws iam put-role-policy \
35  --role-name GitHubActionsDeployRole \
36  --policy-name DeployPolicy \
37  --policy-document file://deploy-policy.json
38
39ROLE_ARN=$(aws iam get-role \
40  --role-name GitHubActionsDeployRole \
41  --query "Role.Arn" --output text)
42echo "Role ARN: $ROLE_ARN"

Step 4: Configure the GitHub Actions Workflow

yaml
1# .github/workflows/deploy.yml
2name: Deploy
3
4on:
5  push:
6    branches: [main]
7
8permissions:
9  id-token: write   # Required: lets the job request an OIDC token
10  contents: read
11
12jobs:
13  deploy:
14    runs-on: ubuntu-latest
15
16    steps:
17      - name: Checkout
18        uses: actions/checkout@v4
19
20      - name: Configure AWS credentials via OIDC
21        uses: aws-actions/configure-aws-credentials@v4
22        with:
23          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsDeployRole
24          aws-region: ap-south-1
25
26      - name: Verify identity
27        run: aws sts get-caller-identity
28
29      - name: Log in to ECR
30        id: login-ecr
31        uses: aws-actions/amazon-ecr-login@v2
32
33      - name: Build and push image
34        env:
35          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
36          IMAGE_TAG: ${{ github.sha }}
37        run: |
38          docker build -t $ECR_REGISTRY/my-app:$IMAGE_TAG .
39          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
1PROJECT_ID=$(gcloud config get-value project)
2POOL_NAME="github-actions-pool"
3
4gcloud iam workload-identity-pools create $POOL_NAME \
5  --project=$PROJECT_ID \
6  --location=global \
7  --display-name="GitHub Actions Pool"

Step 2: Create a Provider in the Pool

bash
1GITHUB_ORG="your-org"
2GITHUB_REPO="your-repo"
3
4gcloud iam workload-identity-pools providers create-oidc github-provider \
5  --project=$PROJECT_ID \
6  --location=global \
7  --workload-identity-pool=$POOL_NAME \
8  --display-name="GitHub Provider" \
9  --attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository" \
10  --attribute-condition="assertion.repository=='${GITHUB_ORG}/${GITHUB_REPO}'" \
11  --issuer-uri="https://token.actions.githubusercontent.com"

Step 3: Bind the Provider to a Service Account

bash
1SA_NAME="github-actions-sa"
2
3gcloud iam service-accounts create $SA_NAME \
4  --project=$PROJECT_ID \
5  --display-name="GitHub Actions Service Account"
6
7POOL_ID=$(gcloud iam workload-identity-pools describe $POOL_NAME \
8  --project=$PROJECT_ID \
9  --location=global \
10  --format="value(name)")
11
12gcloud iam service-accounts add-iam-policy-binding \
13  "${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" \
14  --project=$PROJECT_ID \
15  --role="roles/iam.workloadIdentityUser" \
16  --member="principalSet://iam.googleapis.com/${POOL_ID}/attribute.repository/${GITHUB_ORG}/${GITHUB_REPO}"
17
18# Grant the SA actual permissions
19gcloud projects add-iam-policy-binding $PROJECT_ID \
20  --member="serviceAccount:${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" \
21  --role="roles/artifactregistry.writer"

Step 4: Configure the GitHub Actions Workflow for GCP

yaml
1name: Deploy to GCP
2
3on:
4  push:
5    branches: [main]
6
7permissions:
8  id-token: write
9  contents: read
10
11jobs:
12  deploy:
13    runs-on: ubuntu-latest
14
15    steps:
16      - name: Checkout
17        uses: actions/checkout@v4
18
19      - name: Authenticate to GCP
20        uses: google-github-actions/auth@v2
21        with:
22          workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/github-actions-pool/providers/github-provider
23          service_account: github-actions-sa@your-project.iam.gserviceaccount.com
24
25      - name: Set up gcloud
26        uses: google-github-actions/setup-gcloud@v2
27
28      - name: Verify identity
29        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.

Official References

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.