Make every critical route go through one authentication step that verifies a credential the server issued: here a Bearer token whose HMAC-SHA256 is checked with hmac.Equal. Take the user from the verified token and the users store, never from an X-User-Id header or a body field. Answer 401 when the credential is missing or invalid.
- List every route and mark the public ones (a health probe) and the critical ones (reading a profile, changing an email, closing an account). Everything not explicitly public needs authentication.
- Write one authenticator and run it in middleware in front of the protected routes, so a route cannot be added without it. It must verify a credential, not merely read a claimed identity.
- Verify the credential: recompute the token's HMAC over its payload and compare with hmac.Equal (constant time), then take the user ID from the verified token. Reject a missing or malformed credential with 401.
- Load the user from the store by the verified ID and put it in the request context under an unexported key type. Handlers read the user from there and never from X-User-Id, a query parameter or the body.
- Test through httptest.NewServer with the authenticator wired in: a legitimate signed token works, a forged X-User-Id header is ignored, and an unsigned token is refused, with nothing changed.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
func Vulnerable(header AuthHeaders, store *Store) (User, bool) {
// The client simply says who they are. We believe them.
if header.XUserID == "" {
return User{}, false
}
return store.Lookup(header.XUserID)
}With IMPL=vulnerable, 2 of 9 tests fail (2 exploit tests).
The fix
func Fixed(header AuthHeaders, store *Store) (User, bool) {
token, ok := strings.CutPrefix(header.Authorization, "Bearer ")
if !ok {
return User{}, false // no credential: the handler never runs
}
id, sig, ok := strings.Cut(token, ".")
if !ok {
return User{}, false
}
want, err := hex.DecodeString(sig)
// Recompute the MAC over the claimed ID and compare in constant time. hmac.Equal rejects a
// token the server never signed, so the ID cannot be forged or swapped.
if err != nil || !hmac.Equal(want, mac(store.Secret(), id)) {
return User{}, false
}
// The identity is whatever the verified token says, never the X-User-Id header.
return store.Lookup(id)
}With IMPL=fixed, 9 of 9 tests pass.
A fix that looks safe but is not
func Wrong(header AuthHeaders, store *Store) (User, bool) {
token, ok := strings.CutPrefix(header.Authorization, "Bearer ")
if !ok || token == "" {
return User{}, false
}
// Decode the claimed ID. There is no signature to check, so any well-formed token is accepted.
id, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return User{}, false
}
return store.Lookup(string(id))
}With IMPL=wrong, 2 of 9 tests fail (2 exploit tests).
Routes: the user comes from a verified credential, not the request
// Every /account route is wrapped in requireUser, so the handler only runs once an identity is
// proved, and it acts on currentUser(r) alone. GET returns the caller's profile; POST changes
// the caller's email; DELETE closes the caller's account.
protected := http.NewServeMux()
protected.HandleFunc("GET /account", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, currentUser(r))
})
protected.HandleFunc("POST /account/email", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil || body.Email == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "email is required"})
return
}
// The target is the signed-in user, never a user named in the body.
store.SetEmail(currentUser(r).ID, body.Email)
writeJSON(w, http.StatusOK, map[string]string{"email": body.Email})
})
protected.HandleFunc("DELETE /account", func(w http.ResponseWriter, r *http.Request) {
store.Delete(currentUser(r).ID)
writeJSON(w, http.StatusOK, map[string]string{"status": "closed"})
})
mux.Handle("/account", requireUser(store, authenticate, protected))
mux.Handle("/account/", requireUser(store, authenticate, protected))The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: each payload claims an identity the caller cannot prove. The secure outcome is 401 with
// nothing changed; the forged identity must never be honoured.
func TestExploitForgedUserIdHeaderIsNotTrusted(t *testing.T) {
f := newServer(t)
req := f.request(t, "GET", "/account", "")
req.Header.Set("X-User-Id", "bob") // "I am bob" with no credential
got := f.do(t, req)
if got.status != http.StatusUnauthorized {
t.Fatalf("an X-User-Id header is not a credential; expected 401, got %d returning %v", got.status, got.body)
}
}
func TestExploitUnsignedBearerTokenIsRejected(t *testing.T) {
f := newServer(t)
req := f.request(t, "GET", "/account", "")
// A token the attacker made: base64url of the victim's ID, with no signature.
req.Header.Set("Authorization", "Bearer "+base64.RawURLEncoding.EncodeToString([]byte("bob")))
got := f.do(t, req)
if got.status != http.StatusUnauthorized {
t.Fatalf("an unverified token is not a credential; expected 401, got %d returning %v", got.status, got.body)
}
}
func TestExploitEmailTakeoverViaForgedHeader(t *testing.T) {
f := newServer(t)
before := f.emailOf("bob")
req := f.request(t, "POST", "/account/email", `{"email":"attacker@evil.example"}`)
req.Header.Set("X-User-Id", "bob")
f.do(t, req)
if f.emailOf("bob") != before {
t.Fatalf("critical function ran for a forged identity: bob's email became %q", f.emailOf("bob"))
}
}
func TestExploitEmailTakeoverViaUnsignedToken(t *testing.T) {
f := newServer(t)
before := f.emailOf("bob")
req := f.request(t, "POST", "/account/email", `{"email":"attacker@evil.example"}`)
req.Header.Set("Authorization", "Bearer "+base64.RawURLEncoding.EncodeToString([]byte("bob")))
f.do(t, req)
if f.emailOf("bob") != before {
t.Fatalf("critical function ran for an unverified token: bob's email became %q", f.emailOf("bob"))
}
}- An X-User-Id header, a user field in the body, or a ?user= query parameter is a claim, not a credential. Trusting any of them (the vulnerable implementation) is missing authentication: the caller names whoever they like.
- A token the server never signed is not a credential. The wrong implementation base64-decodes the user ID out of the token and trusts it; an attacker base64-encodes the victim's ID and is the victim. A token must be verified, not just decoded.
- Compare the MAC with hmac.Equal, not ==: a byte-by-byte == returns early and leaks the right prefix through timing. Decode the hex first and compare the raw bytes.
- Put authentication in middleware in front of a route group, not in each handler, or the one route someone adds later outside the group is open. ServeMux lets you wrap a sub-mux, as app.go does.
- Authentication (who you are) is not authorization (what you may do). This card only proves identity; a real API still checks what the verified user may touch (see the missing-authorization card).
- Keep the user in the context under an unexported key type such as type userKey struct{}, not a string, so no other package can set or read it by accident.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authentication/go-nethttp does this for you, in Docker if you do not have Go).
- Against your own API, send a request with X-User-Id set to another user and no token, then a Bearer token you built yourself without the server's key; both must answer 401 and change nothing.
- Scanners do not help here: Semgrep's packs, gosec and CodeQL's security-extended suite report nothing about missing authentication on any implementation.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | p/golang | No findings on any implementation. semgrep-rules has no Go rule that distinguishes an authenticator that verifies a signed credential from one that trusts a header or an unsigned token; the three implementations look identical to every rule.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep image sha256:529ee8a2...); semgrep scan --metrics=off --config p/golang --config p/owasp-top-ten . |
| Semgrep | go.lang.security.audit.crypto.use_of_weak_rsa_key.use_of_weak_rsa_key | No findings. The crypto audit rules look for weak algorithms and hard-coded keys, not for the absence of a verification step; the fixed code's HMAC-SHA256 is not flagged.Checked: Semgrep 1.180.0; every go/lang/security/audit/crypto rule run against impl/ from a semgrep-rules checkout (develop 9bbf021) |
| CodeQL | go-security-extended | No Go query in the security-extended suite looks for a missing or forgeable authentication check. The only authentication-related Go query is the experimental go/pam-auth-bypass, which models the PAM C API and does not apply. CodeQL cannot tell that the wrong implementation never verifies the token's signature.Checked: github/codeql main at b5b8165; query suite and models read, not executed |
| gosec | default rule setgosec 2.29.0 | No findings. gosec has no rule for missing authentication; its G505/G401 crypto rules do not fire on HMAC-SHA256, and it cannot see that a token is never verified.Checked: gosec 2.29.0 (run with Go 1.26.9: gosec 2.29.0 cannot read Go 1.27 export data); gosec ./... |
No Go scanner rule looks for an endpoint that skips authentication or trusts an unverified identity. The regression tests are the control: they prove a forged header and an unsigned token are both refused.
| Component | Version |
|---|---|
| Language | Go |
| Runtime | Go 1.27.2 (golang:1.27.2-trixie, linux/arm64); also passes on Go 1.26.9 |
| Framework | Go standard library 1.27.2 (net/http, crypto/hmac, crypto/sha256) |
| gotest.tools/gotestsum (JUnit report, a go.mod tool) | 1.13.0 |
| golang.org/x/text (raised for gotestsum) | 0.42.0 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authentication/go-nethttpfolder.
go mod download
go build ./... tool# Expected: every test passes
IMPL=fixed go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: an exploit test fails
IMPL=vulnerable go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...
# Expected: a test fails
IMPL=wrong go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...Without Go installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go mod download && go build ./... tool'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=fixed \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=vulnerable \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-go-gomod:/go/pkg/mod \
-v fix-go-gocache:/root/.cache/go-build \
-e IMPL=wrong \
golang:1.27.2-trixie@sha256:e58d6f83b3416618d8bcac2b3dde1b7f7e3c4a77d25e88637f8bbae81536c48d \
sh -c 'go tool gotestsum --junitfile reports/junit.xml -- -count=1 ./...'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs missing-authentication/go-nethttp runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64); tests in Docker Desktop (Engine 29.4.3), linux/arm64
- Toolchain
- go 1.27.2 (golang:1.27.2-trixie image); also run on 1.26.9 (golang:1.26.9-trixie)
- gotestsum 1.13.0
- semgrep 1.180.0
- gosec 2.29.0
- codeql CLI 2.27.1, codeql/go-queries 1.6.12 (read, not executed)
- Matrix
- fixed: 9 of 9 tests pass; vulnerable: 2 of 9 tests fail (2 exploit tests); wrong: 2 of 9 tests fail (2 exploit tests)
- Test results
- fixed 9/9 pass; vulnerable 2/9 fail (2 exploit tests: the forged X-User-Id header, the email takeover via that header); wrong 2/9 fail (2 exploit tests: the unsigned bearer token, the email takeover via that token), each test run in a container with --network=none after install; node examples/fixes/run-all.mjs missing-authentication/go-nethttp --install
- Dependency freshness
- The card needs only the standard library. gotestsum 1.13.0 (2025-09-11) asks for golang.org/x/text 0.17.0, which GO-2026-5970 affects in a package gotestsum builds (x/text/unicode/norm), so go.mod raises it to 0.42.0 (2026-09-08). Go 1.27.2 (2026-10-08) is under seven days old but is a security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- CWE-306: Missing Authentication for Critical Function
- OWASP API Security Top 10 2023: API2 Broken Authentication
- OWASP Authentication Cheat Sheet
- OWASP Top 10:2025 A07 Authentication Failures
- Go standard library: crypto/hmac (Equal is constant time)
- Go standard library: net/http ServeMux patterns and precedence
- Go standard library: context.WithValue (key types)
- Go standard library: net/http MaxBytesReader
Attack patterns: