Overview and What You Will Learn
This guide covers how to remove long-lived cloud credentials from your CI/CD pipelines entirely, replacing them with short-lived OIDC tokens, and how to manage the secrets that remain (database passwords, third-party API keys) using proper secret stores instead of plaintext repo variables. You will also learn how to scope runner permissions to the minimum a job actually needs, prevent secrets from leaking into build logs, pin third-party actions to a commit SHA to block supply chain attacks, and keep an audit trail of every pipeline run that touches production.
By the end of this topic you will be able to configure GitHub Actions OIDC federation with AWS IAM, mask sensitive values in logs, and reason about which credentials a given job should and should not be able to use.
Why This Matters in Production
A static AWS access key sitting in a repository secret is a single point of failure that never expires on its own. If a contractor's laptop is compromised, if a dependency is hijacked, or if a workflow YAML file is misconfigured to print environment variables, a long-lived key gives an attacker standing access to your cloud account until someone notices and manually rotates it. Teams running dozens of microservices through CI -- the kind of footprint you see at Razorpay or PhonePe -- cannot rely on humans to notice and rotate keys fast enough. OIDC removes the problem at the root: there is no long-lived secret to steal, because the pipeline requests a new, narrowly-scoped credential that expires in under an hour for every single run.
COMMON MISTAKE / WARNING**Common Mistake:** Storing an AWS access key and secret key as repository secrets and reusing them across every workflow and every environment. If that key leaks, an attacker has the same access your entire CI system has -- indefinitely, until someone catches it.
Core Principles
Why static credentials are dangerous
Static credentials have three structural problems: they do not expire on a useful timescale, they are usually over-scoped because rotating narrower keys is operational toil, and they are copy-pasteable -- anyone with read access to the secret store, or anyone who can get a workflow to print an environment variable, now effectively has the key forever.
OIDC (OpenID Connect) for federated cloud access
OIDC lets your CI platform act as an identity provider. GitHub Actions can issue a signed JSON Web Token (JWT) for a running job that asserts facts like "this token was issued for repo razorpay/payments-api, on branch main, triggered by a push event." AWS, GCP, and Azure can be configured to trust that JWT and exchange it for temporary, scoped credentials -- with no secret ever stored in GitHub.
+------------------------------------------+| GitHub Actions job requests OIDC token | <- step 1+------------------------------------------+ | v+------------------------------------------+| GitHub OIDC provider issues signed JWT | <- step 2+------------------------------------------+ | v+------------------------------------------+| AWS STS validates JWT vs trust policy | <- step 3+------------------------------------------+ | v+------------------------------------------+| AWS returns short-lived STS credentials | <- step 4+------------------------------------------+ | v+------------------------------------------+| Job calls AWS APIs with temp credentials | <- step 5+------------------------------------------+GitHub Actions OIDC with AWS IAM
You register GitHub's OIDC provider in IAM once per AWS account, then create an IAM role whose trust policy restricts which repo, branch, and even which environment is allowed to assume it.
{ "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::418773912004:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" }, "StringLike": { "token.actions.githubusercontent.com:sub": "repo:razorpay/payments-api:environment:production" } } } ]}GitHub Actions OIDC with GCP Workload Identity Federation
GCP's equivalent is Workload Identity Federation. You create a workload
identity pool, an OIDC provider inside that pool pointing at GitHub, and an
attribute condition restricting the pool to a specific repository before
binding it to a service account with roles/iam.workloadIdentityUser.
Secret management: GitHub Secrets, Vault, AWS Secrets Manager
Not every credential can be replaced by OIDC -- third-party API keys (a payment gateway sandbox key, a Slack webhook) still need to live somewhere.
- GitHub Secrets -- fine for low-blast-radius values, repo or environment-scoped, encrypted at rest, but not rotated automatically.
- HashiCorp Vault -- dynamic secrets with short TTLs, full audit log, used when multiple internal systems beyond CI also need the same secret.
- AWS Secrets Manager -- native rotation Lambdas, IAM-controlled access, a natural fit once you already have OIDC into AWS for the job.
PLACEMENT PRO TIP**Tip:** If a job already assumes an AWS role via OIDC, pull runtime secrets from AWS Secrets Manager inside that job instead of storing a second copy in GitHub Secrets. One source of truth, one rotation point.
Least-privilege runner permissions
Every workflow file should declare the minimum permissions: block it
needs at the top level, and every IAM role assumed via OIDC should be
scoped to exactly the actions and resources that job touches -- a job that
only pushes a Docker image to ECR should not also have s3:DeleteBucket.
permissions: contents: read id-token: write packages: writePreventing secret leaks in logs
CI platforms automatically mask values that are registered as secrets, but
only the exact string -- if a job base64-encodes a secret or splits it
across two echo statements, the mask will not catch it.
- name: Mask a derived value run: | DERIVED_TOKEN="${{ secrets.API_KEY }}-${{ github.run_id }}" echo "::add-mask::$DERIVED_TOKEN" echo "token=$DERIVED_TOKEN" >> "$GITHUB_OUTPUT"COMMON MISTAKE / WARNING**Security:** Never run `env` or `printenv` as a debugging step in a job that has secrets injected as environment variables -- this is one of the most common ways secrets end up readable in plaintext build logs.
Pinning action versions to SHA, not tag
A third-party action referenced by a mutable tag like @v4 can be silently
repointed to malicious code by whoever controls that tag -- this is a real
supply chain attack vector. Pin to the full commit SHA instead.
# Risky: tag can move- uses: some-org/some-action@v4 # Safe: SHA is immutable- uses: some-org/some-action@a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2Audit logging for pipeline runs
Every production deployment job should log who triggered it, what commit SHA was deployed, and what environment it touched, ideally written somewhere outside the CI platform itself (CloudTrail for the AWS API calls, a deployment-events table for the application-level record) so the trail survives even if CI history is later deleted.
Detailed Step-by-Step Practical Lab
This lab sets up OIDC-based deployment from GitHub Actions to AWS for
Razorpay's payments-api repository, deploying to the mumbai-prod-cluster
EKS cluster without ever storing an AWS key in GitHub.
Milestone 1 — Register GitHub as an OIDC provider in AWS IAM
aws iam create-open-id-connect-provider \ --url https://token.actions.githubusercontent.com \ --client-id-list sts.amazonaws.com \ --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1At this point AWS trusts tokens issued by GitHub's OIDC endpoint, but no role can be assumed yet -- you have only created the trust anchor.
Milestone 2 — Create a tightly-scoped IAM role
Create gha-payments-api-deploy-role using the trust policy shown in Core
Principles above, restricted to repo:razorpay/payments-api on the
production environment only. Attach a permissions policy limited to ECR
push and eks:DescribeCluster -- nothing broader.
aws iam create-role \ --role-name gha-payments-api-deploy-role \ --assume-role-policy-document file://trust-policy.json aws iam attach-role-policy \ --role-name gha-payments-api-deploy-role \ --policy-arn arn:aws:iam::418773912004:policy/payments-api-deploy-policyAt this point the role exists but no workflow has tried to assume it yet.
Milestone 3 — Add the permissions block and configure-aws-credentials step
name: Deploy payments-api to production on: push: branches: [main] permissions: id-token: write contents: read jobs: deploy: runs-on: ubuntu-latest environment: production steps: - uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc6 # v4.1.6 - name: Configure AWS credentials via OIDC uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2 with: role-to-assume: arn:aws:iam::418773912004:role/gha-payments-api-deploy-role aws-region: ap-south-1 - name: Verify identity run: aws sts get-caller-identityAt this point aws sts get-caller-identity in the job log should print the
assumed role ARN, not a static IAM user -- confirming OIDC federation works.
Milestone 4 — Push image to ECR using the assumed role
- name: Login to ECR run: | aws ecr get-login-password --region ap-south-1 \ | docker login --username AWS --password-stdin \ 418773912004.dkr.ecr.ap-south-1.amazonaws.com - name: Build and push run: | docker build -t 418773912004.dkr.ecr.ap-south-1.amazonaws.com/payments-api:${{ github.sha }} . docker push 418773912004.dkr.ecr.ap-south-1.amazonaws.com/payments-api:${{ github.sha }}At this point the image is in ECR, pushed entirely with credentials that expire when the job ends -- nothing persists for an attacker to reuse later.
Milestone 5 — Add masking and SHA-pin every third-party action
Replace every @v4-style reference in the workflow with the full commit
SHA (as shown in Core Principles), and add ::add-mask:: for any value
derived from a secret before it is echoed or written to GITHUB_OUTPUT.
Milestone 6 — Confirm the audit trail
aws cloudtrail lookup-events \ --lookup-attributes AttributeKey=Username,AttributeValue=gha-payments-api-deploy-role \ --max-results 5At this point you should see the AssumeRoleWithWebIdentity event followed
by the ECR API calls, all attributable to a single job run -- this is your
audit trail if you ever need to answer "what did CI do and when."
REMEMBER THIS**Remember:** An OIDC token's `aud` (audience) and `sub` (subject) claims are what the cloud provider checks before issuing credentials -- if your trust policy condition is too loose (for example, matching `repo:razorpay/*` instead of the exact repo and environment), any workflow in any repo under that org can assume the role.
Production Best Practices & Common Pitfalls
- Scope the IAM trust policy to repo, branch or environment, and ideally
workflow filename -- never leave the
subcondition as a wildcard. - Rotate the small number of secrets that cannot be replaced by OIDC (third party API keys) on a fixed schedule, not "whenever someone remembers."
- Separate IAM roles per environment -- the role a
stagingdeploy job assumes must not also have production permissions. - Run a periodic job that lists all third-party actions still pinned by tag instead of SHA across your repos, and treat new instances as a build failure in a linting workflow.
- Do not put
permissions: write-allat the workflow level out of convenience -- declare only what each job needs.
Quick Reference & Troubleshooting Commands
| Symptom | Command | What to Look For |
|---|---|---|
Not authorized to perform sts:AssumeRoleWithWebIdentity |
aws sts get-caller-identity after the credentials step |
Trust policy sub condition not matching the actual repo/environment string |
| Secret appears in plaintext in logs | Search the raw log for the secret's literal value | A derived/encoded value that was never passed through ::add-mask:: |
| OIDC token request fails in job | Check permissions: id-token: write is set |
Missing id-token permission at workflow or job level |
| Action behaves differently after a dependency update | git log on the pinned SHA's upstream repo |
Action was referenced by tag, tag was moved upstream |