Private Modules — Professional Level¶
Table of Contents¶
- Introduction
- How
cmd/goResolves a Module - The Proxy Protocol in Detail
- The Sumdb Protocol
- Lookup Precedence: GOPROXY × GONOPROXY × GOPRIVATE
- Insecure Modes: GOFLAGS=-insecure and GOINSECURE
- GONOSUMCHECK and Friends — What Actually Toggles
- Caching and Mutation
- Security Implications
- Reading the Toolchain Source
- Summary
Introduction¶
Focus: "What does the toolchain actually do, byte-for-byte, when it sees a private module path?"
This file follows a single go get github.com/acme/internal-auth@v0.3.1 from typed command to bytes-on-disk, with the source-of-truth being cmd/go itself. Where the Go Modules Reference is silent, the source code is law. We will cover the proxy protocol, sumdb protocol, exact precedence of the env vars, and the security implications that come from each switch.
This is not user-facing documentation; it is the mental model of someone debugging a malfunctioning Athens or writing a custom proxy.
How cmd/go Resolves a Module¶
The high-level state machine for a module fetch:
Each box maps to identifiable code in cmd/go/internal/modfetch:
coderepo.go— Git/Mercurial/etc. drivers behinddirect.proxy.go— the proxy protocol client.sumdb.go— sumdb client.cache.go—$GOMODCACHE(a.k.a.$GOPATH/pkg/mod) management.module.go— path validation, escape rules.
Path normalisation¶
Paths are case-preserving but case-folded for lookup only on filesystems that need it. The proxy protocol uses an explicit escape function (module.EscapePath) that lower-cases uppercase letters by prefixing them with ! — GitHub.com becomes !git!hub.com. This is to avoid case-collisions on case-insensitive filesystems.
You will see this in the cache:
When you peek under the cache, treat !X as "uppercase X."
Version resolution¶
For path@selector, the toolchain first asks "is this an exact semver?" If not, it queries the proxy:
The response is a newline-separated list of versions. The selector (@latest, @upgrade, @patch, branch, SHA) is resolved against this list.
For private paths matching GONOPROXY, the equivalent of git ls-remote produces the list. The toolchain runs (roughly):
Tags become version candidates; branch heads become potential pseudo-versions.
The Proxy Protocol in Detail¶
The Go module proxy protocol is HTTP-based. It is defined in go.dev/ref/mod#goproxy-protocol. There are five endpoints, all relative to the proxy URL:
| Endpoint | Purpose |
|---|---|
GET <module>/@v/list | Plain-text list of available versions, newline-separated. |
GET <module>/@latest | JSON describing the latest version. |
GET <module>/@v/<version>.info | JSON metadata: {Version, Time, ...}. |
GET <module>/@v/<version>.mod | The go.mod of that version. |
GET <module>/@v/<version>.zip | A zip archive of the module at that version. |
The path component is escaped: every uppercase ASCII letter becomes ! followed by the lowercase letter. So github.com/Acme/Foo is !git!hub.com/!acme/!foo on the wire.
A complete fetch sequence for github.com/acme/internal-auth@v0.3.1 against https://athens.acme.io:
GET https://athens.acme.io/github.com/acme/internal-auth/@v/list
→ 200 OK
v0.1.0
v0.2.0
v0.3.0
v0.3.1
GET https://athens.acme.io/github.com/acme/internal-auth/@v/v0.3.1.info
→ 200 OK
{"Version":"v0.3.1","Time":"2025-04-01T08:30:00Z"}
GET https://athens.acme.io/github.com/acme/internal-auth/@v/v0.3.1.mod
→ 200 OK
module github.com/acme/internal-auth
go 1.22
require ...
GET https://athens.acme.io/github.com/acme/internal-auth/@v/v0.3.1.zip
→ 200 OK
<binary>
Three observations:
- Statelessness. Each request is independent. The proxy can be a CDN.
- Idempotence. Once a
.info,.mod, or.zipis published at a version, it must not change. Proxies enforce this via content-addressed storage. - Auth. The proxy can require authentication (typically Basic auth with
.netrc). The toolchain reusesgit's credential machinery for this.
Proxy chain semantics¶
GOPROXY=https://a,https://b,direct means:
- Try
a. If 200, use it. If 404 or 410, fall through to next. - Try
b. Same rule. - Fall through to
direct(Git clone).
For any other HTTP error (500, network timeout), the toolchain stops — does not fall through. This is to prevent a transient outage from accidentally bypassing your security model.
You can override fall-through behaviour:
The pipe (|) means "fall through on any error, not just 404/410." Useful for redundancy; dangerous for security.
What the proxy does for private modules¶
A proxy like Athens that supports private modules:
- Receives
GET /github.com/acme/internal-auth/@v/v0.3.1.zip. - Checks its
.netrcforgithub.comcredentials. - Runs
git clone --depth 1 --branch v0.3.1 https://x:TOKEN@github.com/acme/internal-auth /tmp/build. - Builds a zip archive that conforms to the spec (specific
module.txt, exact path layout). - Hashes the zip. Stores in S3.
- Streams the zip back to the requester.
Subsequent requests for the same @v0.3.1 are served from S3 directly.
Module zip format¶
A module zip has a strict layout:
github.com/acme/internal-auth@v0.3.1/
github.com/acme/internal-auth@v0.3.1/go.mod
github.com/acme/internal-auth@v0.3.1/auth.go
github.com/acme/internal-auth@v0.3.1/auth_test.go
...
The top-level prefix is <module>@<version>/. Files outside this prefix, symlinks, files larger than 500MB, file paths with backslashes, etc., are rejected by the toolchain. cmd/go/internal/modfetch/zip enforces this.
The Sumdb Protocol¶
The checksum database is a transparency log in the Certificate Transparency tradition. Defined in go.dev/ref/mod#checksum-database.
Lookup¶
Response:
2185234
github.com/google/uuid v1.6.0 h1:ABCDEF...
github.com/google/uuid v1.6.0/go.mod h1:GHIJKL...
— sum.golang.org Az3hBw...<signature>
Three blocks:
- The log's tree size at the time of the answer (
2185234). - The actual hashes (
h1:issha256of the zip contents, plus ago.modhash). - A signature over the answer, made with the sumdb's signing key.
The toolchain verifies the signature using a hard-coded public key (or one from GOSUMDB=name+key+url). It then checks the hash against the bytes it just downloaded. If they disagree, the build fails with "checksum mismatch."
Tree consistency proofs¶
The sumdb is append-only. Every answer the client receives includes proof that the current tree size is consistent with the previous tree size the client saw. So if sum.golang.org ever started lying — serving different hashes to different clients — clients with retained state would catch it.
Why private paths skip this¶
sum.golang.org cannot answer for paths it cannot fetch. Asking it about a private module returns "not found." Worse: the act of asking leaks the path of your private repo to the public sumdb operator. GONOSUMDB (set via GOPRIVATE) ensures the lookup never happens.
Running an internal sumdb¶
The protocol is fully documented; in principle anyone can implement it. In practice, very few do — the cryptographic engineering bar is high, and the value over "trust your go.sum" is debatable for most teams. Companies in regulated industries are the main adopters.
Lookup Precedence: GOPROXY × GONOPROXY × GOPRIVATE¶
The exact precedence, in the order the toolchain checks:
GONOPROXY— does the path match? If yes, fetcher isdirect; do not consultGOPROXY.- Otherwise, walk
GOPROXYin order. GONOSUMDB— does the path match? If yes, do not call sumdb; rely ongo.sumonly.- Otherwise, call
GOSUMDB.
Where GOPRIVATE enters: if GONOPROXY is unset, it inherits from GOPRIVATE. Same for GONOSUMDB. So setting GOPRIVATE alone is equivalent to setting both GONOPROXY and GONOSUMDB to the same value.
If you set GONOPROXY explicitly, it overrides the inheritance. Same for GONOSUMDB. This is what lets you say "route private paths through an internal proxy and skip the public sumdb":
GOPROXY=https://athens.acme.io,direct
GOPRIVATE=github.com/acme/*
GONOPROXY= # empty — do NOT bypass GOPROXY
GONOSUMDB=github.com/acme/* # but DO bypass public sumdb
Now github.com/acme/* paths still go through Athens (which authenticates), but don't reach sum.golang.org. Athens has the bytes, the toolchain trusts the proxy's TLS certificate, and go.sum records the hash on first fetch.
This nuance is the main reason GONOPROXY and GONOSUMDB exist as separate variables. GOPRIVATE is the convenient default; the others are the surgical knobs.
Insecure Modes: GOFLAGS=-insecure and GOINSECURE¶
The toolchain has two ways to relax TLS verification:
GOINSECURE (Go 1.14+)¶
A glob list, just like GOPRIVATE. Module paths matching GOINSECURE are allowed to be fetched over HTTP, with TLS verification disabled.
When the toolchain hits internal.acme.io/foo, it allows http://internal.acme.io/foo/... and accepts self-signed certs.
GOFLAGS=-insecure¶
A historical flag passed to go get. Equivalent to "all paths are insecure." Strongly discouraged. Treat as legacy.
When to use¶
- A self-signed internal proxy. Not really. Issue a real certificate from a private CA and add the CA to your machines' trust stores.
GOINSECUREis a stopgap, not a solution. - Testing on a closed network. Acceptable, briefly.
- Production. Never.
Risks¶
GOINSECUREis per path glob. It doesn't disable TLS forproxy.golang.org, only for paths you opt in to. That bounds the blast radius.- Without TLS, a network attacker on the path between you and the proxy can substitute bytes. The hash in
go.sumwill still catch tampering on subsequent builds, but the first download is at risk. - Once
go.sumhas the wrong hash, every subsequent build silently uses the malicious code.
GONOSUMCHECK and Friends — What Actually Toggles¶
There has been a churn of similarly-named environment variables. Here is the current map (Go 1.22+):
| Variable | Purpose | Status |
|---|---|---|
GOPRIVATE | Glob list of private modules. Implies GONOPROXY and GONOSUMDB. | Current. |
GONOPROXY | Glob list bypassing GOPROXY. | Current. |
GONOSUMDB | Glob list bypassing GOSUMDB. | Current. |
GOSUMDB=off | Disable sumdb verification entirely. | Current. |
GONOSUMCHECK | Older synonym in some builds; not part of the current toolchain. | Removed/never released — do not rely on it. |
GOFLAGS=-insecure | Force insecure transport for all paths. | Discouraged. |
GOINSECURE | Glob-scoped insecure transport. | Current. |
Some StackOverflow answers mention GONOSUMCHECK. It was discussed during early modules design but the released toolchain uses GOSUMDB=off (full off) or GONOSUMDB (glob-scoped) instead. If your script sets GONOSUMCHECK, the toolchain will silently ignore it.
What GOSUMDB=off actually does¶
It is much broader than GONOSUMDB=*. off short-circuits the sumdb client entirely; even paths not matching any glob are unverified. Only use it inside a fully-controlled environment (your own internal proxy that you trust to enforce hashes).
Caching and Mutation¶
Cache layout¶
$GOMODCACHE/
├── cache/
│ ├── download/
│ │ └── github.com/acme/internal-auth/@v/
│ │ ├── v0.3.1.info
│ │ ├── v0.3.1.mod
│ │ ├── v0.3.1.zip
│ │ └── v0.3.1.ziphash
│ └── lock
├── github.com/acme/internal-auth@v0.3.1/ # extracted source, read-only
└── ...
*.ziphash is the hash of the .zip after download. Used to detect cache corruption.
Cache is supposed to be immutable¶
The toolchain marks extracted module trees as read-only (chmod -R a-w). If you vim a file in the cache, you get permission denied — by design. This protects against accidental edits.
Clean cache¶
Wipes everything. Use when chasing a stale-cache bug. Safe; the cache is a re-derivable artifact.
For surgical clean of one path:
chmod -R u+w $GOMODCACHE/github.com/acme/internal-auth@v0.3.1
rm -rf $GOMODCACHE/github.com/acme/internal-auth@v0.3.1
rm -rf $GOMODCACHE/cache/download/github.com/acme/internal-auth/@v/v0.3.1.*
After this, the next build re-fetches from the proxy.
What if go.sum and the cache disagree?¶
Cache integrity is checked on every build. If a cached file's hash does not match go.sum, the build fails with the same "checksum mismatch" you would see on download. Restoration: clear the cache and refetch.
Security Implications¶
Setting GOPRIVATE is security-positive and security-negative¶
Positive:
- Stops your private repo paths from leaking to
proxy.golang.org. (Paths leak; bytes do not, since proxy.golang.org returns 410.) - Stops the public sumdb from learning about your private versions.
Negative:
- The first fetch of every private dep is unverified.
- A compromised PAT lets an attacker mutate
githistory (e.g., re-tag a release with malicious bytes). The next freshgo.sumwill record the malicious hash.
GOSUMDB=off is security-negative and a footgun¶
Without sumdb, every dep's first fetch is unverified. Combined with no internal proxy, you have no defence against a man-in-the-middle on your network.
In practice, GOSUMDB=off should appear only in tightly-controlled CI pipelines that hit a fully-trusted internal proxy.
Threat models¶
| Attacker | What GOPRIVATE helps with | What it does not |
|---|---|---|
Public mirror operator (proxy.golang.org) | Hides paths and bytes of private code. | Nothing — they were never going to see the code anyway. |
| Network MITM | TLS still applies; GOPRIVATE doesn't change that. | TLS is what helps; GOPRIVATE is neutral here. |
| Compromised PAT | GOPRIVATE can't help; once auth is compromised, attacker can ship malicious bytes through the legitimate channel. | Limit PAT scopes; rotate aggressively; require code review on the upstream. |
| Compromised module proxy | GOPRIVATE skips public sumdb, so you trust the proxy. | Run your own proxy; require TLS; consider an internal sumdb. |
Why pure GOPRIVATE is "fine for most teams"¶
Because the threat model that justifies expensive infra (internal sumdb) is one where you cannot trust your own CI, your own reviewers, and your own VCS host. If any of those is trustworthy, you don't need the sumdb. Most teams do trust at least their own CI. GOPRIVATE + careful PR review of go.sum covers them.
Reading the Toolchain Source¶
If the docs are silent, the source is law. Useful entry points (paths within https://github.com/golang/go, but only navigate on a checked-out tree):
src/cmd/go/internal/modfetch/proxy.go— proxy client logic, including the famous fall-through rules.src/cmd/go/internal/modfetch/coderepo.go—directfetcher; calls intovcs.gofor Git, Hg, etc.src/cmd/go/internal/modfetch/sumdb.go— sumdb client and verifier.src/cmd/go/internal/cfg/cfg.go— environment variable parsing; central place forGOPROXY,GOSUMDB, etc.src/cmd/go/internal/modload/init.go— initialisation of the modload subsystem; chooses fetcher based on env.src/golang.org/x/mod/module/module.go— path escaping and validation.
Reading the proxy client (proxy.go) is highly recommended once. It is shorter than you expect, and the fall-through rules are spelled out plainly.
Summary¶
A private module fetch is just a normal module fetch with three switches flipped: GONOPROXY redirects routing to direct, GONOSUMDB skips public sumdb, and GOPRIVATE is the convenient name for setting both. The proxy protocol is five HTTP endpoints, content-addressed and stateless. The sumdb protocol is a Merkle-tree transparency log; private paths skip it because the public DB cannot serve them. Insecure modes (GOINSECURE, GOFLAGS=-insecure) are local relaxations of TLS verification — bounded but always discouraged. The threat model that GOPRIVATE improves is "leak my private path to the public proxy." The threat model it does not improve is "an attacker with a valid PAT publishes malicious bytes." For that, you need code review and short token rotations — neither of which is a Go-toolchain concern.