Sleeper Squats: How a Hyphen (Almost) Unraveled GitHub's Immutable OIDC Subject Claim
research

Sleeper Squats: How a Hyphen (Almost) Unraveled GitHub's Immutable OIDC Subject Claim

TL;DR: In late April 2026, GitHub shipped a changelog post introducing immutable subject claims for GitHub Actions OIDC tokens, then pulled the post six hours later. The feature stayed live in production into the next day. During that short window, I (and, as I’d learn later, at least one other person) realized the new <org>-<org_id> format opens a pre-hijack opportunity: anyone can register a legacy organization whose name is a perfect string collision for a future victim’s immutable subject claim, then wait for the victim to opt in. I disclosed via HackerOne the next morning; the feature was disabled in production about an hour later. GitHub later reshipped it with @ (not a hyphen) as the delimiter, which closes the collision; the namespace-recycling problem it addresses was first disclosed by Tal Skverer in February 2025.


On disclosure and publication timing

Identified 2026-04-23, reported via HackerOne #3693338 on 2026-04-24, published here once the issue was fixed and GitHub greenlit release. Dates appear in absolute form so the timeline stays readable whenever you read this. GitHub closed the specific abuse path by reissuing the feature with @ as the delimiter instead of a hyphen, making an exact-string collision impossible to construct. The underlying lesson about parsing ambiguity in string-based sub claims stands. For the current state of the feature, see the OIDC documentation.

An afternoon in Montreal

Thursday, April 23, 2026, 20:59 UTC. 16:59 here in Montreal. After a brutally long Quebec winter, the weather had finally turned warm enough to justify closing the laptop and heading to the park. I was one git push away from closing the lid when I glanced at the refreshed GitHub dashboard, “Latest from our changelog”, and one item caught my eye: Immutable subject claims for GitHub Actions OIDC tokens.

The change was substantial: the default sub claim for new repositories (and opt-in for existing ones, with mandatory enforcement on June 18, 2026) would append immutable owner and repository IDs.

# Before
repo:octocat/my-repo:ref:refs/heads/main

# After
repo:octocat-123456/my-repo-456789:ref:refs/heads/main

The motivation is sensible: if an org name is recycled, the old sub claim can be minted again by a new owner; tying the claim to immutable IDs closes that door. I pinged the usual Signal group of supply chain friends (including one with very close knowledge of how PyPI built their Trusted Publishing backend) figuring somebody would have an opinion on how PyPI, npm, RubyGems, and crates.io would handle the transition. Then I noticed the fine print: opt-in is available right now, at the org or repo level. Not June; now. I also pulled another friend into the thread for a hot take, the one behind the conference talk on weaponizing GitHub Actions + OIDC + Cloud IAM, and actually went outside.

The park bench lightbulb

Same evening, 22:13 UTC. Sipping an espresso tonic, #summerdrink, on a park bench. My phone buzzed; the thread was getting vitriolic about the rushed rollout. And while I was reading, the real lightbulb went off.

Wait. If enforcement only kicks in on June 18 and opt-in is available right now, then: existing orgs still emit the legacy format, anyone can register a new org with an arbitrary name, and hyphens are (and always have been) valid characters in GitHub org and repo names. Which means today you can register an org called microsoft-<microsoft's org id> and GitHub’s platform won’t blink. Come June 19, that legacy org will still emit a legacy-format sub containing microsoft-<org_id>/.... Bit-for-bit identical to what the real Microsoft’s immutable format will look like.

Five minutes later (22:18 UTC), I tried it on one of my own orgs. Success. No warning, no collision check, no validation against existing namespaces. I’d learn later that someone else had the same idea about nineteen minutes before I did, with a more spicy target: github.com/aws-2232217. I appreciate the timing. The Signal thread swapped vitriol for expletives.

The threat in plain English

OIDC into AWS IAM, Azure Entra ID, GCP Workload Identity Federation, HashiCorp Vault, Chainguard OctoSTS, and OIDC-based Trusted Publishing all share the same trust decision: the Relying Party receives a JWT from GitHub, inspects claims, and decides whether to hand out access. Those policies are, almost universally, documented and implemented as literal string comparisons against the sub claim. A typical AWS IAM role trust policy today looks like this:

{
  "Condition": {
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:octocat/my-repo:*"
    }
  }
}

That :* on the right-hand side is extremely common: it lets any branch, tag, or environment in octocat/my-repo assume the role. Even more common, and more dangerous, is the telescopic variant where operators trust an entire org: "repo:octocat/*". Now the picture: GitHub’s migration UI instructs the victim to swap that string for the new immutable-prefixed one ("repo:octocat-123456/my-repo-456789:*", or the org-wide "repo:octocat-123456/*"). At that moment, AWS IAM cannot distinguish a sub minted by the legitimate octocat org (where GitHub internally appended -123456) from a sub minted by an attacker-owned org literally named octocat-123456 still on the legacy format. Both JWTs are signed by GitHub. Both strings match. AWS has no concept of “is this hyphen a delimiter or part of the original name?” It’s just a string. And with the org-wide wildcard, the attacker doesn’t even need to squat a specific repo name: any repo inside octocat-123456 walks right in. Hence the Sleeper Squat:

  1. Pre-positioning (before June 18, 2026): enumerate high-value orgs via api.github.com/orgs/<name>, mass-register squats like <target>-<target_id> with inner <repo>-<repo_id> repos. Everything stays on the legacy format by default.
  2. Waiting game: a workflow in each squat tries to assume the victim’s IAM role or publish to their npm namespace. Fails today because the victim’s trust policy doesn’t match the new string yet.
  3. Trigger: the victim, nudged by the new “Use immutable subject claim” toggle, opts in. The UI helpfully shows them the new Default subject claim prefix and, following GitHub’s own instructions, they paste it into their AWS trust policy.
  4. Execution: the attacker’s sleeper workflow runs, GitHub mints a legacy-format token whose sub string-matches the victim’s new IAM policy perfectly. Cloud access granted. Or a malicious package published into the victim’s namespace.

One subtlety for package registries. PyPI was designed defensively around exactly this class of problem, thanks in large part to William Woodruff’s sharp recommendation during the design and in the OpenSSF Trusted Publishers for All Package Repositories doc: their backend ignores the string sub for authorization and instead evaluates the integer repository_owner_id claim in the JWT. Immune by design. npm, RubyGems, and crates.io, based on their public docs and UI flows, ask maintainers to type literal owner and repository strings, which is exactly the pattern this bug abuses. If they internally resolve those strings to integer IDs, fine. If not, risk is real.

Vibe-disclosure mode

Later that evening, I went to re-read the changelog. HTTP 404. The post was gone. I hopped to another browser where the tab was still open, saved it to PDF, and breathed out. Post pulled, feature still live in production; weird place to be.

I dropped my raw notes into a Gemini chat to stress-test the framing.

Gemini then said: You have just identified a massive, highly critical zero-day architectural flaw in how GitHub is rolling out this migration.

Dramatic, but it agreed with my read. Good enough for a vibe-disclosure draft. Before sleeping, I set up the reproduction (screenshotting every step as I went, in case the feature went 404 on me the way the blog post had): victim side boguscorp (org ID 124409894), public repo whatever (repo ID 1219544299); attacker side a fresh org literally named boguscorp-124409894 with a private repo whatever-1219544299. Private because I could, and because added stealth only helps the attacker’s case.

I toggled “Use immutable subject claim” on the victim’s Settings → Actions → OIDC page. GitHub dutifully showed me the new prefix: repo:boguscorp-124409894/whatever-1219544299. Bit-for-bit identical to the sub an attacker workflow in boguscorp-124409894/whatever-1219544299 would mint under the legacy format. GitHub’s own UI even says, in friendly green text: “Use this prefix when configuring trust policies in your cloud provider.”

GitHub UI showing the 'Use immutable subject claim' toggle enabled on a victim repo, displaying the default subject claim prefix repo:boguscorp-124409894/whatever-1219544299.

As Seen On github.com, circa 2026-04-24, ~09:45 EDT.

Submission, then silence

The following morning (2026-04-24), I drafted the HackerOne report, took screenshots, and submitted at 10:02 EDT / 14:02 UTC. About an hour later, the feature was gone: toggle vanished from the settings UI, preview endpoint down, default template the only option. (Me, crying that GitHub killed my OIDC trust binding: booo. I kid.)

Same repo's OIDC settings, about an hour after the HackerOne submission. The 'Use immutable subject claim' toggle is gone; only the default template option remains.

Same repo, about an hour later (2026-04-24). Toggle gone.

Net exposure: the feature was opt-in, live for roughly a day, and pulled about an hour after the report. No evidence anyone was actually compromised. The collision was constructible, but the window closed before it could be weaponized at scale. That is the “almost”.

What a better delimiter looks like

The suggestion I put in the HackerOne report: use a delimiter that is not a valid character in GitHub org or repo names, and not already meaningful inside the sub claim (so not : or /). The pipe character (|) fits both constraints and is already the de-facto separator used by Auth0 for composite subject identifiers (github|12345), so there’s prior art.

repo:boguscorp|124409894/whatever|1219544299:ref:refs/heads/main

Exact-string collision becomes mathematically impossible, because boguscorp|124409894 is not a registerable org name. The outer shape (repo:<owner>/<repo>:...) is unchanged, so existing string-splitting logic keeps working. Other options (block <name>-\d+$ at registration, or deprecate string-based sub evaluation across every Relying Party) rank lower: the first doesn’t help orgs already squatted during the window, the second pushes migration cost onto AWS/GCP/Azure/Vault/npm/RubyGems and every customer’s trust policy. Keep the burden on the IdP, where it belongs.

When the feature reappeared, GitHub had landed on the same conclusion, just with a different character. The republished changelog (same original URL, now carrying an editor’s note) drops the hyphen and joins name and ID with @, which is not a legal character in a GitHub org or repository name:

# What GitHub shipped
repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main

That closes the door exactly the way the pipe would have: there is no org you can register that produces octo-org@123456 on the legacy format, so the exact-string collision the Sleeper Squat depended on is no longer constructible. @ instead of | is immaterial; the one constraint that mattered (a delimiter off the namespace alphabet, and not : or /, which are already meaningful inside the sub) is satisfied. The default-on cutover also slipped, from the original June 18 enforcement date to repositories created after July 15, 2026, with older repositories opting in.

The root cause: Sub:jugation

Worth being precise about where this sits in the larger story. The namespace-recycling class that immutable subject claims set out to solve is not something I found, and credit where it is due: that is Tal Skverer’s work, disclosed to the affected vendors in February 2025 and later presented at fwd:cloudsec 2026 as Sub:jugation: Hijacking Cloud Identities by Recycling Namespaces in Global OIDC Issuers. The framing is broader than any single bug: any platform that runs one global OIDC issuer for all tenants and builds the sub from recyclable, human-readable namespace paths (GitHub Actions, GitLab CI, and friends) inherits the same flaw. Delete or abandon a namespace, let someone re-register it, and any cloud role still trusting that sub is assumable.

Immutable subject claims are GitHub’s remediation for that class, and the Sleeper Squat is not a rediscovery of it: it is a flaw in the remediation itself, where the rollout’s hyphen delimiter plus the opt-in window briefly let an attacker pre-register a perfect collision for the future immutable string. Root cause upstream, fix in the middle, bug in the fix.

The interesting part is the difference in blast radius, an unintended side effect of the rollout. Skverer’s original needs the namespace free first: an org has to be deleted, abandoned, or renamed before it can be re-registered, so the targets are names their owners already gave up. The Sleeper Squat needs none of that. During the opt-in window, the hyphen format let you pre-register <org>-<org_id> against any live org, including the biggest names that would never relinquish theirs. For that short stretch, a class that previously only reached dead namespaces widened to include active, high-value targets. It was a transient artifact of an in-progress migration, not a property of the final design. I was not alone in spotting it: at least one other researcher had a HackerOne report validated in the same window (by the timing, the person behind github.com/aws-2232217). That nobody got hurt owes a lot to that parallel disclosure, and to GitHub pulling the feature quickly and reshipping it with a clean delimiter. Read Skverer’s research for the full picture.

Moral of the story

As William Woodruff put it in the thread, the OIDC sub claim “always seemed incredibly brittle and abuse prone.” He’s right. We’ve seen this movie before (LDAP DNs, SAML NameID, S3 ARNs): stitch one flat string out of fields with mismatched mutability and trust domains, hand it to systems that treat the whole thing as opaque, and it ends the same way every time. The other takeaway is simpler: if you ship an opt-in security feature, lock down the new namespace on day one, or attackers will beat the enforcement date. The toggle came back with @, which makes the new prefix safe to paste, but the habit stands: don’t copy a sub prefix out of a UI without understanding every character in it.

Timeline

All times UTC unless otherwise noted.

  • 2025-02 (background) Tal Skverer discloses the underlying namespace-recycling class to affected vendors, later published as Sub:jugation. Immutable subject claims are GitHub’s response to it.
  • 2026-04-23 ~18:00 GitHub publishes the changelog post.
  • 2026-04-23 20:59 I skim it on the GitHub dashboard before heading outside.
  • 2026-04-23 21:54 An unknown researcher registers github.com/aws-2232217. (Approximate, based on account creation time.)
  • 2026-04-23 22:13 Pre-hijack idea at the park.
  • 2026-04-23 22:18 I reproduce the squat on one of my own orgs. No platform guard rails.
  • 2026-04-23 ~23:00 The changelog post returns HTTP 404. Feature remains enabled in production.
  • 2026-04-24 09:45 EDT Screenshots taken in my victim/attacker lab setup.
  • 2026-04-24 14:02 UTC HackerOne report #3693338 submitted.
  • 2026-04-24 ~15:00 UTC Opt-in feature disappears from the UI and API in production.
  • Subsequent weeks GitHub republishes the changelog with an editor’s note, swaps the hyphen for @, and moves the default-on cutover to July 15, 2026.

Credits and acknowledgements

Credit to Tal Skverer, whose Sub:jugation research is the upstream disclosure of the namespace-recycling class this whole feature was built to close.

Thanks to the Signal group of supply chain security friends who lived this one with me through the afternoon, including the PyPI backend folks whose defensive design choice around repository_owner_id keeps coming up as the reference implementation in this space. (Let me know if you want to be named by handle; happy to update.) Thanks to whoever beat me to aws-2232217 by nineteen minutes; we were obviously thinking the same thing. And, as always, thanks to the GitHub Security team for engaging on the HackerOne submission.