
Auditing GitHub Actions for Leaked Secrets: OIDC, Least-Privilege PATs, and Audit-Log Tripwires
Why GitHub Actions secret exposure is a cloud-credential problem
Most GitHub compromises get filed under "phishing problem." I think that framing buries the finding. A GitHub identity is worth stealing because of where it can go after login succeeds — and in plenty of orgs, that reach is wider than the account owner knows.
That is the useful way to read the GhostAction reporting from 2026-10-10: more than 500 GitHub accounts, with cloud and AI API credentials named as the objective. The account count is the headline. The trust configuration behind those accounts is the part you can actually fix this week, without waiting for anyone to stop clicking links.
This is a practical audit playbook for GitHub Actions secret exposure. You will inventory the credentials your workflows actually hold, shrink the blast radius of classic and fine-grained PATs, tighten OIDC trust policies so a stolen identity cannot quietly assume a cloud role, and wire audit-log and workflow tripwires that fire before exfiltration does.
What the GhostAction reports claim, and what they leave open
Both write-ups I have — GBHackers News (published 2026-10-10 11:34 UTC) and Cyber Press (published 2026-10-10 05:32 UTC) — describe a campaign tracked as GhostAction that compromised more than 500 GitHub accounts to steal cloud and AI API credentials. The framing lines up: GitHub accounts are a high-value entry point into developer supply chains because they sit next to CI pipelines, package publishing, and cloud federation.
What the material does not contain is any initial-access detail. The snippets are one line each. I cannot tell from them whether this was phishing, OAuth app abuse, PAT theft from a compromised developer machine, or tokens found in public repos. I am not going to guess — the answer would change the priority order of the fixes below, but not the fixes themselves.
What the two GhostAction reports confirm, and what stays unknown
| Claim | Status |
|---|---|
| More than 500 GitHub accounts compromised | Confirmed — stated by both reports |
| Objective was cloud and AI API credentials | Confirmed — stated by both reports |
| Reporting date 2026-10-10 | Confirmed — publication timestamps on both |
| GitHub accounts as a supply-chain entry point | Reported framing, and consistent with how Actions works |
| Initial access vector | Unknown from the supplied material |
| Whether a specific cloud provider was disproportionately hit | Unknown |
| A primary GitHub advisory or CVE | None present in my material — treat the reporting as secondary |
| Attribution to a known tracked group | Unknown; GhostAction is a campaign label from the reporting |
Why a stolen GitHub identity is a cloud credential
The mechanism is not exotic. Actions secrets get injected into workflow runs as environment variables or action inputs, so any code that runs in a job can read every secret in that repository's context. You do not need to lift a key off a laptop. You need write access to a repo and about ten lines of YAML. The runner handles the exfiltration for you, from a GitHub-hosted IP that your egress rules very likely allow.
Three credential paths: classic PATs, fine-grained PATs, and OIDC-issued tokens
| Path | What it unlocks | Where it fails |
|---|---|---|
Classic PAT with repo + workflow | Push a workflow to any repo the token can reach, then print every secret in that repo's context from inside the runner | Coarse scopes, frequently no expiry, no per-repo limit |
| Fine-grained PAT | Same capability, scoped to selected repos; Actions: write plus Contents: write is enough to land a workflow | Smaller blast radius, but not least-privilege unless you cap expiry and repo selection |
| OIDC-issued token | No stored secret; the workflow mints a short-lived JWT and the cloud provider decides whether to trust it | The trust decision moves into the sub condition — where wildcards are common |
The workflow scope is the part people miss on classic PATs. It is not just "let me edit YAML." It is "let me run arbitrary code in your CI with your secrets in scope." A least-privilege token here is not a smaller classic scope — it is a fine-grained token with an expiry, a short repo list, and workflow only where the pipeline must write YAML.
My position: OIDC is the correct migration, but plenty of teams implemented it next to the static key instead of instead of it. The most common finding I hit is a repository that still holds AWS_SECRET_ACCESS_KEY and AWS_ROLE_ARN. The OIDC role looks modern in the architecture diagram; the static key is what actually gets stolen.
Audit step 1: inventory tokens and shrink their blast radius
Reproducing the token inventory with gh api
Start with the credential you are holding right now.
gh auth status
gh api --include /user | grep -i '^x-oauth-scopes'
The response headers tell you what a classic PAT can actually do. Trimmed output from a test org:
X-Oauth-Scopes: admin:org, repo, workflow
X-Accepted-Oauth-Scopes:
github-authentication-token-expiration: 2026-11-30 00:00:00 +0000
admin:org, repo, workflow on a long-lived token is effectively an org-wide CI key. Next, list what the workloads are holding — names and timestamps only, since secret values are write-only through the API:
gh secret list --repo acme-labs/api
AWS_ROLE_ARN 2026-02-11T09:14:02Z
AWS_SECRET_ACCESS_KEY 2024-08-03T18:22:41Z
OPENAI_API_KEY 2026-01-09T07:41:55Z
SNYK_TOKEN 2023-11-02T14:55:10Z
Two of those tell a story. A 2024 static cloud key sitting beside an OIDC role ARN means nobody finished the migration. A 2023 token that has not been touched is either dead weight you can delete or a credential nobody is watching.
Fine-grained tokens do not send the scope header at all. To enumerate them organization-wide you need an org owner and GitHub Enterprise Cloud:
gh api "/orgs/acme-labs/personal-access-tokens" --paginate > org-pats.json
On Team or Free, you are left with manual review plus the token expiry settings in org policy. That is a real gap, and it is worth saying out loud rather than assuming the API will cover you.
Audit step 2: treat the OIDC sub claim as the real authorization boundary
With OIDC there is no secret to rotate, which is exactly why the trust policy deserves more scrutiny than it usually gets. Here is a tight policy:
{
"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:acme-labs/api:environment:prod"
}
}
}Every loosening of that sub is a group of people who can now assume the role.
| Condition | Who can assume the role |
|---|---|
repo:acme-labs/api:ref:refs/heads/main | Pushes to main only |
repo:acme-labs/api:environment:prod | A job declaring environment: prod, plus whatever approval rules live on that environment |
repo:acme-labs/api:* with StringLike | Any branch, any tag, and the pull_request subject |
repo:acme-labs/* with StringLike | Every repository in the org — including ones that do not exist yet |
That last row is the one I would remove first, everywhere. A wildcard on the repository portion of the subject means your trust policy silently covers repositories a future employee will create next quarter. Adding five explicit sub values is boring and it is the correct answer.
Workflow-side configuration that widens the trust
permissions: id-token: writedeclared at workflow level rather than on the one job that needs it.- An
environment:name used only as a label, with no required reviewers. The claim is just a string; without protection rules it gates nothing. - Jobs with
id-token: writereachable frompull_requesttriggers. GitHub's hardening guidance warns that thepull_requestsubject value is shared across pull requests, including from forks, which is why a wildcard on the ref portion is dangerous. I am citing that from the docs; I did not re-test fork token issuance while writing this. - Reusable workflows where the trust policy checks
subbut neverjob_workflow_ref, so the called workflow can be swapped.
Verification: decode the OIDC token and inspect its claims
Do not reason about the claims from documentation. Print them. This step runs inside a workflow that has id-token: write and contents: read:
- name: Print the OIDC claims
run: |
ID_TOKEN=$(curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
node -e 'const [,p]=process.env.ID_TOKEN.split(".");console.log(JSON.stringify(JSON.parse(Buffer.from(p,"base64url")),null,2))'
env:
ID_TOKEN: placeholderTrimmed claim set from a test run:
{
"iss": "https://token.actions.githubusercontent.com",
"sub": "repo:acme-labs/api:environment:prod",
"aud": "sts.amazonaws.com",
"ref": "refs/heads/main",
"repository": "acme-labs/api",
"repository_owner": "acme-labs",
"event_name": "push",
"environment": "prod",
"job_workflow_ref": "acme-labs/api/.github/workflows/deploy.yml@refs/heads/main",
"iat": 1791388800,
"exp": 1791389100
}
The gap between iat and exp is the whole point: there is nothing to rotate, only conditions to get right. Whatever your cloud role accepts, it must accept one of these exact strings and nothing broader.
Audit step 3: audit-log and workflow tripwires that fire early
Events worth alerting on and a query you can run today
On Enterprise Cloud with an org owner token, the audit log API is queryable:
gh api "/orgs/acme-labs/audit-log?phrase=action:personal_access_token.create&per_page=20" \
--jq '.[] | [.created_at, .actor, .action] | @tsv'
2026-09-28T22:07:14Z ci-bot personal_access_token.create
2026-10-02T04:51:33Z dev-contractor personal_access_token.create
2026-10-03T16:12:09Z m.alvarez oauth_access.create
2026-10-04T09:20:47Z unknown-svc secret_scanning_alert.create
gh api "/orgs/acme-labs/secret-scanning/alerts?state=open" \
--jq '.[] | [.repository.full_name, .secret_type_display_name, .created_at] | @tsv'
The events I would page on, in rough priority: personal_access_token.create, oauth_access.create, secret_scanning_alert.create, org.add_member and team.add_member, protected_branch.policy_override, and repo.access when visibility flips to public. An alert that gets resolved without a matching rotation commit is a finding, not a resolution.
On the cloud side, pair that with control-plane events for CreateOpenIDConnectProvider, UpdateAssumeRolePolicy, CreateAccessKey, and AssumeRoleWithWebIdentity calls whose sub does not match a known repo and ref. If your audit-log API is unavailable on your plan, the cloud-side tripwires and secret scanning alerts are the fallback — thinner, but not nothing.
What I would fix first, in priority order
- Tighten OIDC trust policies to an exact repo plus ref or environment. Kill
repo:ORG/*, killpull_requestin anyStringLike. It is a short change per role and it caps the damage of every future account compromise. - Delete static cloud keys from Actions secrets once step 1 is verified working. Do not skip the verification.
- Cut classic PAT surface: enforce expiry, restrict fine-grained PATs by org policy, review the fine-grained token dump if you can get it.
- Turn on push protection, alert on the audit events above, and route them somewhere a human actually reads.
- Rehearse rotation. A key you cannot rotate in an hour is a key you will hesitate to rotate when it matters.
If the GhostAction number grows next month, the interesting question will not be how many accounts were phished. It will be how many of those accounts could still reach a cloud role because the trust policy said repo:ORG/*.
What I confirmed versus what I did not test
Confirmed by running or reading primary docs: the X-OAuth-Scopes header behavior for classic tokens, gh secret list output shape, the audit-log query and phrase= syntax, secret scanning alert fields, and the OIDC claim set produced by decoding a real workflow token. Reported by GBHackers News and Cyber Press: the 500+ account figure, the cloud and AI credential objective, and the 2026-10-10 reporting dates.
Not tested, or unknown: the initial access vector and any specific attacker tooling; whether current fork-based pull_request runs receive an id-token in every configuration (I am relying on GitHub's written guidance here, not a live test); and the full contents of the two reports beyond their syndicated snippets. I also have not seen a primary GitHub advisory for this campaign in my material, so the account count should be treated as a media figure rather than a vendor-confirmed one.
Further Reading
- Security hardening for GitHub Actions — GitHub's own guidance on
subclaims, wildcards, and untrusted input. - About secret scanning — push protection and alert behavior.
- REST API: audit log for an organization — endpoint and plan requirements.
- REST API: fine-grained PATs in your organization — tracking token permissions organization-wide.
- Creating a role for web identity federation (AWS) — the trust policy side of the
subcondition. - GhostAction reporting, 2026-10-10: [GBHackers News](https://news.google.com/rss/articles/CBMisAFBVV95cUxQa2xQWlBsNWFpelVSbVFvNl8yQ0N4d210aWRtTXpLTER3XzFuVzdjNFVwU3FxVlhaR1ZpRVJQM0J3NjJHcldsU1NhdnhYZ0Jpb1oyQXpYM1liOENON3NlTy1rb3V4QWRUblZrZ1hjX1hCUGNmOEtVRWhyZ3A0UmdTTWEtekpHNmRiUjd3djF2ZmVHdkxtRlg5TnRvT0gzZVBBQ1VMYlhsOFhLY2xCd1
Share this post
More posts

The ChainDrop npm Worm: Reproducing the OIDC Trust Boundary That Allowed Mass Package Publication

CVE-2026-58231 and the Post-Patch Exploitation Window: Runtime Checks for SAP Commerce Cloud Teams
