Go module security rests on three mechanisms: the sum.golang.org checksum transparency log, which cryptographically pins every public module version; the GOPROXY chain, which controls where module source is fetched; and the GOPRIVATE family of variables, which carve private modules out of both. Get all three right and a tampered module fails the build before it compiles. Get one wrong and you either leak internal module paths to Google-operated infrastructure or silently disable checksum verification for everything. Most teams I've audited have at least one of them misconfigured.
What sum.golang.org actually guarantees
The checksum database is an append-only transparency log (built on Trillian, the same machinery behind Certificate Transparency). The first time anyone in the world fetches github.com/spf13/cobra v1.8.1, the log records its hash. Every subsequent fetch — by you, by CI, by a stranger — must match that hash or go refuses to proceed with a checksum mismatch error.
This kills a specific attack: a module author or a compromised origin serving different code to different people for the same version. It's why "the maintainer force-pushed the tag" breaks builds in Go instead of silently shipping new code, which is exactly what you want.
What it does not guarantee: that the code is benign. A malicious version published normally gets logged like any other version, and sum.golang.org will happily attest that everyone receives the same malware. Checksum verification is an integrity control, not a vetting control — you still need vulnerability and malware scanning on top, which is where SCA tooling earns its keep. We've written before about how real Go module compromises played out, and none of them were stopped by the sumdb alone.
go.sum pins hashes, go.mod pins versions
People conflate these constantly. go.mod plus minimal version selection determines which versions you build with — Go has no floating ranges at resolution time, which already removes a class of surprise upgrades that npm and pip users live with. go.sum records the h1: dirhash of every module (and its go.mod) that the build has ever needed, so a later fetch of the same version can't differ.
Two commands worth wiring into CI:
go mod verify # re-hashes the module cache against go.sum
go build -mod=readonly ./...
-mod=readonly has been the default since Go 1.16, but pass it explicitly anyway so a stray GOFLAGS=-mod=mod in someone's environment can't let CI mutate go.mod mid-build. If you vendor, go mod vendor plus -mod=vendor gives you a fully hermetic build at the cost of a noisier repo. Teams under strict change-control requirements often prefer it precisely because every dependency change shows up in review as a literal diff.
GOPROXY ordering: comma versus pipe
The default is:
GOPROXY="https://proxy.golang.org,direct"
The separator matters more than most people realize. A comma means "fall back only on HTTP 404 or 410" — if proxy.golang.org returns a 500 or times out, the build fails rather than falling through. A pipe (|) means "fall back on any error," including network failure. That sounds friendlier, but it means an attacker who can degrade your connection to the proxy can push resolution to direct — straight to the origin VCS, where the sumdb still protects known versions but you've lost the proxy's immutability cache.
If you run an internal proxy (Athens is the common self-hosted choice, Artifactory if you're already paying for it), chain it deliberately:
go env -w GOPROXY=https://goproxy.internal.example.com,https://proxy.golang.org,direct
One operational note: proxy.golang.org caches module versions effectively forever. That's a feature — deleted or force-pushed upstream repos keep building — but it also means "we removed the bad version from GitHub" does not remove it from anyone's dependency graph.
Private modules without leaking their names
Here's the failure mode nobody notices until a security review: without GOPRIVATE, a go get of git.internal.example.com/payments/ledger asks proxy.golang.org for it first. The fetch fails, but you've just disclosed an internal module path — team names, project names, sometimes unreleased product names — to a third party's logs, on every build, forever.
The fix is one line:
go env -w GOPRIVATE=git.internal.example.com/*,github.com/yourorg/*
GOPRIVATE sets the defaults for both GONOPROXY (skip the proxy, fetch direct) and GONOSUMDB (skip checksum-database verification, since sum.golang.org has never seen your private code and never should). If you want private modules to go through your internal Athens instance but still skip the public sumdb, set GONOPROXY and GONOSUMDB separately instead of using the GOPRIVATE shorthand.
Two adjacent risks worth closing while you're in there. First, vanity import paths (go.example.com/pkg) depend on your control of that domain and its meta tags — a lapsed domain is a module takeover. Second, set GOVCS if you want to forbid direct fetches over anything but git and https:
go env -w GOVCS=*:git
Malicious versions still need a scanner
Because the sumdb only proves consistency, you need something watching content. govulncheck ./... is the floor — it's free, it's symbol-aware, so it only flags vulnerable functions you actually reach, and it runs in a few seconds on most repos. Layer a full SCA scan on top for malware detection, license checks, and transitive-dependency policy; the Go ecosystem's flat module cache makes it one of the easier ecosystems to scan accurately. Safeguard's Go scanning, for what it's worth, reads go.mod and go.sum together so it flags hash drift as well as known-bad versions — but whatever tool you use, run it on pull requests, not nightly. A nightly scan tells you about the malware you merged eight hours ago.
A hardening checklist that fits in one file
Everything above condenses into a setup you can commit to a repo's CI config:
| Control | Setting |
|---|---|
| Proxy chain | GOPROXY=<internal>,https://proxy.golang.org,direct |
| Private carve-out | GOPRIVATE=git.internal.example.com/* |
| No silent go.mod edits | -mod=readonly explicit in build commands |
| Cache integrity | go mod verify in CI |
| VCS restriction | GOVCS=*:git |
| Vulnerability gate | govulncheck ./... on every PR |
None of these require new infrastructure. The whole table is maybe twenty minutes of work, most of which is arguing about the internal proxy hostname.
Frequently asked questions
Does the Go checksum database protect against malicious packages?
No. It guarantees that every consumer of a given module version receives byte-identical code, which stops tampering and stealth re-tagging. A malicious version published through normal channels is logged and served like any other — you need vulnerability and malware scanning for that.
Should GOFLAGS or CI set -mod=readonly if it's already the default?
Yes, set it explicitly in CI. The default can be overridden by an ambient GOFLAGS environment variable or a stray flag in a Makefile, and readonly mode is the difference between "build fails on unexpected dependency change" and "CI quietly rewrites go.mod."
What's the difference between GOPRIVATE, GONOPROXY and GONOSUMDB?
GONOPROXY controls which module paths bypass the proxy; GONOSUMDB controls which bypass checksum verification. GOPRIVATE is a convenience that sets both to the same pattern list, which is correct for most private-module setups.
Do I still need an SCA tool if I run govulncheck?
For known CVEs in reachable code, govulncheck is genuinely good. An SCA platform adds malware and typosquat detection, license policy, SBOM export, and PR-level gating across every ecosystem you use — Go is rarely the only one in the building.