Go workspaces landed in Go 1.18 on 15 March 2022 and they changed how teams organize multi-module repositories. Before workspaces, working on two modules that depended on each other usually meant a pile of replace directives in each go.mod, which would then have to be scrubbed before release. Workspaces gave us go.work, a file that sits at the root of a checkout and tells the toolchain to treat a set of local modules as a single logical unit. It is a lovely feature. It is also, when you look closely, a supply chain concern that most teams have not thought through.
I spent a few weeks auditing workspace configurations across a mix of open source repos and internal codebases, and I came away with a clearer picture of what can go wrong. This is what I learned.
What does go.work actually do?
At its simplest, go.work lists the modules that belong to the workspace with use directives and can declare replace directives that apply across the whole workspace. When you run go build inside a workspace, the toolchain consults go.work first. Any use entry effectively overrides whatever version of that module would have been selected through minimum version selection. Any replace in go.work overrides replace directives in individual go.mod files.
That last sentence is the one to dwell on. A replace in go.work wins over replace in go.mod, and both of them bypass the checksum database. When Go fetches a replaced module, it does not consult sum.golang.org to verify its content against the public transparency log. The trust is local.
Why is this a supply chain concern?
Consider a typical setup. A developer clones a monorepo, opens the workspace, and runs go test ./.... They never look at go.work because it is generated and maintained by tooling. A month later, someone adds a line to go.work:
replace example.com/auth => github.com/someuser/auth-fork v0.0.0-20240501000000-abcdef123456
The auth package now points at a fork controlled by a personal GitHub account. The build succeeds. Tests pass. The checksum database is never consulted. If that fork is compromised or abandoned, every developer and every CI job that uses the workspace pulls from it.
This is not hypothetical. The pattern of dependency hijacking through abandoned forks is well documented. In November 2022, CVE-2022-41723 hit golang.org/x/net/http2 and affected many downstream projects. Teams that had pinned to a fork rather than the upstream had to chase the fix manually. A go.work replacement would have made that even harder to spot.
The go.work.sum file
Go 1.20, released 1 February 2023, added go.work.sum, which records checksums for any dependencies resolved through the workspace that are not already in any member module's go.sum. It is a partial answer. It provides a verifiable record for workspace-only dependencies, and CI can check that it is up to date. But it does not cover replace targets that point at local paths or at forks that bypass the proxy.
If you run go work sync, the toolchain pushes any missing requirements from go.work into member go.mod files. That is useful, but it also means that a change in go.work can propagate silently into many modules during a sync.
How should teams scope go.work in CI?
The Go documentation is clear that go.work is intended for local development. The recommendation is to gitignore it for release builds or use GOWORK=off to disable workspace mode in CI. In practice, many teams commit go.work because it simplifies onboarding and keeps everyone on the same set of module versions. Both approaches have trade offs.
If you commit go.work, treat it as security-relevant config. Every change to use or replace entries should be reviewed with the same care as a go.mod change. Every developer with write access to the repo has effective write access to your dependency selection. If you gitignore it, make sure CI runs with GOWORK=off and that your release builds never accidentally pick up a stray workspace file from a build agent's home directory.
Vendoring and workspaces
Before Go 1.22, released 6 February 2024, workspaces did not support vendoring. You could not run go work vendor and get a single vendor/ tree for the workspace. That meant teams who wanted both workspaces and offline reproducible builds had to choose. Go 1.22 fixed this with the go work vendor command, which writes a workspace vendor directory and a matching modules.txt. This is a big improvement for security, because vendored code is auditable in the repository and not subject to proxy-time substitution.
If you are on Go 1.22 or later and care about supply chain integrity, I would strongly recommend workspace vendoring plus -mod=vendor in CI. The cost is repository size. The benefit is that every dependency is reviewable in PR and cannot change without a commit.
Local path traps
A replace like replace ./internal/tools => ./internal/tools is benign. A replace example.com/foo => ../../../../tmp/foo is not, and I have seen it in the wild. When a workspace is checked out on a shared build system, relative paths that escape the workspace can point at attacker-controlled directories. Go does not prevent this. It is on you to keep the paths sane.
A rule I like: every local path in go.work should be a subdirectory of the workspace root. Anything that uses .. is a red flag. A simple pre-commit hook can enforce that.
Minimum version selection interactions
Workspaces interact with minimum version selection in subtle ways. When you add a use directive, the toolchain effectively promotes the local version of that module to the selected version. If the local module requires a newer version of some transitive dependency than the rest of the workspace, that newer version wins. This can accidentally upgrade modules for other workspace members, and those upgrades do not show up in any single go.mod diff. They appear in go.work.sum and in the resolved build. If you are not watching that file, you can miss real changes.
What about govulncheck?
govulncheck understands workspaces as of v1.1.0, released 4 March 2024. Running it at the workspace root analyzes all member modules together, using the resolved versions. This is how you want to run it, because scanning one module at a time can miss vulnerabilities introduced by workspace-level replacements. Put it in CI, gate merges on it, and you close one of the biggest gaps.
How Safeguard Helps
Safeguard parses go.work, go.work.sum, and every member go.mod to build a unified view of the workspace dependency graph. When a replace directive is added or modified, Safeguard flags the change, looks up the target module's provenance, and checks whether the replacement bypasses the checksum database. Policy gates can require that all replace targets point at approved repositories and block merges that introduce relative paths escaping the workspace. For teams on Go 1.22 or later, Safeguard validates that vendor directories match the workspace-resolved graph so that drift between go.work and vendor/ cannot hide.