Securing CI Pipelines with OIDC: No Long-Lived Secrets
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
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/mainrepository:your-org/your-repoenvironment: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
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:
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."
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:
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:*"
To restrict to a specific GitHub Environment (requires GitHub Environments configured):
"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:
# 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
# .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
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
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
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
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.