# Repojacking

> Repojacking is a supply chain attack where someone registers a renamed or deleted GitHub owner name and recreates a repository that old references still fetch.

Also known as: repo jacking, repository hijacking, GitHub namespace hijacking

Source: https://socket.dev/glossary/repojacking

Last updated: 2026-11-02

Repojacking (also written RepoJacking) is a [supply chain attack](https://socket.dev/glossary/supply-chain-attack) in which an attacker registers a GitHub username or organization that its owner renamed or deleted, then recreates a repository at the old path. Code that still references `old-owner/repo` silently fetches the attacker's code, because GitHub drops its rename redirect as soon as a real repository claims the name.

GitHub documents the gap in plain terms. After a rename, [GitHub's documentation](https://docs.github.com/articles/changing-your-github-username) says, "your old username becomes available for anyone else to claim." The redirect that keeps old links working lasts only until someone takes the name. Repojacking needs no stolen password and no platform bug. The attacker signs up for a free username through the normal registration flow.

Our position is that repojacking is a binding problem, not an account security problem. Every reference that names `owner/repo` and resolves the name at fetch time trusts whoever holds the name today. GitHub's main defense, retiring the names of popular repositories, is a heuristic that researchers have bypassed at least three times since 2021. The references that survive a hijack are bound to something an attacker cannot re-register: a commit SHA, a content hash, or a numeric repository ID.

## What is repojacking?

Repojacking is the takeover of a repository's path, not of the repository itself. The original code, history, and stars stay with the renamed owner. The attacker gets only the string that old references contain.

GitHub identifies every repository by a numeric ID, but people and tools address it as `owner/name`. That path is a lookup, and the lookup is mutable. When an owner renames their account, [GitHub promises](https://docs.github.com/en/account-and-profile/reference/username-reference) that "web links to your existing repositories will continue to work," and that pushes to old remote URLs keep working too. It does this by keeping a redirect from the old path to the moved repository.

GitHub built redirects so that renames would not break the world. Thousands of `go.mod` files, workflows, Composer manifests, READMEs, and install scripts hard-code paths, and nobody updates them all. The redirect hides each stale reference, which is exactly why stale references survive for years.

The old name itself is released. Anyone can register it. Once that new account creates a repository with the same name, GitHub's documentation says "that will override the redirect entry and your redirect will stop working." Consumers get no error. A `git clone`, a fetch of an uncached Go version, or a workflow run simply receives the new repository.

> Diagram in three stages. Stage 1: the path acme/lib resolves to repository ID 1001 owned by acme. Stage 2: acme renames to acme-labs; acme/lib returns a 301 redirect to acme-labs/lib, still repository 1001, and the name acme is free. Stage 3: an attacker registers acme and creates lib; acme/lib now resolves directly to a new repository, ID 9042, and the redirect is gone.

Figure 1 shows three stages of the same reference, `github.com/acme/lib`. In stage 1, before any rename, the path resolves to repository ID 1001, owned by the real acme account. In stage 2, acme renames itself to acme-labs: `acme/lib` now answers with an HTTP 301 redirect to `acme-labs/lib`, which is still repository 1001, and the name acme is free to register. In stage 3, an attacker registers acme and creates a repository named lib: `acme/lib` now resolves directly to a new repository, ID 9042, the redirect is gone, and the real code at repository 1001 is reachable only as `acme-labs/lib`. The consumer's reference is identical in all three stages. The repository IDs are illustrative.

Deleted accounts lead to the same place without a redirect. A reference to a deleted owner returns 404 until someone registers the name. That is how the best-documented repojacking in the wild happened.

## The hautelook/phpass hijack

The hautelook/phpass hijack is the clearest real repojacking on record. In 2021 the hautelook GitHub organization was deleted, taking hautelook/phpass, a PHP password-hashing library, with it. The package's [Packagist](https://socket.dev/glossary/packagist) entry still pointed at `github.com/hautelook/phpass` and was set to auto-update from it. Packagist does not host code for most packages. It indexes tags from a Git repository, and Composer downloads from the repository.

In May 2022 an attacker registered the free hautelook name and recreated phpass at the same URL. According to the [Concrete CMS security advisory](https://www.concretecms.org/about/project-news/security/supply-chain-hack-phpass-repo-jacking), the attacker committed code and tagged a release around 7:00am PST on May 19, 2022, and Packagist began tracking it. The payload kept the library working and added code that "sends two hardcoded AWS environment variables to a heroku app." Packagist administrators deleted the malicious version on May 24, 2022, five days later.

The exposure was narrower than the download count suggested. Concrete CMS found that a system was affected only if it ran `composer update` during the window. Our reading of why: `composer install` from an existing `composer.lock` requests one specific commit, and a freshly created repository does not contain it. The lockfile failed closed. Only resolution, the step that turns a name and a version range into a commit, consulted the hijacked name.

Packagist's metadata for the package shows how the incident was cleaned up and what Composer actually pins. We fetched it on November 2, 2026, trimmed to the fields that matter:

```json
{
  "package": {
    "name": "hautelook/phpass",
    "repository": "https://github.com/bordoni/phpass",
    "abandoned": "bordoni/phpass",
    "downloads": { "total": 2629396 },
    "versions": {
      "0.3.6": {
        "source": {
          "url": "https://github.com/bordoni/phpass.git",
          "reference": "12f8f5cc03ebb7efd69554f104afe9aa1aa46e1a"
        },
        "dist": {
          "url": "https://api.github.com/repos/bordoni/phpass/zipball/12f8f5cc03ebb7efd69554f104afe9aa1aa46e1a",
          "shasum": ""
        }
      }
    }
  }
}
```

Two details matter. First, the package is marked abandoned and its source now points at a maintained fork, bordoni/phpass, so no new resolution touches the hautelook name. Second, `dist.shasum` is empty. For GitHub-hosted Composer packages, the only integrity anchor is the Git commit in `reference`. That commit is what protected lockfile users in 2022, and it is the only thing that would protect them again.

## Repojacking compared with starjacking, account takeover, and typosquatting

Repojacking is easiest to pin down next to the attacks it gets confused with. Each one abuses a different link between a name and code, and each needs a different control.

[Maintainer account takeover](https://socket.dev/glossary/maintainer-account-takeover) gives the attacker the legitimate account, with its original repository, history, and publishing rights. Repojacking gets only the name, after the legitimate owner has walked away from it. Multi-factor authentication stops many account takeovers and does nothing against repojacking, because no account is compromised.

[Starjacking](https://socket.dev/glossary/starjacking) abuses the link in the other direction. A malicious package lists a popular, unrelated GitHub repository in its metadata to borrow that project's stars and credibility. Unless the package carries verifiable provenance, nothing ties that field to the code. Repojacking changes what a real reference resolves to. Starjacking makes a fake package claim a real repository.

[Typosquatting](https://socket.dev/glossary/typosquatting) registers a name that looks like the real one. Repojacking registers the real one, after it was released. Both end the same way for the victim: a correctly typed install command that fetches attacker code.

## Why does GitHub's namespace retirement not stop repojacking?

GitHub's namespace retirement blocks re-creation of an old `owner/repo` only when the repository hosted a Marketplace action or looked popular in the week before the rename. As of November 2026, [GitHub's username reference](https://docs.github.com/en/account-and-profile/reference/username-reference) says it permanently retires the old owner and repository name combination for public repositories with a Marketplace action, or with "more than 100 clones or more than 100 uses of GitHub Actions in the week prior to you renaming your account." Public container images with more than 5,000 downloads get the same treatment. An attempt to reuse a retired path fails with this message:

```text
The repository <REPOSITORY_NAME> has been retired and cannot be reused.
```

The heuristic has four structural gaps.

The measurement window is one week. A stable, vendored, or cached library draws few clones even when thousands of projects depend on it. [VulnCheck found](https://www.vulncheck.com/blog/go-repojacking) a Go repository with more than 170 stars that had only 20 clones in a week.

Clones are the wrong signal for cached ecosystems. Go consumers download from the module mirror, not from GitHub, so the clone counter barely moves. In September 2023, [Palo Alto Networks showed](https://www.paloaltonetworks.com/blog/cloud-security/github-actions-worm-dependencies/) that GitHub Actions fetched action code as a zip through the API, "bypassing the clone count." GitHub's documentation now counts Actions uses as well, which closes that specific gap and no other.

Retirement is invisible. [Checkmarx noted in 2022](https://checkmarx.com/blog/attacking-the-software-supply-chain-with-a-simple-rename/) that GitHub applies the rule from internal metrics and does not tell owners whether a path is protected. A February 2026 study of JitPack coordinates by [ITRES Labs](https://labs.itresit.es/2026/02/18/supply-chain-necromancy-reborn-namespaces-in-jitpack-coordinates/) found the protection applied unevenly. One abandoned username was claimable while its repository name was blocked, and other old names could not be registered at all.

The check has been bypassed repeatedly. Checkmarx reported a first bypass in November 2021 and a repository-transfer bypass that GitHub fixed on September 19, 2022. In 2023 Checkmarx found a [race condition](https://checkmarx.com/blog/persistent-threat-new-exploit-puts-thousands-of-github-repositories-and-millions-of-users-at-risk/) between repository creation and username renaming, which GitHub fixed on September 1, 2023. Every bypass was fixed, and each showed that a name-reuse rule must hold across every code path that creates, renames, or transfers a repository.

The researchers who measured the problem reached the same conclusion we do. [Aqua Security's Team Nautilus wrote](https://www.aquasec.com/blog/github-dataset-research-reveals-millions-potentially-vulnerable-to-repojacking/) that "organizations should not depend on the retired names as a security mechanism."

## Which dependencies are exposed to repojacking?

A dependency is exposed to repojacking when it resolves an `owner/repo` name at fetch time and nothing checks the bytes against a hash recorded earlier. Registry-hosted packages are mostly not exposed. An npm tarball or a PyPI wheel lives on the registry, and the GitHub link in its metadata does not decide what gets installed. The exposure sits wherever a manifest, workflow, or script names GitHub directly.

Measured exposure is large. In June 2023, [Aqua Security sampled](https://www.aquasec.com/blog/github-dataset-research-reveals-millions-potentially-vulnerable-to-repojacking/) 1.25 million repository names from one month of 2019 GitHub event logs. Of those, 36,983, or about 2.95%, redirected to a repository whose original owner name was unclaimed. Hits included a Google README telling users to clone from an old organization and a Lyft install script that downloaded from a redirected repository. In December 2023, VulnCheck counted more than 9,000 Go module repositories exposed by username changes and more than 6,000 by deleted accounts, together backing more than 800,000 module versions.

| Reference | Example | When the name is resolved | What pins the content | Verdict |
| --- | --- | --- | --- | --- |
| GitHub Actions, tag | uses: acme/lib@v1 | Every workflow run | Nothing; tags move | Exposed on every run |
| GitHub Actions, full SHA | uses: acme/lib@\<40-char SHA\> | Every workflow run | The commit SHA | Fails closed: the new repo lacks the commit |
| Composer via Packagist | "hautelook/phpass": "^0.3" | Every composer update | composer.lock commit reference | Exposed on update; lockfile installs fail closed |
| Go module | require github.com/acme/lib v1.2.3 | First fetch of each version | Module mirror cache and checksum database | Existing versions safe; new versions enter only on upgrade |
| npm Git dependency | "lib": "github:acme/lib" | Every fresh resolution | Lockfile commit SHA, root project only | Blocked by default in npm v12; exposed on npm 10 and 11 |
| JitPack coordinate | com.github.acme:lib:1.+ | First build of each version | JitPack build store, frozen after about 7 days | Exposed for snapshots, dynamic versions, failed builds |
| Docs and scripts | git clone https://github.com/acme/lib in a README or install.sh | Every execution | Nothing | Fully exposed |
| Registry package | "lib": "^1.2.0" from the npm registry | Not applicable | Registry immutability | Not exposed to repojacking |

Verdict: any reference that resolves a GitHub name at run time and checks nothing should be treated as attacker-controlled from the day its owner renames. Pin GitHub Actions and Git dependencies to full commit SHAs. Leave Go's checksum database on. Mark orphaned Composer packages abandoned and repoint them. Never pipe a branch URL into a shell.

### Go modules

A Go module path is a repository path, so `github.com/acme/lib` names a GitHub location directly. Go's module mirror caches each version on first fetch, and the checksum database records its hash permanently. A repojacker therefore cannot change v1.2.3 for anyone using Go's defaults. With `GOPROXY=direct`, a hijacked v1.2.3 would hash differently from the value in `go.sum` or the checksum database, and the build would stop.

A repojacker can publish new versions. VulnCheck demonstrated the full chain in December 2023: register the abandoned name, recreate the repository, tag a new version, and ask the mirror to cache it, after which pkg.go.dev lists it as an update. Under minimal version selection, described in our [Go modules](https://socket.dev/glossary/go-modules) entry, that version reaches a build only when someone runs `go get -u` or merges an update bot's pull request.

The same immutability cuts the other way. In the [boltdb-go/bolt typosquat](https://socket.dev/blog/malicious-package-exploits-go-module-proxy-caching-for-persistence) we reported in February 2025, the mirror kept serving a backdoored version for more than three years after its author rewrote the Git tag to clean code. The cache preserves whatever it saw first.

### GitHub Actions

A `uses: owner/repo@v1` line resolves the name and the tag on every run, with the job's secrets in the environment. That makes [GitHub Actions security](https://socket.dev/glossary/github-actions-security) the most direct path from repojacking to credential theft. Palo Alto Networks found that papeloto/action-zip had moved to the vimtor organization, which left papeloto free to register. The action had about 600 dependents, including a workflow in Hangfire, whose NuGet package drew about 9,400 daily downloads. The researchers registered papeloto themselves to block abuse.

Pinning to a full commit SHA turns a hijack into a broken build, because the attacker's new repository does not contain the pinned commit. GitHub's [workflow dependency lockfiles](https://docs.github.com/en/actions/concepts/security/workflow-dependency-lockfiles), in public preview since October 14, 2026, go further. The `.github/workflows/actions.lock` file records each action's commit plus a numeric `owner_id` and `repo_id`, and GitHub says Actions "uses these to confirm at run time that the repository behind the name is still the one you locked." That is the first GitHub control we know of that binds a consumer to a repository's identity instead of its name. As of November 2026, GitHub does not document what a mismatch does at run time, and the lockfile does not cover dependencies of reusable workflow calls.

Socket's [GitHub Actions scanning](https://socket.dev/blog/introducing-github-actions-scanning-support), launched as an experimental feature for Business and Enterprise customers in October 2025, takes the same view of tags. It scans the specific commit a tag resolves to at scan time, and flags the change if the tag later points somewhere else. A repojacked action shows up as exactly that kind of change, but only on the next scan, so a workflow that runs between scans is not covered.

### JitPack and Git dependencies

JitPack builds Maven artifacts straight from GitHub, so a coordinate like `com.github.User:Repo:Tag` is a GitHub path. ITRES Labs reported that JitPack treats public builds as immutable after about seven days, but snapshots, dynamic selectors like `1.+`, failed builds, and tags that were never built stay open to a new owner of the name. In a deliberately biased sample of 500 public `build.gradle` files, 20, or 4%, referenced at least one coordinate with an unstable upstream namespace. The busiest, `com.github.lzyzsd:jsbridge`, drew about 8,500 requests in a month and had a failed v1.0.4 build.

npm [Git dependencies](https://socket.dev/glossary/git-dependencies) are the same pattern in JavaScript. A `github:owner/repo` entry is a name lookup at install time, and a dependency's own lockfile never reaches the people who install it. [npm v12](https://socket.dev/blog/npm-12), released July 8, 2026, refuses Git dependencies, direct or transitive, unless `--allow-git` permits them. Clients on npm 10 and 11 still resolve them by default.

## Finding claimable references in your code

You can find repojacking exposure by resolving every GitHub path your code references and asking two questions. Does the path redirect or 404? Is the original owner name free? A yes to both means anyone can claim the name today. Aqua Security and VulnCheck ran essentially this test at scale.

```bash
#!/usr/bin/env bash
# List GitHub owner/repo references whose original owner name is free to register.
# Needs an authenticated gh CLI and ripgrep. Run from the root of a checkout.
set -uo pipefail

rg -o -N --no-filename --hidden -g '!.git' \
  '(?:github\.com[/:]|uses:\s*)([A-Za-z0-9-]+)/([A-Za-z0-9._-]+)' -r '$1/$2' . \
  | sed -E 's/\.git$//' | sort -fu |
while read -r ref; do
  owner=${ref%%/*}
  if canon=$(gh api "repos/$ref" --jq .full_name 2>/dev/null); then
    [ "${canon,,}" = "${ref,,}" ] && continue   # resolves directly, no rename
    state="redirects to $canon"
  else
    state="repository not found"
  fi
  if ! gh api "users/$owner" --silent 2>/dev/null; then
    echo "CLAIMABLE  $ref  ($state; owner \"$owner\" does not exist)"
  fi
done
```

The script relies on two API behaviors. `gh api` follows GitHub's redirect for a renamed repository, so `full_name` returns the new path. The `users/` endpoint answers for organizations as well as people, and a 404 there means the name is unregistered. Note the `--hidden` flag: ripgrep skips dot-directories by default, and `.github/workflows` is where the riskiest references live. Each hit prints one line, for a renamed owner or a deleted one:

```text
CLAIMABLE  acme/lib  (redirects to acme-labs/lib; owner "acme" does not exist)
CLAIMABLE  oldorg/tool  (repository not found; owner "oldorg" does not exist)
```

We tested the extraction and decision logic on November 2, 2026 against a fixture checkout and a stubbed `gh`. Neither historical case in this entry would show up today: the Palo Alto Networks researchers registered papeloto, and Packagist now resolves hautelook/phpass through bordoni/phpass. Expect real hits to be obscure names in old workflows, READMEs, and build files.

The script finds names an attacker could claim today. It cannot find a hijack that already happened, because a hijacked path resolves directly, with no redirect, to a repository that exists. Detecting that needs a record of what the path resolved to before: a commit SHA in a lockfile, a hash in `go.sum`, or a stored repository ID. Comparing a stored ID with the output of `gh api repos/OWNER/REPO --jq .id` is the cheapest drift check we know.

For npm, Socket's [GitHub dependency and Git dependency alerts](https://socket.dev/alerts) fire on any manifest entry that resolves code from a Git host instead of the registry, which is the precondition for repojacking in that ecosystem. The alert tells you a package reaches outside the registry. It cannot tell you who will own that name tomorrow.

## How do you prevent repojacking?

You prevent repojacking by binding each reference to content or identity instead of a name. Consumers and maintainers own different halves of that work, and every control has a cost.

> Diagram: what a hijack of acme/lib does to three kinds of GitHub Actions reference. A tag reference, acme/lib@v1, resolves to the attacker's commit and runs it with the job's secrets. A full commit SHA reference fails because the attacker's new repository does not contain that commit. An actions.lock entry records repo\_id 1001, while the name now resolves to repository 9042, so the mismatch is visible to GitHub Actions before the step runs.

Figure 2 compares three GitHub Actions references after `acme/lib` has been hijacked and now resolves to the attacker's repository, ID 9042. A tag reference, `uses: acme/lib@v1`, looks the tag up in whatever repository holds the name, so the step runs a tag the attacker created, with the job's secrets. A full commit SHA reference copied from the original repository fails, because the attacker's repository does not contain that commit, so the job errors out instead of running attacker code. An `actions.lock` entry records the commit, `owner_id`, and `repo_id` 1001; the name now resolves to repository 9042, and GitHub says it checks those IDs at run time. The IDs and SHA in the figure are illustrative.

Consumers should do four things:

- Pin full 40-character commit SHAs in workflows and Git dependencies. A hijack becomes a failed build instead of code execution. The cost is that updates become manual, so pair pins with an update bot. A SHA names content, not authorship: the [TanStack compromise](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) ran from an orphan commit that resolved under tanstack/router even though TanStack never wrote it.
- Keep proxies and lockfiles in the path. Go's default `GOPROXY` and `GOSUMDB` make existing versions immutable, and a broad `GOPRIVATE` pattern quietly turns that off. A committed [lockfile](https://socket.dev/glossary/lockfile) or an internal artifact repository gives other ecosystems the same property. The cost is that a cache preserves whatever it saw first, malware included.
- Refuse transitive Git dependencies. npm v12's defaults do this for upgraded clients. The cost is that legitimate Git dependencies break until their authors publish to a registry.
- Adopt `actions.lock` where you can accept a preview feature whose format may still change.

Maintainers should never let a referenced name go free:

- Transfer repositories to an existing account rather than renaming the account that owns them.
- After renaming an account, register the old name again as a placeholder. Aqua Security gives the same advice.
- Archive repositories instead of deleting an organization that published packages. An archived repository keeps its name owned.
- Update registry metadata to the new path. Repoint the Packagist URL, and mark an orphaned package abandoned. For Go, a module path change is a breaking change, so keep the old name owned for as long as anyone imports it.

None of these controls is complete. SHA pins and checksums protect the versions you already have. They say nothing about the next version someone chooses to adopt, and that choice is where a repojacker in Go or Composer waits.

## The redirect is the vulnerability

A rename redirect is more dangerous than a 404. A 404 breaks consumers on day one and forces them to fix the reference. A redirect keeps the stale reference working indefinitely, then hands it to whoever registers the name next, with no signal to anyone downstream. Deletion is only slightly better: the broken hautelook/phpass reference sat in Packagist's configuration for months until an attacker gave it something to resolve to.

GitHub chose availability over integrity for every renamed repository, and namespace retirement patches that choice only for repositories its one-week counters happen to notice. We think the default should flip: an old path should stay retired whenever any public repository, workflow, or package still depends on it, not only when it crosses 100 clones in a week. The cost is that some names become unusable forever, and we think that is a fair price for integrity. Until that happens, assume every `owner/repo` string in your build belongs to whoever registered that owner most recently.
