OpenID Connect for Cloud Deployments
Most pipelines that deploy to a cloud provider used to store a long-lived access key or service account key as a repository secret. That key worked forever, in every workflow run, until someone remembered to rotate it. OpenID Connect (OIDC) removes that liability: GitHub Actions requests a short-lived, cryptographically signed identity token for the running job, your cloud provider verifies that token against a trust policy, and hands back credentials that expire in about an hour. No cloud secret ever sits in your repository.
This lesson assumes you already know how to write a basic workflow and use secrets. It focuses on how OIDC federation works, how to configure the workflow and the cloud side correctly, and the mistakes that turn a well-intentioned OIDC setup into an unscoped credential-issuing machine.
Overview / How it works
OIDC federation for deployments involves three parties: GitHub’s OIDC provider, your cloud provider’s identity service, and your workflow. The flow is always the same shape, regardless of cloud:
- Your job requests an ID token from GitHub’s OIDC token endpoint. This requires the
id-token: writepermission — it is not granted by default. - GitHub issues a signed JSON Web Token (JWT). The token’s claims describe the run:
repository,ref,workflow,environment,actor, and asub(subject) claim that combines several of these, for examplerepo:example-org/payments-service:ref:refs/heads/main. - A cloud-specific action (
aws-actions/configure-aws-credentials,azure/login, orgoogle-github-actions/auth) sends that JWT to the cloud provider’s token exchange endpoint, along with the identifier of an IAM role, service principal, or service account you want to assume. - The cloud provider validates the JWT’s signature against GitHub’s published OIDC discovery document, then checks the JWT’s claims against a trust policy you configured ahead of time. If the claims match, it issues short-lived cloud credentials scoped to that one role.
Nothing about this requires a stored secret on the GitHub side. The trust relationship lives entirely in cloud-side configuration: an IAM identity provider plus a role trust policy in AWS, a federated credential on an app registration in Azure, or a workload identity pool and provider in GCP. Because the credentials are minted per run and expire quickly, a leaked build log or a compromised runner has a much smaller blast radius than a static access key.
Syntax or workflow structure
Every OIDC-based deploy job needs three things: the permission to request a token, a pinned action that performs the exchange, and a role or identity reference that matches what you configured on the cloud side.
The permissions block is the part most tutorials skip and most real workflows get wrong:
permissions:
id-token: write # required to request the OIDC token
contents: read # only what the job actually needs to read the repo
Set this at the workflow level if every job needs it, or scope it to the single job that deploys, leaving other jobs (linting, unit tests) with no elevated permissions at all. Pin the cloud action to a release tag or, for anything touching production credentials, to a commit SHA. A tag can be moved by anyone with write access to that action’s repository; a SHA cannot. The trade-off is that SHA-pinned actions do not receive automatic security patches, so you take on the job of watching for and manually bumping updates.
Examples
Example 1 — AWS deploy via an assumed role. The job authenticates once, then every subsequent AWS CLI or SDK call in that job uses the resulting session credentials automatically.
name: Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-payments-deploy
aws-region: us-east-1
- name: Sync build output to S3
run: aws s3 sync ./dist s3://example-app-static-assets --delete
Expected behavior: the configure-aws-credentials step exchanges the run’s OIDC token for a session that expires in roughly one hour, exports it as environment variables for later steps, and the deploy runs without any AWS secret ever appearing in the workflow file.
Example 2 — Azure login with a federated credential. Azure uses the same JWT exchange, but the identity is a federated credential attached to an app registration rather than an IAM role.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Azure login via OIDC
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebafae4a9 # v2.1.1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to Azure Web App
run: az webapp deploy --name example-app --resource-group example-rg --src-path ./dist
Note that the client, tenant, and subscription IDs are not secrets in the credential-leak sense — they identify which app registration to use, not a password — but storing them as repository secrets keeps them out of a public workflow file and easy to rotate per environment.
Example 3 — GCP Workload Identity Federation. GCP calls its version workload identity federation; a pool and provider replace the IAM identity provider you configure in AWS.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@71f986410dfbc7added4569d411d040a91dc6935 # v2.1.6
with:
workload_identity_provider: projects/111111111111/locations/global/workloadIdentityPools/gha-pool/providers/gha-provider
service_account: gha-deployer@example-project.iam.gserviceaccount.com
- name: Deploy to Cloud Run
run: gcloud run deploy example-app --image us-docker.pkg.dev/example-project/app/image:latest --region us-central1
Expected behavior for all three: the deploy step only succeeds if the run’s claims (repository, branch or environment, and in Azure/GCP’s case the workflow path) match what was configured in the trust policy or federated credential. Any other repository or branch attempting to use the same role or service account is rejected before any cloud credential is ever issued.
Step by step
- In the cloud console, register GitHub’s OIDC provider once per account or project. For AWS this is an IAM identity provider with issuer URL
https://token.actions.githubusercontent.comand audiencests.amazonaws.com. - Create the role, service principal, or service account the workflow will assume, and attach the minimum IAM policy it needs — write access to one S3 bucket or one Cloud Run service, not account-wide admin.
- Write a trust policy or federated credential that restricts the
subclaim to your exact repository and branch or environment, and, for AWS, also checks theaudclaim. - In the workflow, add
permissions: id-token: writeat the workflow or job level. - Add the cloud login action pinned to a tag or SHA, referencing the role or identity created in step 2.
- Gate the deploy job behind a GitHub Environment with required reviewers for production, so credential issuance for the production role also requires human approval.
- Run the workflow from the exact branch or environment the trust policy allows, and confirm the deploy step succeeds; then try running it from a different branch and confirm the cloud provider rejects the exchange.
Common Mistakes
Mistake 1: forgetting the id-token: write permission. Without it, GitHub never issues a token to request, and the cloud action fails immediately with no credentials to exchange.
Error: Credentials could not be loaded, please check your action inputs: Could not load credentials from any providers
Correction: add the permission explicitly. Default workflow permissions do not include id-token: write even when the repository’s default token permissions are set to “read and write,” because it is treated as a separate, higher-sensitivity grant.
permissions:
id-token: write
contents: read
Mistake 2: a trust policy scoped too broadly. GitHub’s OIDC issuer is shared by every public and private repository on GitHub. If your condition on the sub claim uses a wildcard that matches more than your own repository, or you omit the audience check entirely, any workflow anywhere that knows your role’s ARN can attempt to assume it.
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:*"
}
}
}
Correction: pin both the audience and a specific repository and ref (or environment) in the subject claim.
{
"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"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:example-org/payments-service:ref:refs/heads/main"
}
}
}
Best Practices
- Scope every trust policy to one repository and one branch or GitHub Environment name; never accept a wildcard across an entire organization.
- Create a separate role or service account per environment (staging, production) with its own least-privilege policy, so a compromised staging deploy cannot touch production resources.
- Put production deploy jobs behind a protected GitHub Environment with required reviewers, so credential issuance also requires human sign-off, not just a matching claim.
- Never trigger an OIDC-authenticated deploy job from
pull_request_targeton untrusted fork input, and never run privileged deploy jobs on self-hosted runners that also build code from forks — both let attacker-controlled code run with access to real cloud credentials. - Prefer OIDC over storing any cloud access key or service account key as a repository secret; if a provider or workflow still needs one, treat it as a stopgap and migrate it.
- Audit trust policies and federated credentials periodically; a role’s trust policy is a credential in itself and deserves the same review as an IAM policy.
Practice Exercises
- Register your cloud provider’s OIDC trust with GitHub’s issuer, create a role scoped to a single test repository and branch, and write a workflow that assumes it. Confirm the deploy step succeeds only from that exact branch.
- Deliberately broaden the trust policy’s subject condition to a wildcard, rerun the workflow from a different branch, and confirm the exchange now succeeds. Then revert the policy and confirm it is rejected again, to see the risk directly.
- Move your deploy job behind a GitHub Environment with a required reviewer, and verify the OIDC token exchange only happens after the approval is granted.
Summary
OIDC replaces stored cloud secrets with a per-run identity exchange: GitHub issues a short-lived signed token, your cloud provider checks it against a trust policy you control, and hands back credentials that expire in about an hour. The workflow side needs the id-token: write permission and a pinned login action; the cloud side needs a trust policy scoped tightly to one repository and branch or environment, with an audience check where the provider supports one. The two failure modes to watch for are forgetting the permission, which simply breaks the deploy, and scoping the trust policy too broadly, which quietly lets other repositories assume your role. Pair OIDC with protected environments and least-privilege IAM policies, and a stolen build log stops being a path to your cloud account.
