Key Takeaways
- Go identifies a package by where it lives rather than by a name someone owns, so a lapsed GitHub username can turn into a supply chain problem on its own.
- The checksum database guarantees everyone gets the same code for a given version, not that the version came from someone you trust.
- GitHub retires popular namespaces, but downloads served through Go’s module mirror do not register as the clones that trigger that protection.
- The paths that need auditing are the small transitive ones nobody reads.
In December 2023, researchers at VulnCheck went looking for Go repositories on GitHub sitting in a reclaimable state, and the count came back higher than most people would guess. Over 15,000. More than 9,000 were exposed by a GitHub username change, and another 6,000-plus by account deletion. Together, those repositories back more than 800,000 Go module versions.
None of that requires stolen credentials or a compromised build pipeline. It just requires an account to lapse and somebody to notice before the original owner does.
That count is a couple of years old now, and nobody has published a fresh one. The mechanism behind it has not changed, though, and the reason it works in Go and almost nowhere else comes down to how Go decides what a package even is.
Why Go Package Identity Is a Location Rather Than a Name
Most major registries keep an ownership record. npm knows who is allowed to publish axios. PyPI knows who can push a new requests release. Maven Central verifies a namespace before anyone gets to claim a groupId under it.
Go has a path that doubles as an address.
github.com/someuser/somelib is not a name registered with a Go registry. It is a module path that tells the toolchain where to look, and whoever controls that location controls what versions can be served from it next. When the account behind it disappears and someone else claims the username, the import path in your go.mod now points at their code.
This is a different animal from the account-compromise campaigns making headlines lately, like PolinRider. That campaign reached Go modules in 2026 as well, but the mechanism is not the same. Those attackers took over accounts that were still active, and in several cases rewrote Git tags to hide what they had done. Repojacking is quieter, and in some ways easier, because nothing needs to be broken into. Once an account is gone, there is nothing left to defend.
Go’s module resolution is elegant, and that is worth saying before getting into what is wrong with it. go get github.com/user/lib resolves through the module proxy by default, records a version, and writes a hash. No account, no registry submission, no publishing pipeline. Push a properly named version tag and the version exists, with no separate publish step in between.
That works because the import path is the source of truth. The path tells the toolchain where to look, and no second system records who is allowed to put code there.
Compare what each registry actually knows about a package.
| Registry | Identity is | Ownership record | Reclaimable after abandonment |
| npm | A registered name | Yes, account-linked | No, names stay reserved |
| PyPI | A registered name | Yes, account-linked | Only through a formal process |
| Maven Central | A namespace tied to a verified domain or source repository | Yes, namespace verified | Yes, if the domain lapses |
| crates.io | A registered name | Yes, account-linked | No, names stay reserved |
| Packagist | A registered name | Yes, account-linked | No, names stay reserved |
| Go modules | A VCS location | None | Yes, whenever the location frees up |
Maven has its own version of this problem through expired domains, which is what the MavenGate research described back in January 2024. Group IDs are derived from domains, so a domain that lapses hands over the namespace attached to it.
Packagist has a documented case too. In a 2022 incident, a researcher repojacked the GitHub repo behind a Packagist package and got malware published, because Packagist was crawling GitHub for releases without a separate publish step. That particular door has since been closed, and after the Laravel-Lang tag-rewriting incident in May 2026, Packagist went further and made stable versions immutable, announced in May and switched on in July. Worth being clear about what that fixes, though. Immutable versions stop someone from moving a published version’s Git reference. They do nothing about someone claiming an abandoned namespace and publishing something new under it.
For Go, that second scenario is the default condition for any module hosted under a personal GitHub account. GitHub’s own security team named Go as one of three ways builds can pull code straight from a repository in its 2024 post on repojacking, alongside GitHub Actions and Git submodules, and its advice for those two is to pin to a specific commit. Go has an integrity layer they do not, since module versions get authenticated against go.sum and, by default, against the public checksum database. Which is where this gets more interesting than it first looks.
There is a slower-burning version of this risk as well. A large share of the open source components sitting somewhere in production code have not seen a commit in years, and dormant projects are exactly the ones whose maintainers eventually delete an account or let a domain lapse. In most ecosystems that is a patching problem. In Go it is also a takeover surface.
How Repojacking Works Against an Import Path
Two account-level events free up a GitHub path. Deleting an account releases the username, and changing a username releases the old one. Either way, github.com/olduser/lib is left unclaimed, and whoever registers that username next can recreate a repository under the matching name and own the path. Deleting a single repository under an account that still exists does not do this, since nobody else can create a repository inside your account.
GitHub does defend against this, and the defense is narrower than most people assume. Namespace retirement permanently locks an owner-and-repository-name combination so it cannot be reused, and it covers repositories that had more than 100 clones or more than 100 GitHub Actions uses in the week before the rename or deletion, plus anything containing an action listed on GitHub Marketplace.
Which means the protection covers the packages everyone already worries about and leaves the long tail open.
For Go specifically, that long tail turns out to be enormous, and the same research that found those 15,000-plus repositories flagged why. Go’s module mirror fetches a repository once and then serves that copy to everyone else. A module can sit behind thousands of go get calls without those proxy-served downloads registering as clones on GitHub, so a heavily used library can still sit under the clone threshold that would otherwise protect its namespace. For an ordinary Go library, which is not a GitHub Action and not on the Marketplace, clone count badly understates downstream use.
A utility library with 40 clones a week, imported transitively into a few hundred projects, is well under the bar regardless. Those are the paths that actually need auditing, and they are also the paths nobody reads, because they arrived four levels deep in someone else’s dependency tree.
Retirement is evaluated at the moment the account is renamed or deleted, not continuously. A repository that was popular two years ago and quiet since may not have met the threshold in the week that mattered. There is no API for checking and no public list, so the only way to find out is to try creating the repository and see whether GitHub replies that the name has been retired and cannot be reused.
Why the Checksum Database Cannot Vouch for a New Version
Go’s checksum infrastructure is genuinely good, better than what most ecosystems have. Knowing exactly what it guarantees, and where the guarantee stops, is the difference between feeling covered and being covered.
sum.golang.org keeps an append-only, publicly auditable record of module hashes. When a build resolves a public module version that is not already in go.sum, the toolchain looks that hash up in the log before writing it locally, so a first download is not going unverified. Later builds compare against what is already on file, and go mod verify checks that the copies in your local module cache have not been modified since they were downloaded.
All of that machinery is very good at one job. It makes sure everyone who asks for example.com/[email protected] gets identical bytes, today and in five years. Republish different content at a version that has already been recorded and the mismatch surfaces loudly.
Repojacking never touches a recorded version. The attacker publishes a brand-new version, or a whole new path, and the checksum database does exactly what it was built to do. For a genuinely new version, the content the attacker serves can become the recorded content for that module and version, authenticated faithfully for everyone downstream from then on. The hash is real. The log entry is real. Nothing in either one says the code came from the maintainer you originally trusted.
| Scenario | Prior hash for that version | Takeover detected |
| Recorded version republished with new content | Yes | Yes, checksum mismatch |
| New minor version of a hijacked path | No, brand-new version | No |
| New dependency added after a takeover | No | No |
| Transitive dependency bumped past the takeover point | No, brand-new version | No |
Bump a dependency or add a new one, and the toolchain can prove the bytes are the same bytes everyone else is getting. That is not the same as proving they belong in your build.
The module proxy at proxy.golang.org helps here, within limits. The mirror keeps versions it has already fetched and can keep serving them after they vanish upstream, so a version pulled before a takeover stays available and unchanged even if the repository is later deleted or replaced. That is a real security property. It only applies to versions already cached before the takeover, and it has nothing to say about the next one.
What is left is the question no checksum answers, which is whether the content showing up at a first resolution deserves trust at all. Screening a module before it resolves, and rebuilding it from source to compare against what the path served, is a different control operating earlier in the sequence. That is the layer SecureChain for Go adds in front of the proxy.
Vanity Import Paths Open a Second Route
Repojacking against GitHub usernames is the version that got a name and some research behind it. The version that gets far less attention involves custom domains.
Go supports vanity import paths through HTML meta tags. Resolving example.com/mylib sends the toolchain to https://example.com/mylib?go-get=1, which returns a go-import meta tag naming the real VCS location, and the toolchain follows it. That is how golang.org/x/… works, and how a company publishes modules under its own domain.
The failure mode writes itself, mostly because it does not need an attacker to be clever. If the domain expires, whoever registers it next controls every module path underneath it, and they get to point those paths anywhere they want. There is no clone-count threshold here, no namespace retirement, no GitHub in the loop at all.
Small consultancies, defunct startups, one-person open source projects – that is the exposure. A lapsed domain is also cheaper and quieter to acquire than a GitHub username with any history attached to it.
go list -m -f ‘{{.Path}}’ all \
| grep -v ‘^github.com/\|^golang.org/\|^gopkg.in/’ \
| cut -d/ -f1 | sort -u
That gives you every non-obvious host your build resolves from. Work out who controls each of those domains and keep an eye on the registration status, because a domain nearing expiry, or one that simply looks abandoned, is a dependency with a deadline attached.
Auditing Your Module Paths for Takeover Risk
Two checks, both quick. Run both against every Go repository your org has, not just the flagship service everyone already watches.
Start by flagging GitHub repository URLs that no longer return a live-repository response.
go list -m -f ‘{{.Path}}’ all | grep ‘^github.com/’ \
| cut -d/ -f1-3 | sort -u \
| while read p; do
printf ‘%s %s\n’ “$(curl -s -o /dev/null -w ‘%{http_code}’ “https://$p”)” “$p”
done | grep -v ‘^200’
A 404 is a high-priority signal that the path is no longer publicly available – worth investigating before treating it as confirmed repojacking risk, since it can also mean the repository went private. A 301 usually means a rename or an account move, and the check deliberately does not follow redirects so that status is what you see. Once an attacker reclaims a path, the check goes back to 200, so this catches the window before a takeover rather than after one.
Then look at what has fallen behind among the paths that do resolve.
go list -m -u all | grep ‘\[‘
That flags modules with newer versions available, which is a starting point rather than an answer. A module several versions back while upstream keeps releasing is worth cross-referencing against the repository’s actual activity.
Put the 404 check in CI on a weekly cadence rather than treating it as a one-off audit. An account that owns one of those repositories today can be deleted or renamed next month, and the window before someone claims the old username is the free warning you get.
What to actually do about a module whose upstream is gone for good is a harder question than finding it. Vendoring the code means your team now owns its CVEs, and most organizations that choose that path do not staff for it. Backporting fixes to code the upstream maintainer walked away from is a narrow discipline built around regression testing and behavioral compatibility rather than new features, which is usually why it ends up outside a product team’s remit.
What to Change in Your Go Builds
Go has no npm-style lifecycle hook, no postinstall equivalent, so pulling a dependency down does not hand an attacker a free execution point. That is a genuine advantage over npm and PyPI.
The advantage has a boundary, though. Code in a Go module still runs eventually, whenever something that imports it gets built, tested, or executed, commonly through an init() function firing at startup. The automatic install-time trigger disappears. The code path does not, which is a different problem rather than a smaller one.
Where to start depends on where you already are. If module paths have never been audited, run the 404 check across every repository this week, because an hour of work produces a concrete list to work from. If your dependencies include modules under personal GitHub accounts, vendor them with go mod vendor and commit the result, which puts reviewed dependency contents in your own repository so builds using the vendor directory never fetch those versions from a path that could change hands.
If you resolve modules from custom domains, check registration status on each one, since a lapsed vanity domain is a broader takeover than a single username and has no protective threshold at all. If you use GOPRIVATE or GONOSUMDB, audit the patterns carefully. GONOSUMDB excludes matching modules from checksum-database verification, and GOPRIVATE acts as the default for that exclusion unless you override it, so a wildcard broader than intended quietly removes checksum-database verification from more modules than you meant to exempt. And if Go sits alongside npm, PyPI, Maven, or crates.io in your organization, decide the policy once across all of them, because attackers work across ecosystems and securing one well just moves the problem somewhere else.
None of this is a one-time fix. A module that is safe today because its maintainer is still active is not guaranteed to stay that way.
Exposure depends heavily on how much of your tree lives under organizational accounts versus personal ones. A Go service depending mostly on golang.org/x, major cloud SDKs, and CNCF projects will generally have less of this particular problem. A service that pulled in six single-maintainer utility libraries back in 2019 has real work to do, and running the audit is how you find out which dependencies need attention.
Where SecureChain for Go Fits
The checks above do not stop a takeover from happening. They tell you where you are exposed, which is useful and also not the same thing as prevention. The gap that is left is deciding whether an authenticated new module version deserves trust at all, rather than only confirming that everyone received the same bytes, and that is what SecureChain for Go is built to handle. It rebuilds modules from source, screens them at fetch time, and keeps patching known vulnerabilities after a module’s upstream has gone quiet. The product page has the details, and TuxCare’s newsletter is there if you would rather keep an eye on Go-specific supply chain research as it comes out.
FAQ
What is repojacking?
Claiming a source repository path that a project still depends on, usually after the original account was deleted or renamed. Because the path is still referenced in dependency files, code published at the reclaimed location resolves into builds that trusted the original.
Why is Go especially exposed to repojacking?
Go identifies modules by their VCS path rather than by a registered name with an ownership record. There is no registry account holding github.com/user/lib, so when the underlying account name becomes available, whoever claims it controls the module path.
Does the Go checksum database prevent repojacking?
Not on its own. It guarantees that everyone downloading a given module version gets identical content, including on a first download, when the hash is looked up in the public log. What it cannot establish is whether a brand-new version published from a hijacked path is trustworthy, because a genuinely new version published from that path can become the recorded content everyone downstream then authenticates against.
Does GitHub prevent repository namespace takeover?
Partially, and mainly for popular repositories. Namespace retirement locks an owner-and-repository-name combination when the repository had more than 100 clones or more than 100 GitHub Actions uses in the preceding week, or contains a Marketplace-listed action. Smaller projects fall below that bar, and their namespaces stay claimable. For Go the clone criterion is weaker still, since the module mirror can serve thousands of downloads without generating clones.
How do I find potentially reclaimable Go dependencies?
List module paths with go list -m -f ‘{{.Path}}’ all, then check whether each repository URL still returns a live response. A 404 on a path your build depends on needs further investigation, since it usually means the upstream repository is gone but can also mean it went private. Either way, that path is worth checking before you assume it is safe, and it is worth checking on a schedule rather than once.
Does vendoring protect against repojacking?
Yes, for dependencies already committed to your vendor directory. Vendoring copies dependency contents into your repository, and Go’s vendor mode does not touch the network at build time. It does not protect new dependencies or version bumps, which still resolve from the path as normal.
Do Go modules run code during installation?
Go modules have no dependency lifecycle scripts comparable to npm’s install-time hooks. Malicious code in a Go module runs when something that imports it runs, whether that is a test, a build-time tool, or a production binary, commonly through an init() function that fires during startup.



