Your Go build fetches dependencies from the internet. If you haven’t thought hard about what exactly it fetches and whether it can be tampered with, you have a supply chain problem. Most teams don’t think about this until they’re on the wrong end of a typosquatting attack or a module author deletes a tag.
Go actually ships with a surprisingly solid supply chain security story baked in. GOPROXY, GOSUMDB, and the go.sum lock file work together as a three-layer defence. The problem is that most developers treat them as magic and never look under the hood — which means they also don’t know when the magic isn’t working.
This article explains how each layer works, where it breaks down, and how to run your own proxy for environments where "phone home to sum.golang.org" is a non-starter.
The Problem With go get Flying Blind
Before Go modules (pre-1.11), dependency management was a disaster. With modules came a critical design choice: Go doesn’t just download code, it verifies it. Every module version you pull gets its hash compared against a transparency log maintained by Google. If the bits don’t match, the build fails.
That sounds great. Here’s the catch: the default setup requires your build machines to reach proxy.golang.org and sum.golang.org. In an airgapped datacenter, behind a strict egress firewall, or in a CI environment with no external network access — the defaults fall apart silently. And even with network access, you’re still trusting a single point of control.
Let’s break down each piece.
GOPROXY: The Download Layer
GOPROXY controls where go fetches module source code. The default is:
GOPROXY=https://proxy.golang.org,direct
This is a comma-separated list with fallback semantics. Go tries each proxy left to right. direct is a special keyword meaning "hit the VCS directly (GitHub, GitLab, etc.)". If proxy.golang.org returns a 404 or 410, Go falls back to direct.
The proxy speaks the GOPROXY protocol — a simple HTTP API:
GET $GOPROXY/$module/@v/list # list available versions
GET $GOPROXY/$module/@v/$version.info # version metadata
GET $GOPROXY/$module/@v/$version.mod # go.mod only
GET $GOPROXY/$module/@v/$version.zip # full source zip
GET $GOPROXY/$module/@latest # latest version hint
The proxy is read-only and cacheable. That’s important — it means you can put a caching proxy in front of your CI fleet and all your builds pull from local storage instead of the internet.
What the proxy does NOT do: it does not verify integrity. That’s GOSUMDB‘s job.
Sentinel values
Two special values matter:
off— prohibit all downloads. Fail if the module isn’t already in the cache.direct— bypass proxy, go straight to VCS.
For a fully locked-down build: GOPROXY=off guarantees nothing will be fetched at build time. You populate the cache separately (in a dedicated "vendor update" pipeline) and builds never touch the network.
GOSUMDB: The Verification Layer
GOSUMDB points to a checksum database. The default is sum.golang.org. Every time Go downloads a module version it hasn’t seen before, it asks the checksum database: "What’s the expected hash for github.com/foo/[email protected]?"
The hash gets recorded in your go.sum file. On subsequent downloads (by anyone, anywhere), Go recomputes the hash and compares it against both go.sum and the database. If anything doesn’t match, the build refuses to continue.
The checksum database is a Merkle tree — a tamper-evident transparency log. You can’t quietly change what a version contains without the log detecting it. The design is borrowed from Certificate Transparency.
# go.sum entry format: module version hash
github.com/some/module v1.4.2 h1:abc123...
github.com/some/module v1.4.2/go.mod h1:def456...
Two entries per version: one for the full source zip, one for just the go.mod. The /go.mod hash lets Go verify dependency graphs without downloading entire modules.
GONOSUMDB and GONOSUMCHECK
Not everything should hit the public sum database. Private modules hosted on your internal GitLab will never be in sum.golang.org — and you don’t want them there.
# Comma-separated list of module path prefixes to skip GOSUMDB for
GONOSUMDB=gitlab.internal.company.com,*.corp.example.com
# Older name, same semantics — GONOSUMCHECK is deprecated, prefer GONOSUMDB
GONOSUMCHECK=gitlab.internal.company.com
Gotcha: GONOSUMDB skips the remote checksum database, but Go still writes hashes to go.sum based on the local download. The local go.sum still protects you against the downloaded content changing between builds on the same machine — it just doesn’t have a third-party attestation.
GONOSUMDB vs GOPRIVATE
GOPRIVATE is a convenience variable that sets both GONOSUMDB and GONOPROXY (skip the proxy) in one shot:
GOPRIVATE=gitlab.internal.company.com
# equivalent to:
GONOPROXY=gitlab.internal.company.com
GONOSUMDB=gitlab.internal.company.com
For internal modules, GOPRIVATE is the right default. For public modules you want to proxy but not verify via sum.golang.org (rare, but valid for air-gapped setups with a private sum database), you’d set them separately.
go.sum: The Lock File
The go.sum file is your local ground truth. Commit it. Always. Treat changes to it the same way you’d treat dependency version bumps — review them in PRs.
A modified go.sum entry that doesn’t match the checksum database is a build-stopping error. That’s by design. But there’s a subtlety: go mod tidy can add entries without any signature verification if GONOSUMDB covers that module path. You need to be deliberate about what falls outside the public log.
Gotcha: go mod vendor copies source into a vendor/ directory but does not verify against go.sum at vendor time. At build time with -mod=vendor, Go also skips the checksum database. If you’re using vendoring for reproducibility, pin your go.sum and add a CI step that runs go mod verify to check the vendor directory matches the recorded hashes.
go mod verify
# output: all modules verified (or a list of failures)
Running Your Own Proxy: Athens
Athens is the reference open-source implementation of the GOPROXY protocol. It stores module zips and go.mod files either on the local filesystem or in object storage (S3, GCS, Azure Blob).
Why run your own?
- Airgapped builds — no egress at build time.
- Guaranteed availability —
proxy.golang.orgdoes have occasional outages. - Audit log — you know exactly which module versions your organization has used.
- You can block specific modules or versions at the proxy level before they ever enter your builds.
Docker Compose setup
# docker-compose.yml
services:
athens:
image: gomods/athens:v0.15.1
restart: unless-stopped
environment:
# Where Athens stores downloaded modules
ATHENS_STORAGE_TYPE: disk
ATHENS_DISK_STORAGE_ROOT: /var/lib/athens
# Upstream proxy chain (Athens fetches from here if not cached)
ATHENS_PROXY_UPSTREAM: https://proxy.golang.org
# Athens will validate against the public sum database
ATHENS_GONOSUMCHECK: ""
# Your network-internal modules that Athens should fetch directly
ATHENS_GOPRIVATE: "gitlab.corp.example.com"
ATHENS_GONOPROXY: "gitlab.corp.example.com"
ATHENS_GONOSUMDB: "gitlab.corp.example.com"
# Optional: git credentials for private VCS
ATHENS_NETRC: /run/secrets/netrc
# Port
PORT: 3000
ports:
- "3000:3000"
volumes:
- athens-data:/var/lib/athens
secrets:
- netrc
secrets:
netrc:
file: ./secrets/netrc # machine gitlab.corp.example.com login token password <PAT>
volumes:
athens-data:
# Start it
docker compose up -d
# Smoke test
curl https://cd-linux.club:3000/github.com/gin-gonic/gin/@v/list
Point your builds at it
export GOPROXY=http://athens.infra.corp.example.com:3000,direct
export GONOSUMDB=gitlab.corp.example.com
export GOPRIVATE=gitlab.corp.example.com
Or in your CI environment (GitLab CI example):
# .gitlab-ci.yml
variables:
GOPROXY: "http://athens.infra.corp.example.com:3000,direct"
GONOSUMDB: "gitlab.corp.example.com"
GOPRIVATE: "gitlab.corp.example.com"
build:
image: golang:1.24
script:
- go build ./...
Gotcha: Athens doesn’t validate content it serves back to you against sum.golang.org by default. If someone compromises your Athens instance and replaces a zip, go.sum on individual developer machines will catch it — but only after the first honest download set the baseline. A compromised Athens on a fresh checkout can inject malicious code into the first download before any go.sum baseline exists. Mitigate this by pointing GONOSUMDB only at what’s actually private, so public modules still get checked against sum.golang.org.
Using S3 for storage
For production, don’t use disk storage — use object storage. Athens supports S3 natively:
environment:
ATHENS_STORAGE_TYPE: s3
AWS_REGION: eu-central-1
ATHENS_S3_BUCKET_NAME: my-go-module-cache
# Athens uses the standard AWS credential chain: env vars, IAM role, ~/.aws/credentials
Running a Private Sum Database
For a truly air-gapped environment, you need a private sum database too. Notary and Sumdb are the building blocks, but operationally they’re non-trivial.
A pragmatic alternative: use GONOSUMDB for everything and rely solely on a committed go.sum. You lose the third-party attestation but you keep the local tamper detection, which is meaningful if your CI pipeline populates the cache and no individual developer can alter go.sum without a PR review.
For environments that do need a private transparency log (regulated industries, defence contractors), GoProxy Enterprise and JFrog Artifactory both ship with private sum database support.
Dependency Pinning and go mod download
One workflow that holds up well in production: pre-download all dependencies in a privileged "update" pipeline that has egress, then cache the module zip directory and use GOPROXY=off everywhere else.
# In the "update" pipeline (egress allowed)
go mod download all
# This populates $GOPATH/pkg/mod/cache with all zips and go.mod files
# Pack the cache into an artifact
tar czf go-module-cache.tar.gz -C $GOPATH/pkg/mod/cache/download .
# In normal build pipelines (no egress):
# 1. Extract the artifact
# 2. Point GOPROXY at the local directory
export GOPROXY=file://$GOPATH/pkg/mod/cache/download,off
The file:// proxy is built into the Go toolchain — no Athens required. The trailing ,off ensures the build fails hard if something isn’t in the local cache rather than silently trying the network.
Gotchas Summary
Pseudo-versions and yanked modules. proxy.golang.org respects retract directives and will return 410 for retracted versions. Your private Athens won’t retract anything automatically — if a module author retracts a version after you’ve cached it, Athens keeps serving it. Add an operational process to regularly audit your cache against the public retraction log.
GOFLAGS can override everything. If someone has GOFLAGS=-mod=mod in their shell, the toolchain will happily update go.sum and go.mod on the fly during builds. Pin -mod=readonly in CI explicitly:
GOFLAGS="-mod=readonly"
Replace directives bypass the proxy. A replace directive in go.mod pointing to a local path or a fork’s VCS URL will fetch directly, ignoring GOPROXY. This is usually intentional (local development), but in CI it means those deps skip verification entirely unless you’re careful with go mod verify.
Athens startup cold cache. First build after deploying Athens will still need egress (to fill the cache from upstream). Plan for this — don’t run your first build in production with a fresh Athens and no egress.
GONOSUMDB is a prefix list, not a glob. GONOSUMDB=gitlab.corp.example.com matches gitlab.corp.example.com/foo/bar but not gitlab.corp.example.com:8443/foo/bar (different port). If your internal GitLab runs on a non-standard port, include it explicitly.
Production Checklist
Before shipping any of this to production, verify these:
# 1. Confirm builds work with no internet
GOPROXY=off go build ./...
# 2. Verify module cache integrity
go mod verify
# 3. Check nothing in go.sum was added outside your expected module paths
git diff go.sum | grep "^+" | grep -v "^+++" | grep -v "your-trusted-prefix"
# 4. Confirm GONOSUMDB isn't silently swallowing public modules
go env GONOSUMDB
# 5. Review GOFLAGS in CI to ensure -mod=readonly is set
go env GOFLAGS
For teams that want automated enforcement, govulncheck integrates with the Go vulnerability database and works well alongside a module proxy setup. It queries vuln.go.dev — which you can also mirror or proxy through Athens with a plugin.
The Minimal Secure Baseline
If you’re not ready to run Athens yet, at minimum do this:
# Commit go.sum, always
git add go.sum
# Lock builds to what's in go.sum — no auto-updates
export GOFLAGS="-mod=readonly"
# Never bypass sum verification for public modules
# Only set GONOSUMDB for genuinely private paths
export GONOSUMDB=your.private.registry.internal
# Fail loudly instead of falling back to direct
export GOPROXY=https://proxy.golang.org,off
# (remove "direct" — you want a hard failure if proxy misses something)
The difference between direct and off as the final fallback is the difference between "silently pulls from GitHub with no audit trail" and "build fails and you know about it." Always prefer off in automated pipelines.
Go’s module security primitives are genuinely well-designed. The transparency log approach is solid cryptography with real-world deployments. What it isn’t is automatic — you have to make deliberate choices about your proxy chain, your sum database, and what falls inside versus outside verification. Make those choices explicitly, document them in your CI configuration, and you’ll have a supply chain story that holds up under scrutiny.