CI/CD secrets with OIDC: GitHub, GitLab, Bitbucket
How CI jobs reach AWS, Google Cloud, Azure and your vault with short-lived OIDC tokens instead of stored keys, and which claims to pin so a leaked token fails.
Short answer
Let the CI provider issue each job a short-lived signed token, and have AWS, Google Cloud, Azure or your vault accept that token only for one repository, branch and deployment environment. Nothing long-lived then sits in CI settings, and a token copied from a log is useless outside that job.
On this page
Stop storing cloud keys and long-lived tokens in CI settings. Instead, let the CI provider issue each job a short-lived token signed with its own key, and configure AWS, Google Cloud, Azure or your vault to accept that token only for a specific repository, branch and deployment environment. The job exchanges the token for temporary access, nothing reusable is stored, and a token copied out of a build log fails anywhere else. This guide covers how that exchange works, the claims GitHub Actions, GitLab CI and Bitbucket Pipelines put in their tokens, a working trust setup for each major cloud, and how to apply the same idea to secrets that aren't cloud keys.
How OIDC works in a CI job
OpenID Connect is a standard for one party to vouch for an identity with a signed token. In CI the provider vouches for the job. The exchange has four steps.
- The job asks for a token. GitHub Actions issues one only to jobs that have the
id-token: writepermission. GitLab issues one for each entry underid_tokensin the job. Bitbucket issues one to steps with OIDC turned on. - The provider signs a JWT. The token names its issuer, its audience, and a subject, plus claims that describe the job: the repository, the branch or tag, the deployment environment, the pipeline.
- The job presents the JWT. The receiving service fetches the provider's public keys, checks the signature and expiry, and compares the claims with its own rules.
- The service grants short-lived access. A cloud returns temporary credentials. A vault returns the secrets the rule allows.
The tokens are short-lived by design. GitLab, for example, sets the expiry to the job's timeout, or five minutes if none is set, per its ID token documentation. Because nothing in CI settings can be reused later, the leak that stored keys allow, a value copied once and used for months, goes away.
What OIDC doesn't change is what a legitimate job can do. That comes up again at the end of the guide.
The claims each CI puts in its token
Trust rules are only as good as the claims they check. These are the ones you will use.
| GitHub Actions | GitLab CI | Bitbucket Pipelines | |
|---|---|---|---|
| How the job gets a token | permissions: id-token: write | id_tokens: with an aud per token | oidc: true on the step; token in BITBUCKET_STEP_OIDC_TOKEN |
| Issuer | https://token.actions.githubusercontent.com | the GitLab instance, https://gitlab.com on GitLab.com | per workspace, shown under the repository's OpenID Connect settings |
| Default audience | the URL of the repository owner | the instance's domain, unless you set aud | the workspace's audience, unless you add your own |
| Default subject | repo:ORG/REPO:ref:refs/heads/BRANCH, or …:environment:NAME | project_path:GROUP/PROJECT:ref_type:branch:ref:BRANCH | set by Bitbucket; pin the UUID claims rather than the subject |
| Claims worth pinning | repository, ref, environment, repository_id, repository_owner_id | project_path, ref, ref_type, ref_protected, environment | repository UUID, branch, deployment environment UUID |
A few details from each provider's documentation change how you write rules.
GitHub changed its default subject in 2026. Per the GitHub OIDC reference, repositories created after July 15, 2026 use an immutable default subject that includes both the owner ID and the repository ID, such as repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main. Older repositories keep the previous format unless the organization or repository opts in, and renames and transfers after that date also move to the new format. A trust policy that matches repo:octo-org/octo-repo:… silently stops matching for a new repository. The IDs exist for a reason: unlike names, they can't be reused by someone who registers a deleted name.
GitHub's subject depends on the trigger. If a job references an environment, the subject uses the environment. A pull request without an environment gets repo:ORG/REPO:pull_request. Only otherwise does it use the branch or tag. A rule that expects ref:refs/heads/main will not match a job that deploys to the production environment, and that is a feature: it lets you tie production access to an environment with protection rules.
GitLab lets you choose the audience per token. Declare one token per receiving service with its own aud, so a token minted for your cloud can't be replayed against your vault. ID tokens are listed for GitLab's Free, Premium and Ultimate tiers.
Bitbucket shares one default audience across the workspace. Every service that trusts the workspace audience accepts the same token. Add your own under oidc: audiences: in the step; Atlassian's guide allows up to 10 audiences of up to 150 characters each.
GitHub Actions to AWS
Example. Kiteline is a fictional company. The examples in this guide use its repository kiteline/web-app.
AWS trusts GitHub through an IAM OIDC identity provider for token.actions.githubusercontent.com with the audience sts.amazonaws.com, and a role whose trust policy says which subjects may assume it. The policy below, adapted from the AWS IAM guide, allows only the main branch of one repository:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::012345678910: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:kiteline/web-app:ref:refs/heads/main"
}
}
}
]
}For a repository created after July 15, 2026, the subject includes the IDs, so the same condition reads repo:kiteline@OWNER_ID/web-app@REPO_ID:ref:refs/heads/main with your numeric IDs. When AWS sees GitHub as the trusted principal, it refuses to save a trust policy whose sub condition is missing or is only a wildcard. It also warns that without a specific organization or repository in sub, workflows outside your control could assume the role.
The workflow requests the token and lets the official action make the exchange. This follows the configure-aws-credentials README, which uses sts.amazonaws.com as the audience by default:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v6.3.0
with:
role-to-assume: arn:aws:iam::012345678910:role/web-app-deploy
aws-region: eu-west-1
- run: aws sts get-caller-identityTwo rules make this setup hold up. Give the role only the permissions the deploy needs, because any step of the job can use them. And for production, prefer an environment subject over a branch subject, then add required reviewers or branch limits to that environment in GitHub. The same AWS guide strongly recommends protection rules on any environment that an OIDC policy relies on.
GitLab CI to Google Cloud
Google Cloud calls the mechanism Workload Identity Federation. You create a pool, add an OIDC provider for your GitLab issuer, map claims to attributes, and restrict which tokens the provider accepts with an attribute condition. The commands below follow Google's guide for deployment pipelines:
gcloud iam workload-identity-pools create ci-pool \
--location="global" \
--display-name="CI pipelines"
gcloud iam workload-identity-pools providers create-oidc gitlab \
--location="global" \
--workload-identity-pool="ci-pool" \
--issuer-uri="https://gitlab.com" \
--attribute-mapping="google.subject=assertion.sub,attribute.project_id=assertion.project_id,attribute.namespace_id=assertion.namespace_id" \
--attribute-condition="assertion.namespace_id=='GROUP_ID' && assertion.environment=='production'"GROUP_ID is the numeric ID of your GitLab group. Mapping google.subject to sub keeps the project path and Git ref in the principal, so you can grant access per project and branch.
The attribute condition is not optional in practice. GitLab.com and GitHub use one issuer for every customer, so without a condition the provider accepts tokens from any project on the platform. Google's guide says you must restrict access to tokens from your own organization, and suggests numeric ID claims over names because IDs are unique and can't be reused.
Google then recommends granting roles directly to the federated principal rather than impersonating a service account, where the API you call allows it. The member string names the pool and an attribute, with the project number rather than the project ID:
gcloud storage buckets add-iam-policy-binding gs://kiteline-artifacts \
--role=roles/storage.objectViewer \
--member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/ci-pool/attribute.project_id/4242"In the job, declare an ID token whose audience is the provider itself, write it to a file, and point the Google client libraries at a credential configuration that reads that file:
deploy:
environment: production
id_tokens:
WORKLOAD_IDENTITY_TOKEN:
aud: https://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/ci-pool/providers/gitlab
variables:
GOOGLE_APPLICATION_CREDENTIALS: $CI_BUILDS_DIR/.workload_identity.wlconfig
script:
- echo "$WORKLOAD_IDENTITY_TOKEN" > "$CI_BUILDS_DIR/.workload_identity.jwt"
- ./write-credential-config.sh
- ./deploy.shThe credential configuration is a small JSON file of type external_account that names the provider, the token file and Google's token endpoint; Google's guide shows it in full. Keep one ID token per receiving service, so the Google token and any vault token have different audiences.
GitHub Actions to Azure
Azure trusts external tokens through a federated identity credential on a Microsoft Entra application or a user-assigned managed identity. A credential names an issuer, an exact subject and an audience. The example below is from Microsoft's federated credential guide:
{
"name": "web-app-production",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:kiteline/web-app:environment:production",
"audiences": ["api://AzureADTokenExchange"]
}Three constraints from that guide shape the design:
- Exact matches only. Wildcards aren't supported in any field, and pattern matching isn't supported for branches and tags. A subject that is slightly off is saved without error, and the exchange then fails without a clear message. For workflows that run on many branches, bind to an environment.
- Up to 20 credentials per application or managed identity. Plan one identity per deploy target rather than one per branch.
- One audience, with
api://AzureADTokenExchangerecommended.
The workflow uses azure/login with the IDs of the application, as in Microsoft's GitHub Actions guide:
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write
contents: read
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}The client, tenant and subscription IDs are identifiers, not credentials. Microsoft still suggests keeping them in secrets rather than in the workflow file.
Bitbucket Pipelines to AWS
Bitbucket hands each OIDC-enabled step a token in BITBUCKET_STEP_OIDC_TOKEN. You register the workspace's identity provider URL and audience, copied from the repository's OpenID Connect settings, as an IAM OIDC provider, then assume a role with the token. Atlassian's example uses the AWS CLI directly:
pipelines:
default:
- step:
oidc: true
image: amazon/aws-cli
script:
- aws sts assume-role-with-web-identity --role-arn arn:aws:iam::012345678910:role/web-app-build --role-session-name build-session --web-identity-token "$BITBUCKET_STEP_OIDC_TOKEN" --duration-seconds 1000On the AWS side, restrict the role by repository and, for deploys, by deployment environment, using the repository and environment UUIDs from Bitbucket's settings. Atlassian's guide lists those restrictions, plus limiting access to Bitbucket Pipelines IP ranges, as options for the resource side. The blog post on the Bitbucket step token goes through the claims one by one.
Self-managed CI and other issuers
Everything above assumes the hosted services. Self-managed installations change two things.
The issuer is yours. A self-managed GitLab instance signs tokens with its own domain as the issuer, so a cloud provider that trusts it trusts only your instance, not every GitLab.com customer. That removes the shared-issuer problem but not the need for a condition: every project on your instance can still request a token. Restrict by group or project ID as before.
Defaults differ. GitHub's rollout of the immutable subject format does not include GitHub Enterprise Server, per its OIDC reference, so subjects there keep the name-based format unless you customize them.
Other CI systems, and tools that run jobs outside a classic CI, can act as OIDC issuers too. The test for whether you can trust one is the same as for the big three: does it publish signing keys through OIDC discovery, does it put the repository or project in a claim you can pin, and can you set an audience per receiver? A vault or cloud that accepts a generic issuer should make you pin at least one such claim. Crunu, for example, requires a generic rule to pin a repository, project or owner without a * pattern, and checks the standard claims such as expiry and audience itself.
When a CI system can't issue tokens at all, the fallback is a stored credential with every other limit you can add: access to only the environments the job needs, a short lease, a usage limit, and an allowed network range. In Crunu those are settings on the service token, described in Any CI with a service token.
OIDC for secrets that aren't cloud keys
Cloud trust covers cloud APIs. Most pipelines also need values that no cloud issues: a Stripe test key for integration tests, a database URL for migrations, an API token for a monitoring service, a signing key for release builds. Stored as CI variables, they bring back every problem OIDC removed.
There are two ways to keep them out of CI settings too.
Store them in the cloud's secret manager and reach it with OIDC. The job assumes a role, then reads the secret. This works. The trade-off is that the cloud service decrypts the value to return it, and anyone in the account with the right IAM permissions can read it as well. Who can decrypt your secrets compares that model with the alternatives.
Have the vault itself accept the CI token. Several vaults accept OIDC from CI. In Crunu, OIDC is a CI binding on a service token. The token is created in an admin's browser and shown once, and it is granted only the environments the pipeline needs. With a binding, the token signs in only together with a JWT from the CI provider that matches your rules, and each JWT can be used once:
| Provider | Required in a rule | Optional limits |
|---|---|---|
| GitHub Actions | repository, audience | ref, environment, workflow ref, repository ID, owner ID |
| GitLab CI | project path, audience | ref, ref type, protected ref, environment, namespace path, project ID |
| Bitbucket Pipelines | workspace, workspace UUID, repository UUID | branch, deployment environment UUID, audience |
| Any OIDC issuer | an https issuer, an audience, at least one claim that pins a repository, project or owner without * | up to 32 claim constraints |
The agent picks up the job's token by itself on GitHub Actions, GitLab CI and Bitbucket Pipelines. On GitHub the job needs only the permission. On GitLab it declares a token with your Crunu server address as the audience:
deploy:
image: node:24
environment: production
id_tokens:
CRUNU_ID_TOKEN:
aud: https://app.crunu.com
script:
- crunu run --env web-app/production -- ./deploy.shOn Bitbucket, add your server address as an audience of its own, so the token isn't the workspace-wide one:
- step:
name: Tests with staging variables
oidc:
audiences:
- https://app.crunu.com
script:
- crunu run --env web-app/staging -- npm testcrunu run decrypts the environment in memory on the runner and starts the command with its variables. It never passes its own credentials, such as CRUNU_TOKEN or the ID token, to that command. The server stores only the token's public keys and ciphertext, so the values never pass through it in readable form. A replayed JWT is refused and raises an alert; for Bitbucket, whose tokens carry no jti claim, replays are recognized by a fingerprint of the token's signed part. See Bind a service token to CI with OIDC for every field.
One identity per job, not per repository
The easy design is one role per repository that every job assumes. It is also the design that gives your test job the same access as your production deploy. OIDC makes finer identities cheap, so use them.
| Job | What it should get | Rule |
|---|---|---|
| Tests on pull requests | test or staging values, no cloud write access | repository plus pull_request, or a GitLab rule without ref_protected |
| Build on the main branch | permission to push an image or artifact | repository plus the protected main branch |
| Deploy to staging | staging deploy rights and staging values | repository plus the staging environment |
| Deploy to production | production deploy rights only | repository plus the production environment with required reviewers |
Each row is a separate cloud role, Google principal set or Azure credential, and in a vault a separate service token with its own environments. Two things follow. A mistake in the test job can no longer reach production, because nothing it can present is accepted there. And your audit log shows which pipeline stage did what, instead of one shared identity for everything.
Keep the count manageable by naming identities after the job and environment, such as web-app-deploy-production, and by keeping the rules in the same repository as the workflow, reviewed together. Azure's limit of 20 federated credentials per identity is another reason to split by deploy target rather than accumulate branches on one identity.
Writing rules that hold
Each item below is a way a rule can be looser than its author thought. Check yours against the list.
- Wildcards across an organization.
repo:kiteline/*accepts every repository in the organization, including one an intern creates tomorrow. Name the repository. - Repository without branch or environment. Any branch that can run a workflow, including one pushed by anyone with write access, then gets production. For production, bind to an environment with protection rules, or at least to a protected branch.
- Pull requests. A rule that accepts
pull_requestsubjects accepts code that hasn't been reviewed yet. Give pull request jobs a separate identity with test access only. - Names instead of IDs. A deleted organization or repository name can be registered again. Prefer
repository_idandrepository_owner_idon GitHub, project or namespace IDs on GitLab, and UUIDs on Bitbucket. - Shared audiences. If two services accept the same audience, a token minted for one works against the other. Give each receiver its own.
- Changing the subject format without updating policies. GitHub recommends creating the matching condition at your cloud provider before customizing the subject template. The same applies to opting in to the immutable format.
- Long-lived fallbacks. An old access key left in CI "just in case" undoes the work. Delete it once the OIDC path works.
The OIDC trust policy builder generates AWS, Google Cloud and Azure policies from a repository, branch and environment, in both GitHub subject formats.
When a token is refused
A refused exchange usually means the rule and the token disagree about one claim. The error messages are terse, and Microsoft notes that a wrong subject on Azure fails without a clear error. Check these in order:
- The subject format. Is the repository new enough to use GitHub's immutable format with IDs? Does the job reference an environment, so that the subject names the environment rather than the branch?
- The audience. AWS expects
sts.amazonaws.com, Azure recommendsapi://AzureADTokenExchange, Google's GitLab example uses the provider's own address, and your vault expects its server address. A GitLab job needs oneid_tokensentry per audience. - The issuer. Compare it character by character, including a trailing slash. Microsoft's own examples show the GitHub issuer both with and without one, so match what your provider's token actually contains.
- The trigger. A workflow started by a tag, a schedule or a manual run may produce a different subject from the branch push you tested.
- The permission. On GitHub, a job without
id-token: writegets no token at all, and reusable workflows called from another organization need the permission set explicitly in the caller.
To see what the job really sends, decode the token's claims in a debug run on a branch that has no production access, and never print the token itself: until it expires, the token is a credential. Compare the decoded sub, aud and iss with the rule, fix the rule, then remove the debug step.
What OIDC does not protect
OIDC answers "is this the right job?" It doesn't answer "is this job doing the right thing?"
- Code inside the job. A compromised dependency, a malicious build script or a modified test runs inside the job that passed every check, and can use whatever credentials the job received. The protection is a narrow grant: the job gets only what its work needs.
- Logs and artifacts. A value printed by mistake ends up in logs, caches and artifacts. Masking replaces only exact strings that the CI knows about, so a value that was encoded or split along the way can slip through.
- Job-wide variables. Variables set for the whole job are visible to every step and process in it. Set credentials on the one step that uses them.
- The runner itself. On a runner you host, anyone with access to the machine sees what the job sees.
For production, one answer is to keep production secrets out of CI entirely. The OWASP Secrets Management Cheat Sheet says to treat CI/CD tooling as a production environment, and that it is better still when the consumer of a secret retrieves it itself. With Crunu that means running the agent on the production machine, so the pipeline only ships code and restarts a service. Giving CI only the secrets it needs explains that setup and why it beats any token in CI.
Moving a pipeline from stored keys to OIDC
- List every credential stored in the pipeline's settings, with the job that uses it and what it can reach.
- For each cloud, create the OIDC identity provider or workload identity pool and a role that only that pipeline uses.
- Write the trust rule with the narrowest subject that works: repository plus branch, or repository plus deployment environment for production.
- Change the job to request a token and exchange it, and run it on a branch that the rule doesn't allow, to confirm it is refused.
- Move non-cloud secrets into a vault the job reaches with its OIDC token, and grant it only the environments the job needs.
- Delete the stored keys from CI settings, then deactivate and delete them at the provider.
- Record which identity each pipeline uses, so the next audit can check the rules against the list.
Step 4 is the one that catches mistakes. A rule that accepts a forbidden branch looks exactly like a correct rule until something proves otherwise.
For more depth on single topics, see the OIDC subject claim, workload identity federation, GitHub's new subject format and AWS trust policies and GitLab CI OIDC without Premium. The CI pages for GitHub Actions, GitLab CI and Bitbucket Pipelines show the Crunu side in each.
Frequently asked questions
Something else? Write to support@crunu.com
What is OIDC in CI/CD, in one sentence?
The CI provider signs a short-lived token for each job that says which repository, branch and environment it runs for, and the cloud or vault checks that signature and those claims instead of asking for a stored password or key.
Do I still need any secrets in CI if I use OIDC?
For cloud access, usually not: the job exchanges its token for temporary credentials. For other secrets, such as a Stripe key or a database URL, the job still needs a place to fetch them from. A vault that accepts the CI token, or a cloud secret manager reached through OIDC, keeps those out of CI settings too.
Why does AWS say my GitHub workflow is not authorized to assume the role?
A likely cause is that the sub claim in the trust policy doesn't match the token. Repositories created on GitHub after July 15, 2026 use a subject format that includes owner and repository IDs, so a policy written for the old format no longer matches. Compare the policy with the claims your job actually receives.
Is OIDC available on the free plans of GitLab and Bitbucket?
GitLab lists ID tokens on its Free, Premium and Ultimate tiers. Atlassian's guide to OIDC in Bitbucket Pipelines doesn't tie it to a plan. Check your own plan's limits before you depend on it.
Can OIDC stop a malicious dependency in my build from stealing secrets?
No. OIDC proves which job is calling. A job that passes the checks receives its credentials, and any code running inside that job can use them. Keep each job's access as narrow as its work, and keep production secrets out of jobs that run on pull requests.
How does Crunu use OIDC?
As a binding on a service token. With a CI binding, the token signs in only together with a JWT from GitHub Actions, GitLab CI, Bitbucket Pipelines or another OIDC issuer that matches your rules for repository, branch and environment. A replayed JWT is refused.
Sources
Every source this guide relies on, with the date we last checked it.
- OpenID Connect reference, GitHub Docs · checked October 30, 2026
- Create a role for OpenID Connect federation, AWS IAM User Guide · checked October 30, 2026
- configure-aws-credentials, AWS · checked October 30, 2026
- Workload Identity Federation with deployment pipelines, Google Cloud · checked October 30, 2026
- Create a trust relationship with an external identity provider, Microsoft Entra · checked October 30, 2026
- Authenticate to Azure from GitHub Actions by OpenID Connect, Microsoft Learn · checked October 30, 2026
- OpenID Connect (OIDC) ID token authentication, GitLab Docs · checked October 30, 2026
- Integrate Pipelines with resource servers using OIDC, Atlassian Support · checked October 30, 2026
- OWASP Secrets Management Cheat Sheet · checked October 30, 2026