Expose Pprof on a Go Server Without Exposing It to Everyone
Serve net/http/pprof from a separate loopback listener with a token, avoid the DefaultServeMux trap, and fix the WriteTimeout error on CPU profiles.

net/http/pprof is the best debugging feature in the Go ecosystem and one of the easiest to expose by accident. One blank import and a running server gives you CPU profiles, heap profiles, goroutine dumps and execution traces over HTTP. That is exactly what you want at 3 a.m. when a service is eating memory, and exactly what an attacker wants to see too. The mistake is not using it. The mistake is using it the way the package documentation's first example does and leaving the door open to the internet.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article shows the failure on a running server, then the arrangement I use: a separate private listener, explicit registration, a token, and the one timeout setting that otherwise breaks profiling. It is a hardening piece in the web cluster that ends in a production HTTP server, and the profiles themselves are read as in mutex and block profiles and the execution tracer. Everything ran on Go 1.25.6 with the standard library only.
What the import does
The package's init function registers six handlers, /debug/pprof/, /debug/pprof/cmdline, /debug/pprof/profile, /debug/pprof/symbol, /debug/pprof/trace, and the named profile routes under the index, on http.DefaultServeMux. It does not look at what your servers do with the default mux. The catch is that plenty of programs do serve it, often without knowing: http.ListenAndServe(":8080", nil) uses it, so does http.Handle at package level, and so does a library that registers routes in an init.
What is exposed is more than performance numbers. The goroutine dump shows function names, file paths and arguments. A heap profile shows which code allocates what, with the file and line of each allocation site. cmdline prints the process's full command line, including any secret passed as a flag. And the debug=2 goroutine endpoint prints stack traces that can contain request parameters. None of this lets an attacker run code. All of it helps them aim, and the CPU profile endpoint doubles as a cheap way to make your server burn 30 seconds of CPU per request they send.
Watching the leak happen
The program starts a server the common way, with the default mux, and another server with a private mux for comparison.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
_ "net/http/pprof" // the import that does the damage
"strings"
)
func get(label, url string) {
res, err := http.Get(url)
if err != nil {
panic(err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
first, _, _ := strings.Cut(string(b), "\n")
fmt.Printf("%-34s -> %d %.50q\n", label, res.StatusCode, first)
}
func main() {
// A server that passes nil / DefaultServeMux, exactly like http.ListenAndServe(addr, nil).
careless := httptest.NewServer(http.DefaultServeMux)
defer careless.Close()
// A server with its own mux: the pprof handlers are not on it.
own := http.NewServeMux()
own.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hello") })
careful := httptest.NewServer(own)
defer careful.Close()
get("default mux: /debug/pprof/", careless.URL+"/debug/pprof/")
get("default mux: goroutines", careless.URL+"/debug/pprof/goroutine?debug=1")
get("own mux: /debug/pprof/", careful.URL+"/debug/pprof/")
}default mux: /debug/pprof/ -> 200 "<html>"
default mux: goroutines -> 200 "goroutine profile: total 7"
own mux: /debug/pprof/ -> 200 "hello"The default-mux server hands out the profile index and a goroutine dump to anyone. The server with its own mux answers 200 with the home page text for that path, because its / catch-all matches, and the pprof handlers are not there. Registering routes on your own ServeMux, rather than the global one, is a habit worth having for its own sake, and go vet will not tell you when you forget.
The arrangement I use
Three layers, each one cheap:
- Separate listener, loopback only. Run a second
http.Serverbound to127.0.0.1:6060. It is unreachable from the network, so nothing needs a firewall rule to stay private. To reach it from your laptop usessh -L 6060:127.0.0.1:6060 host, or on Kuberneteskubectl port-forward pod/NAME 6060:6060. Thengo tool pprof http://localhost:6060/debug/pprof/heapworks as if it were local. - Explicit registration on a private mux. Register the handlers yourself from
net/http/pprof's exported functions (pprof.Index,Cmdline,Profile,Symbol,Trace), so the intent is visible in code and survives a refactor that stops using the default mux elsewhere. - A token as a second lock. A bearer token in an environment variable guards against the day someone changes the bind address to
0.0.0.0for convenience.
package main
import (
"crypto/subtle"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/http/pprof"
"sort"
"strings"
"sync"
"time"
)
func debugMux(token string) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index) // also serves heap, goroutine, allocs, block, mutex by name
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if subtle.ConstantTimeCompare([]byte(got), []byte(token)) != 1 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
mux.ServeHTTP(w, r)
})
}
func main() {
srv := httptest.NewUnstartedServer(debugMux("s3cret"))
srv.Config.WriteTimeout = 2 * time.Second // a typical API server value
srv.Start()
defer srv.Close()
get := func(label, path, token string) {
req, _ := http.NewRequest("GET", srv.URL+path, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf("%-34s error: %v\n", label, err)
return
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
first, _, _ := strings.Cut(string(b), "\n")
fmt.Printf("%-34s -> %d %.60q\n", label, res.StatusCode, first)
}
get("no token", "/debug/pprof/heap?debug=1", "")
get("wrong token", "/debug/pprof/heap?debug=1", "guess")
get("heap, right token", "/debug/pprof/heap?debug=1", "s3cret")
get("goroutines, right token", "/debug/pprof/goroutine?debug=1", "s3cret")
// A 3 second CPU profile from a server whose WriteTimeout is 2 seconds.
start := time.Now()
req, _ := http.NewRequest("GET", srv.URL+"/debug/pprof/profile?seconds=3", nil)
req.Header.Set("Authorization", "Bearer s3cret")
res, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("3 s profile, 2 s WriteTimeout: error:", err)
} else {
b, _ := io.ReadAll(res.Body)
res.Body.Close()
fmt.Printf("3 s profile, 2 s WriteTimeout -> %d, %d bytes of gzip data after %s\n",
res.StatusCode, len(b), time.Since(start).Round(time.Second))
}
// Only one CPU profile can run at a time: two at once, one loses.
var wg sync.WaitGroup
var mu sync.Mutex
var codes []int
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
req, _ := http.NewRequest("GET", srv.URL+"/debug/pprof/profile?seconds=1", nil)
req.Header.Set("Authorization", "Bearer s3cret")
res, err := http.DefaultClient.Do(req)
if err != nil {
return
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
mu.Lock()
codes = append(codes, res.StatusCode)
mu.Unlock()
}()
time.Sleep(100 * time.Millisecond)
}
wg.Wait()
sort.Ints(codes)
fmt.Println("two simultaneous CPU profiles ->", codes)
}no token -> 401 "unauthorized"
wrong token -> 401 "unauthorized"
heap, right token -> 200 "heap profile: 2: 2064 [2: 2065] @ heap/1048576"
goroutines, right token -> 200 "goroutine profile: total 6"
3 s profile, 2 s WriteTimeout -> 200, 211 bytes of gzip data after 3s
two simultaneous CPU profiles -> [200 500]Two things the output shows. The token check rejects both a missing and a wrong value, and it uses subtle.ConstantTimeCompare so response timing does not leak the token one byte at a time. The profile rows deserve a correction to what older articles say. I expected the three second profile to be refused because the server's WriteTimeout is two seconds, which is how older write-ups describe the handler. On Go 1.25.6 it succeeded: reading the source in the standard library, Profile now calls http.NewResponseController(w).SetWriteDeadline to push the deadline out by the profile length, so a short global WriteTimeout no longer breaks profiling. A separate debug listener is still the better home, because it lets you keep a strict WriteTimeout on the public server for the reasons in the timeouts article while the debug one is free to hold a connection open for a minute.
The last line is the one to remember if you ever expose the endpoint by mistake. The runtime allows a single CPU profile at a time, so when two requests overlap, the second gets a 500 with the message "Could not enable CPU profiling". That is a mild denial of service available to anyone who can reach the route, and it also means that if a colleague is already profiling, your own attempt fails until theirs finishes. Check for that before concluding the endpoint is broken.
Using it, and keeping it safe
Profile on demand and on the real service. For CPU: go tool pprof -http=:0 'http://localhost:6060/debug/pprof/profile?seconds=30' while the problem is happening. For memory: fetch heap and compare two snapshots with -base. For a goroutine leak: goroutine?debug=2 gives the full stacks to grep. The overhead of having the handlers registered while nobody is using them is zero; profiling itself costs a few percent of CPU while it runs.
Some operational rules that have saved me from awkward conversations:
- Own it. Write the access path in the runbook (the tunnel command, where the token lives). People open pprof to the internet under pressure only when they do not know the safe way.
- Rotate the token with the other secrets, and never put it in a URL, since URLs end up in logs.
- Check from outside. Add a smoke test that requests
/debug/pprof/on the public hostname and fails the build unless it gets a 404. It catches the accidentalnilmux the day someone introduces it. - Continuous profiling tools (Pyroscope, Parca, Grafana Phlare and cloud profilers) scrape these same endpoints. If you adopt one, give it the token and a network path to the debug port, and keep the port off the public interface.
The package reference, including the exact route list, is on pkg.go.dev, and the Go team's article on profiling Go programs shows how to read what you collect.
Alternatives to an HTTP endpoint
You do not always need an endpoint. A service that cannot easily be reached, such as a batch job, can write a profile on a signal: start pprof.StartCPUProfile into a file on SIGUSR1, stop it on the next, and collect the file. A program can also dump goroutines to stderr when it receives SIGQUIT, which the Go runtime already does for free (the process prints all stacks and exits). For a service that must stay up, a flag-enabled debug listener is the lightweight middle: off by default, on in staging, and switchable by configuration in production without a rebuild.
Whichever you pick, test it before you need it. The day a service leaks memory is the wrong day to discover that the tunnel command in the runbook has a typo, or that the container image has no go toolchain to read the profile. Copying the profile out and reading it on a laptop works with a bare curl -o heap.pprof and go tool pprof heap.pprof.


