Stop Storing Secrets in GitHub Actions (and the One Wildcard That Opens Your Vault to Every Repo on GitHub)
1. Threat model
| Threat | Static secret | Scoped OIDC |
|---|---|---|
| Secret leaked in logs or artifacts | Credential valid until rotated | Token lives for minutes and is bound to an audience |
| Malicious workflow from a fork or PR | Can exfiltrate the secret if exposed | GitHub doesn't issue OIDC tokens to fork PRs, and the subject wouldn't match anyway |
| Third-party repo that knows your URL | N/A | Real risk if the subject is loose (section 4) |
| Rotation and revocation | Manual, with exposure windows | Nothing to rotate; revoke by removing the identity |
| Attribution | One shared secret, no traceability | Every login is tied to a repo, branch and workflow |
Assumptions: GitHub is the identity provider (IdP) and is trusted; GitHub-hosted runners are ephemeral; an attacker with write access to a protected branch is already inside the perimeter.
CI/CD pipelines are one of the most attacked links in the software supply chain, and the usual root cause is the same: long-lived credentials stored as CI provider secrets.
A CLIENT_ID / CLIENT_SECRET pasted into GitHub never expires, isn't bound to an execution context, and if it leaks (a log, a malicious fork, a compromised dependency) it is valid from anywhere.
The alternative is workload identity federation: the job doesn't present a secret it holds, but a signed statement about who it is and in what context it runs. It is the same model AWS (IAM OIDC provider), GCP (Workload Identity Federation) and Azure (federated credentials) offer for their clouds.
Here I apply it to a secrets manager, Infisical, and focus on what is rarely explained: the trust model, a recent GitHub change that breaks older configurations, and how to configure it without opening a hole.
All examples use placeholders (<OWNER>, <OWNER_ID>, <IDENTITY_ID>, infisical.example.com). Replace them with your own values.
2. How the flow works
sequenceDiagram
participant J as Job (GitHub runner)
participant G as GitHub OIDC IdP
participant I as Infisical
J->>G: 1. Request JWT (audience = A)
G-->>J: 2. Signed JWT (RS256)
J->>I: 3. POST /api/v1/auth/oidc-auth/login {identityId, jwt}
I->>G: 4. Fetch .well-known + JWKS
G-->>I: 5. Public keys
Note over I: 6. Verify signature and claims (iss, aud, sub, claims)
I-->>J: 7. Short-lived access token
J->>I: 8. Read secrets with that token
What matters:
- The JWT (RFC 7519) is signed by GitHub with a key published in its JWKS. Infisical verifies the signature against that JWKS, so it must be able to reach
token.actions.githubusercontent.comoutbound (egress). If you self-host behind a strict firewall, that's the first place to look. - The token has a very short lifetime (minutes) and is issued on demand, per job.
- The audience is chosen by whoever requests the token. It is not a secret and GitHub doesn't fix it. Infisical's login endpoint takes
identityIdandjwt(application/x-www-form-urlencoded).
3. Relevant claims in GitHub's token
| Claim | Example | Use |
|---|---|---|
iss |
https://token.actions.githubusercontent.com |
Same for all of GitHub: doesn't discriminate |
aud |
https://infisical.example.com |
Chosen by the requester: doesn't discriminate |
sub |
see section 4.2 | The main discriminator |
repository / repository_owner |
<OWNER>/<REPO> / <OWNER> |
Names (mutable) |
repository_id / repository_owner_id |
numeric | Identifiers that don't change on rename |
ref, event_name |
refs/heads/main, push |
Branch and trigger |
environment |
production |
If the job uses a GitHub environment |
job_workflow_ref |
<OWNER>/<REPO>/.github/workflows/x.yml@refs/heads/main |
Which workflow (or reusable workflow) requested the token |
4. The critical point: iss and aud protect nothing
The issuer is shared by every GitHub user, and aud is written by whoever requests the token. Anyone can create a repository, write a workflow and request a GitHub-signed JWT with aud = https://infisical.example.com. That token is cryptographically valid.
So the only barrier between your Infisical and the rest of GitHub is the identity's claims policy, especially sub.
Consequences:
- Subject =
*orrepo:*is equivalent to opening authentication to any repository on GitHub. It's a manual confused deputy. - A broad pattern (
repo:<OWNER>/*) is only acceptable if you control everything that runs under<OWNER>and who can write to it. - In Infisical,
*in the Subject behaves like**and also matches:and/(per its documentation). A pattern likerepo:<OWNER>*would also match<OWNER>-other. Always put a/or@separator right after the owner.
4.1. Names are mutable
Historically sub was built from organization and repository names. If an organization was deleted or renamed and someone else registered the freed name, their sub would match your pattern (a namespace reuse attack). The OIDC spec requires sub to be locally unique and never reassigned; the old format didn't guarantee that.
4.2. GitHub's 2026 change: immutable subject (it affects your setup)
GitHub introduced a default sub that includes the immutable IDs of the owner and the repository:
- old format:
repo:octo-org/octo-repo:ref:refs/heads/main - new format:
repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main
According to GitHub's documentation and changelog:
- Since July 15, 2026, new repositories use the immutable format by default, and renamed or transferred repositories move to it.
- Older repositories keep the old format until you opt in at the organization or repository level (OIDC settings UI or REST API).
- A preview endpoint shows what the prefix will look like.
- Not available on GitHub Enterprise Server.
Practical consequence: a policy like repo:<OWNER>/*:ref:refs/heads/main stops matching new, renamed or transferred repositories. The symptom is a 403 on a repo that "used to work" or that you just created.
Recommended setup: opt in your organizations and pin the Subject with the ID, which also closes the name-reuse attack:
repo:<OWNER>@<OWNER_ID>/*:ref:refs/heads/main
Get the owner's numeric ID with gh api users/<OWNER> --jq .id (works for organizations too).
Alternatively, keep the old format and add a condition in Infisical's Claims (repository_owner_id = <OWNER_ID>). If you customize sub with include_claim_keys, note that in the immutable format owner_id and repo_id are always included.
4.3. Restricting by branch or environment
repo:<OWNER>@<OWNER_ID>/*:ref:refs/heads/main# main branch onlyrepo:<OWNER>@<OWNER_ID>/<REPO>@<REPO_ID>:environment:production# only jobs using the "production" environment
Restricting to main only adds security if main is protected (required reviews, no direct pushes). If anyone with write access can push a workflow to main, the boundary disappears. Using environments with required reviewers adds a human approval before the job gets the token.
5. Design: one identity per owner
Infisical supports one subject pattern per identity. Trying to cover several owners with one pattern forces you to widen it, and isolation is lost. The design I use:
| Decision | Reason |
|---|---|
| One identity per owning account (organization or user) | Tight subject, blast radius limited to one owner |
| Organization role No Access | The identity manages nothing at organization level |
| Minimal project role (Viewer), only on the projects it needs | Least privilege: read only, no write or admin |
| Remove Universal Auth if unused | Smaller surface: every active auth method is an entry point |
| Treat identity-id as public data | It is an identifier, not a secret |
6. Step by step
6.1. Infisical
Access Control → Machine Identities → Create: name github-ci-<owner>, organization role No Access.
In the identity: Authentication → Add Auth Method → OIDC Auth with:
| Field | Value |
|---|---|
| OIDC Discovery URL | https://token.actions.githubusercontent.com |
| Issuer | https://token.actions.githubusercontent.com |
| Subject | repo:<OWNER>@<OWNER_ID>/*:ref:refs/heads/main |
| Audiences | https://infisical.example.com |
| Claims (optional) | repository_owner_id = <OWNER_ID> |
Copy the identity's Identity ID.
In the project: Access Control → Machine Identities → Add identity, role Viewer.
6.2. Hardening the issued token (often skipped)
Infisical's form proposes defaults meant for general use, not for CI:
- Access Token TTL / Max TTL: 2592000 s (30 days) in the form. (The documentation also mentions 7200 s in the login response and doesn't explain the difference: check the effective value on your identity.) That's excessive for a CI job: reduce both to the longest job duration plus margin (for example 600-3600 s).
- Access Token Max Number of Uses: 0 (unlimited). If your pipeline makes a bounded number of reads, set a maximum.
- Trusted IPs:
0.0.0.0/0,::/0by default. GitHub-hosted runner ranges are broad and change; with self-hosted runners and a fixed IP you can restrict it.
6.3. Workflow
name: release
on:
push:
branches: [main]
permissions:
id-token: write # lets the job request the OIDC JWT
contents: read # least privilege for everything else
jobs:
build:
runs-on: ubuntu-latest
environment: production # optional: manual approval and `environment` claim
steps:
- uses: actions/checkout@v4
- name: Fetch secrets from Infisical
uses: Infisical/secrets-action@v1.0.12 # in production, pin the commit SHA
with:
method: oidc
domain: https://infisical.example.com
identity-id: <IDENTITY_ID>
project-slug: <PROJECT_SLUG>
env-slug: prod
secret-path: /
export-type: env # "file" writes a .env file instead
Notes:
id-token: writedoes not grant write access to the repository; it only enables JWT issuance. Declare explicit permissions at workflow or job level so you don't depend on organization defaults.- Pin the action to a commit SHA (not a movable tag): the action itself is third-party code that receives your claims and returns secrets.
- With
export-type: envsecrets become environment variables for later steps. Limit their scope to the step that uses them where possible. - Don't request tokens in workflows triggered by untrusted code. GitHub doesn't issue OIDC tokens to
pull_requestruns from forks (as reported by maintainers of widely used actions; it isn't on the main OIDC documentation page). Also avoidpull_request_targetcombined with the PR branch's code.
7. Debugging: inspect the real claims
The most common failure (403) is a mismatch between the configured sub / aud and what GitHub emits, especially after the format change. Instead of guessing, print only the claims (never the full token) in a test workflow:
- name: Inspect OIDC claims (debug only)
run: |
URL="${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=https://infisical.example.com"
curl -sS -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "$URL" \
| jq -r '.value | split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson | {iss, aud, sub, repository_owner, repository_owner_id, repository_id, ref, event_name, job_workflow_ref}'
Compare the output, character by character, with the identity's policy. Remove this step when you're done.
8. Configuration variables and GitHub's Free plan
identity-id and domain aren't secrets; you can keep them in the workflow or in Actions variables.
An easy-to-miss limitation: organization variables on the Free plan are only available to public repositories. On private repositories the repository selector appears empty. Alternatives: repository-level variables, literal values in the workflow, or a centralized reusable workflow / composite action that encapsulates the Infisical step and is called from the rest.
A centralized reusable workflow also enables the strongest defense: restrict job_workflow_ref so that only that audited workflow can obtain the token, even if another repository under the same owner tries to request it.
9. Trade-offs and limitations
- Availability dependency: the job needs both GitHub (IdP) and Infisical to be reachable. A static secret also depends on Infisical, but not on GitHub's JWKS.
- Per-owner surface: patterns like
repo:<OWNER>@<OWNER_ID>/*assume everything under the owner is trusted. If not, scope by repository or byjob_workflow_ref. - IdP changes: the
subformat changed in 2026. A policy built on exact strings is fragile to provider changes; use GitHub's preview and integration tests. - Necessary, not sufficient: OIDC removes the static CI credential, not the risk of a compromised workflow inside the allowed perimeter. Complement it with branch protection, CODEOWNERS reviews and audit logs.
- Observability: review Infisical's audit logs to spot logins from unexpected identities or repositories.
10. Checklist
- [ ] Subject is never
*orrepo:*; pin the owner (and branch or environment). Neverrepo:<OWNER>*without a separator. - [ ] Immutable subject (
<OWNER>@<OWNER_ID>) after opting in the organization, or arepository_owner_idclaim. - [ ] main branch protected, or an environment with required reviewers.
- [ ] One identity per owner; organization role No Access; minimal project role.
- [ ] Access token TTL reduced (minutes, not days); uses and IPs bounded where possible.
- [ ] Explicit permissions:
id-token: writeandcontents: read; action pinned by SHA. - [ ] Universal Auth removed if unused.
- [ ] Alerting or periodic review of audit logs.
References
- GitHub Docs: OpenID Connect reference
- GitHub Changelog: Immutable subject claims for GitHub Actions OIDC tokens
- Infisical Docs: OIDC Auth
- Infisical/secrets-action
- RFC 7519: JSON Web Token (JWT)
- OpenID Connect Core 1.0
If you have a different approach to isolating owners or pinning job_workflow_ref, I'd love to read it in the comments.
Comments
No comments yet. Start the discussion.