DEV Community

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.com outbound (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 identityId and jwt (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 = * or repo:* 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 like repo:<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 only
  • repo:<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, ::/0 by 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: write does 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: env secrets 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_request runs from forks (as reported by maintainers of widely used actions; it isn't on the main OIDC documentation page). Also avoid pull_request_target combined 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 by job_workflow_ref.
  • IdP changes: the sub format 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 * or repo:*; pin the owner (and branch or environment). Never repo:<OWNER>* without a separator.
  • [ ] Immutable subject (<OWNER>@<OWNER_ID>) after opting in the organization, or a repository_owner_id claim.
  • [ ] 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: write and contents: 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.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.