By FELFAL Bouabid · October 21, 2026
Most pipelines deploy with a long-lived cloud access key stored as a repository secret. That key never expires unless someone rotates it, it is copied into more repositories than anyone remembers, and when it leaks, nothing tells you. OpenID Connect (OIDC) replaces it. Each job asks GitHub for a signed token that proves which repository, branch and environment it runs in. Your cloud provider checks that proof against rules you wrote and hands back temporary credentials. No cloud key is stored in GitHub.
A word about the title. 'Secretless' here means no long-lived cloud credentials in your repository settings. It does not mean no secrets anywhere: third-party API tokens and signing keys still exist, and the OIDC token itself is a credential for as long as the job runs. Section 8 covers what remains.
This article walks through a complete AWS setup, with workflow files that were linted with actionlint and helper scripts that were run on test data. One recent GitHub change deserves your attention before you copy any older tutorial: new repositories now get a different subject claim format (section 3).
1. Why stored keys are a liability
- They do not expire. A key created two years ago works today unless someone remembered to rotate it.
- They spread. The same key ends up in several repositories, in local files and in chat history.
- They are broad. Teams grant a wide policy once to make the pipeline work and never narrow it.
- They are anonymous. Cloud logs show the key being used, not which workflow or which commit used it.
- They leak in quiet ways. A compromised dependency or third-party action running in a job can read secrets that the job uses.
2. How OIDC changes the flow
- The job declares the id-token: write permission. The runner then exposes a request URL and a bearer token in two environment variables, ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN.
- The job, usually through a login action, requests a JSON Web Token from GitHub's OIDC provider at https://token.actions.githubusercontent.com, for an audience of its choosing.
- The token carries claims about the run: the subject (sub), the audience (aud), the repository, the ref, the environment, the workflow reference and more.
- The cloud provider verifies the token's signature with GitHub's public keys, then checks the audience and subject against the conditions on the role.
- If the conditions hold, the provider returns short-lived credentials. With AWS and the official action, the default session lasts one hour.
The security boundary is the set of conditions on the role. GitHub's documentation is explicit that you must define at least one condition, otherwise untrusted repositories could request access tokens for your cloud resources.
3. The subject claim, and a change you must know about
The subject is assembled from the run's metadata, and which pieces appear depends on how the job was triggered:
- A job that references an environment: the subject ends with environment and the environment name.
- A pull request event, with no environment: the subject ends with pull_request.
- A branch push, with no environment: the subject ends with ref:refs/heads/ and the branch name.
- A tag, with no environment: the subject ends with ref:refs/tags/ and the tag name.
- Colons inside a value such as an environment named Production:V1 are written as %3A, so the subject contains Production%3AV1.
The environment takes priority. A job that references an environment has the environment in its subject, not the branch.
Immutable subject claims. Subjects built only from names have a weakness: if an organization or repository name is released and later recreated by someone else, the same subject value can point at a different owner. To close that gap, repositories created after July 15, 2026 use an immutable default format that includes the numeric owner ID and repository ID:
- Previous format: repo:octo-org/octo-repo:ref:refs/heads/main
- Immutable format: repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main
Repositories created before that date keep the previous format unless you opt in at the organization or repository level, using the OIDC settings page or the REST API. Renames and transfers after that date also move a repository to the immutable format. GitHub Enterprise Server is not included in this rollout.
The consequence is practical. A trust policy copied from an older tutorial, with names only, will not match tokens from a new repository, and you will see an access-denied error that looks like a misconfiguration but is not. Look up the IDs with the GitHub CLI:
gh api repos/octo-org/octo-repo --jq '[.owner.id, .id]'The audience defaults to the URL of the repository owner. The AWS login action requests sts.amazonaws.com instead, so your trust policy must expect that value.
4. Set up AWS
- Create the identity provider. In IAM, add an OpenID Connect provider with the provider URL https://token.actions.githubusercontent.com and the audience sts.amazonaws.com. Follow AWS's current documentation for any additional fields it asks for.
- Create a role with a web-identity trust policy. This is the file that decides who can assume the role. For a repository on the immutable format, using an environment named production:
{
"Version": "2012-10-17",
"Statement": [
{
"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",
"token.actions.githubusercontent.com:sub": "repo:octo-org@123456/octo-repo@456789:environment:production"
}
}
}
]
}For a repository on the previous format, only the subject value changes, to repo:octo-org/octo-repo:environment:production. Replace the account number, organization, repository and IDs with your own.
- Attach a narrow permissions policy that allows only the actions this deployment performs, on only the resources it touches. The trust policy says who may assume the role. The permissions policy says what they can then do. Both matter.
- Lower the maximum session duration of the role to what your slowest deployment needs.
- Write the workflow. Save this as .github/workflows/deploy.yml:
name: deploy
on:
push:
branches: [main]
permissions: {} # default: nothing. Each job asks only for what it needs.
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
id-token: write # lets the job request an OIDC token
contents: read # needed by actions/checkout
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy-production
role-session-name: gha-${{ github.run_id }}
aws-region: eu-west-1
- name: Show who we are
run: aws sts get-caller-identity
- name: Publish
run: aws s3 sync ./site s3://example-bucket --deletePoints worth noticing in the workflow:
- No secrets are referenced. The role ARN is an identifier, not a credential, and can sit in the file.
- Permissions start at nothing. The empty permissions line at the top denies everything by default, and only the deploy job requests the OIDC token and read access to the code.
- The job references the production environment. That puts the environment in the subject, which is what the trust policy matches. It also lets you attach required reviewers and branch restrictions to the environment.
- The session name carries the run ID. In AWS CloudTrail, every assumed-role action can be traced back to a single workflow run.
- Actions are pinned to full commit SHAs, with the version in a comment. The SHAs above are for v7.0.1 of checkout and v6.3.0 of the AWS action at the time of writing. Verify them yourself, since a tag you trust today is a moving target tomorrow:
git ls-remote https://github.com/actions/checkout refs/tags/v7.0.1That command prints the commit for the tag. If a tag is annotated, the output also has a second line ending in ^{}, and that line holds the commit hash to use.
5. Test your trust policy before you rely on it
The most common failure is a subject pattern that matches too little (the deployment fails) or too much (other repositories can assume your role). Exact matching with StringEquals avoids most trouble. If you need wildcards, StringLike supports them, and they are where mistakes happen:
- A pattern ending in the repository name plus an asterisk, with no colon, also matches other repositories whose names start the same way.
- A pattern ending after the organization name and a slash plus an asterisk matches every repository in the organization.
- A pattern ending in the repository name, a colon and an asterisk matches only that repository, but every branch, tag, environment and pull request in it.
This script generates sample subjects for your repository in both formats, plus look-alike subjects from other repositories and owners, then shows what your pattern matches. It follows AWS's StringLike rules, where an asterisk matches any run of characters and a question mark matches one. Save it as check_subject.py:
import argparse
import re
import sys
CONTEXTS = ['ref:refs/heads/main', 'ref:refs/heads/feature/x', 'ref:refs/tags/v1.0.0',
'pull_request', 'environment:production', 'environment:staging']
def aws_like(pattern, value):
# AWS StringLike: * matches any run of characters, ? matches exactly one; all else is literal.
rx = ''.join('.*' if c == '*' else '.' if c == '?' else re.escape(c) for c in pattern)
return re.fullmatch(rx, value, re.DOTALL) is not None
def build_samples(org, repo, org_id, repo_id):
legit = {
'legacy': [f'repo:{org}/{repo}:{c}' for c in CONTEXTS],
'immutable': [f'repo:{org}@{org_id}/{repo}@{repo_id}:{c}' for c in CONTEXTS],
}
lookalikes = []
for c in CONTEXTS[:3]:
lookalikes += [f'repo:{org}/{repo}-evil:{c}', f'repo:{org}/{repo}x:{c}',
f'repo:{org}/other-repo:{c}', f'repo:{org}-evil/{repo}:{c}',
f'repo:attacker/{repo}:{c}']
return legit, lookalikes
def check(pattern, org, repo, org_id='123456', repo_id='456789'):
legit, lookalikes = build_samples(org, repo, org_id, repo_id)
result = {
'matches': {fmt: [s for s in subs if aws_like(pattern, s)] for fmt, subs in legit.items()},
'overreach': [s for s in lookalikes if aws_like(pattern, s)],
}
return result
if __name__ == '__main__':
ap = argparse.ArgumentParser(description='Test a GitHub OIDC subject pattern (AWS StringLike rules)')
ap.add_argument('pattern')
ap.add_argument('--org', required=True)
ap.add_argument('--repo', required=True)
ap.add_argument('--org-id', default='123456')
ap.add_argument('--repo-id', default='456789')
a = ap.parse_args()
r = check(a.pattern, a.org, a.repo, a.org_id, a.repo_id)
for fmt in ('legacy', 'immutable'):
hits = r['matches'][fmt]
print(f'{fmt} format: {len(hits)} of {len(CONTEXTS)} subjects match')
for s in hits:
print(' ', s)
if r['overreach']:
print('UNSAFE: the pattern also matches other repositories or owners:')
for s in r['overreach'][:5]:
print(' ', s)
else:
print('OK: none of the look-alike subjects from other repositories or owners match')
if not any(r['matches'].values()):
print('NOTE: the pattern matches none of this repository\'s subjects in either format')
sys.exit(1 if r['overreach'] else 0)Run it with your organization and repository names. For a risky pattern, the output looks like this (shortened):
$ python3 check_subject.py 'repo:octo-org/octo-repo*' --org octo-org --repo octo-repo
legacy format: 6 of 6 subjects match
immutable format: 0 of 6 subjects match
UNSAFE: the pattern also matches other repositories or owners:
repo:octo-org/octo-repo-evil:ref:refs/heads/main
repo:octo-org/octo-repox:ref:refs/heads/mainThe second line is the migration warning in action: a pattern written in the old format matches none of the immutable-format subjects. The script exits with a non-zero code when it finds overreach, so you can run it in CI against your policies. It is a simulation on generated samples. It does not replace a real test against the real role, which section 6 helps you do.
6. See the real claims
When access is denied, compare the subject GitHub actually sent with the one in your policy. This workflow requests a token and prints only the claims. Save it as .github/workflows/oidc-debug.yml:
name: oidc-debug
on: workflow_dispatch
permissions: {}
jobs:
claims:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Print OIDC claims (never print the token itself)
run: |
TOKEN=$(curl -fsS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
echo "::add-mask::$TOKEN"
echo "$TOKEN" | python3 .github/scripts/decode_claims.pyAnd the helper it calls, saved as .github/scripts/decode_claims.py:
import base64
import json
import sys
SHOW = ['iss', 'aud', 'sub', 'repository', 'repository_id', 'repository_owner',
'repository_owner_id', 'ref', 'environment', 'event_name', 'job_workflow_ref',
'runner_environment', 'actor']
def b64url_decode(part):
return base64.urlsafe_b64decode(part + '=' * (-len(part) % 4))
def claims(token):
parts = token.strip().split('.')
if len(parts) != 3:
raise ValueError('not a JWT: expected three dot-separated parts')
return json.loads(b64url_decode(parts[1]))
if __name__ == '__main__':
c = claims(sys.stdin.read())
for key in SHOW:
if key in c:
print(f'{key:<22}{c[key]}')
if 'exp' in c and 'iat' in c:
print('lifetime_seconds'.ljust(22) + str(c['exp'] - c['iat']))
print('(signature is not verified here: this only reads the claims)')Three safeguards are built in. The token is registered as a masked value so the log hides it if it appears. The script prints chosen claims only, never the token or its signature. And it does not verify the signature, because its only job is to show you what the cloud provider will see. GitHub also publishes an action for this purpose, github/actions-oidc-debugger, and the full list of supported claims is at the issuer's discovery document under the claims_supported entry.
To confirm your policy works end to end, run the deploy workflow once and check that the identity printed by the get-caller-identity step is your role. Then test the negative cases: run the same job from a different branch, or against an environment you did not allow, and confirm that AWS denies it. A policy you have never seen fail is a policy you have not tested.
7. Harden beyond the basics
- Deny by default. Set an empty permissions block at the top of every workflow, and grant id-token: write only to the job that needs it.
- Treat every step in that job as trusted. Any step in a job with id-token: write can request a token, including third-party actions. Keep deploy jobs short, use as few third-party actions as you can, pin them to full commit SHAs, and let Dependabot propose updates.
- Bind production to an environment. Give the environment required reviewers and a restriction on which branches may deploy to it. The subject then carries the environment name, and your production role trusts only that.
- Split roles by purpose. Use a read-only role for planning and previews and a separate, narrower-in-scope write role for applying changes. Do not let pull request subjects assume anything that can change production.
- Be careful with pull_request_target and workflow_run. These events run with the privileges of the base repository. Never check out and run untrusted code from a pull request in a job that can request an OIDC token. GitHub's guide on using pull_request_target securely explains the pattern.
- Require a central workflow when you can. The sub claim can be customized, at organization or repository level through the REST API, to include job_workflow_ref. Your trust policy can then accept only tokens from an approved reusable workflow. Create the matching condition in the cloud provider first and change the GitHub template second, because tokens issued under the new template are rejected if the cloud side does not yet expect them.
- Use the stable identifiers where your provider allows conditions on them. The claims repository_id and repository_owner_id survive renames. Not every provider supports conditions on every claim, so check yours.
- Dependabot runs are different. Tokens requested for Dependabot update jobs carry an event_name claim of dynamic. If your provider supports a condition on event_name, allow only the values your workflows use.
- Self-hosted runners. The runner_environment claim tells you whether a job ran on a GitHub-hosted or a self-hosted runner. A compromised self-hosted runner can act as every job that lands on it, so keep them ephemeral and separate production from everything else.
8. What stays a secret
OIDC works when the other side agrees to trust GitHub as an identity provider. GitHub's guides cover AWS, Azure, Docker, Google Cloud, HashiCorp Vault, JFrog, Octopus Deploy and PyPI. For services outside that list:
- Use a broker. HashiCorp Vault can authenticate a workflow by its OIDC token and then issue short-lived credentials for systems that do not speak OIDC themselves.
- Otherwise keep a real secret, and protect it. Store it as an environment secret behind required reviewers, scope it as narrowly as the service allows, and rotate it on a schedule.
- The workflow token is different. The GITHUB_TOKEN is generated for each run and expires with it. It is not something you store.
Signing keys and third-party API tokens remain things you must guard. OIDC removes one large category of secret. It does not remove the need to think about the rest.
9. A migration plan
- Inventory. List every secret in your repositories and organizations whose name suggests cloud credentials, such as access key IDs and secret keys.
- One workflow at a time. Create a role for it with the narrowest policy that works, using the steps in section 4.
- Switch and verify. Change the workflow to the OIDC login, run it, and confirm the identity and the result.
- Delete the old secrets. Then deactivate and delete the IAM user's access keys. Before deleting, check that nothing else still uses the key:
aws iam list-access-keys --user-name deploy-bot
aws iam get-access-key-last-used --access-key-id AKIAEXAMPLEKEYID- Watch for surprises. After the key is gone, look in CloudTrail for AssumeRoleWithWebIdentity events from your roles, and for failed attempts that show a policy is too tight or a workflow was missed.
10. Troubleshooting
- AWS says it is not authorized to perform sts:AssumeRoleWithWebIdentity. This is almost always a subject or audience mismatch. Run the debug workflow and compare the real subject, character by character, with the policy. The usual culprits are the immutable format on a newer repository, a job that references an environment (the subject has the environment, not the branch), a pull request or tag subject you did not expect, a different letter case in the environment name, a colon that appears as %3A, and a customized subject template.
- The login action cannot find or load credentials. Check for id-token: write at the job level. For a reusable workflow that belongs to a different organization or enterprise, the caller must grant that permission explicitly.
- It works on main but fails on a tag or a pull request. Those events produce different subjects, so a policy written for one branch will not match them.
- It stopped working after a rename or transfer. GitHub moves renamed or transferred repositories to the immutable subject format after July 15, 2026, so the policy needs the IDs.
- Dependabot or fork runs cannot get credentials. Those runs have restricted permissions by design. Do not widen a role to make them work.
- A wildcard works too well. Run the pattern through the checker from section 5.
11. Limits
- Trust moves, it does not vanish. You now trust GitHub's identity provider and your own policy. A mistake in a wildcard is the new way to leak access.
- A compromised job can still use the role. The credentials last about an hour, but anything running in the job during that hour can use them. The narrow permissions policy is your protection.
- Credentials still exist on the runner during the run. The login action puts temporary credentials in the job's environment. Short-lived is not invisible.
- Provider support varies. Conditions on custom claims, claim formats and audience handling differ between clouds. Read your provider's guide.
- Enterprise Server differs. Immutable subject claims are not part of the Server rollout, so check which behavior your version has.
12. Checklist
- Create an OIDC provider and one role per purpose and environment.
- Match the subject format your repository actually uses, immutable or previous.
- Use StringEquals for exact subjects, and test any wildcard with the checker.
- Set permissions to empty by default and grant id-token: write per job.
- Bind production to an environment with reviewers and a branch restriction.
- Pin third-party actions to commit SHAs.
- Name sessions with the run ID.
- Keep policies narrow and maximum session durations short.
- Test a success case and at least one failure case.
- Delete the old long-lived keys, then watch CloudTrail.
Summary
OIDC lets a workflow prove who it is, and lets your cloud decide what that proof is worth, with credentials that expire on their own. The security of the whole arrangement lives in two places: the conditions on the role and the permissions you give the job. Write them narrowly, test the failures as carefully as the successes, and remember that new repositories now use a different subject format than the tutorials you may have read.
