skip to content

Your Go service checks credentials per route rather than around the whole ServeMux; how do you decide which default is right?

level: principalimportance: nice to knowfreq 31%

answer

  1. think about the route nobody wrapped
  2. default deny versus default allow
  3. the exception list is the deliverable
  4. the mux will not enumerate itself
  5. a test, not a habit

basics

~20 s

Decide by what a route added six months from now does on its own. Wrapping the whole mux fails closed and turns the exceptions into one reviewable list. Per-route checks fail open, and are defensible only with a registry and a test proving every path is covered.

solid answer

~60 s

The technical difference is small; the default it sets is not. Wrapping the credential check around the whole `ServeMux` means a handler somebody registers next quarter is protected without them thinking about it, and the argument moves to a short, explicit list of exempt paths — liveness, readiness, and the endpoint that issues credentials in the first place — which a reviewer can read in one place. Per-route wrapping means every new handler is public until someone remembers, and `http.ServeMux` gives you no way to enumerate its registered patterns, so you cannot even write a test that walks the routes unless you build your own registry. If you keep per-route, that registry plus a test asserting each pattern is either wrapped or explicitly public is the price. Add fail-closed startup: if the expected secret is unset the process should refuse to start, because an unset environment variable is the empty string and would otherwise match an empty credential. And for a genuinely internal admin surface, a separate listener bound to an internal address is a stronger control than any header check.

code

go · 14 lines
go
type route struct {
	pattern string
	public bool
}

var routes []route

func register(mux *http.ServeMux, pattern string, h http.Handler, public bool) {
	routes = append(routes, route{pattern: pattern, public: public})
	if !public {
		h = requireSecret(h)
	}
	mux.Handle(pattern, h)
}

go deeper

for a junior

Know the vocabulary: a check applied around the whole mux protects new routes automatically, while a per-route check protects only the routes someone remembered to wrap.

for a middle

Be able to describe both shapes and the exemptions a mux-wide check needs, and to say why Go's ServeMux offers nothing to enumerate routes for a test.

for a senior

Show how you would make the invariant testable rather than aspirational: a registration helper that records every pattern, a test over that registry, and fail-closed startup when the secret is missing.

for a principal

Own the default and its consequences: which surface is public, who may change that, what the exemption list costs, how a migration is sequenced and ended, and when a separate internal listener beats any middleware.

## What is actually being decided Both shapes work. The question is what happens to the route nobody thought about — the debug endpoint added during an incident, the export handler a new joiner registers, the second mux somebody mounts under a prefix. One shape makes that route protected by default and the other makes it public by default. That is a policy decision the service owner proposes and the security reviewer can overrule, and it should be argued in those terms rather than as a style preference. ## The case for wrapping the mux - **Fails closed.** New routes inherit the check. The failure mode of forgetting is a 401 for a caller who should have got through — visible, noisy, quickly fixed — rather than an open admin endpoint nobody notices. - **The exceptions become an artefact.** Wrapping forces you to write down what must stay reachable without credentials. That list is short and it is exactly what a reviewer wants to read: health and readiness probes, and the path that issues or exchanges credentials. A list in one file gets reviewed; a habit distributed across twenty registration calls does not. - **Auditing is trivial.** One wrap, one exemption list, one place to look. ## The case for per-route, and its real price Per-route wrapping is more explicit at each call site, which some teams genuinely prefer, and it is the natural shape when only a handful of routes are sensitive. The price is that correctness now depends on memory, and Go gives you no safety net: `http.ServeMux` exposes no way to list the patterns registered on it. You can ask it which handler answers a *given* request, but you cannot ask it what it knows about. So there is no test you can write that discovers an unwrapped route. The way to buy the safety net back is to stop calling `mux.Handle` directly. Register everything through one helper that records the pattern and forces an explicit choice — authenticated or deliberately public — and have a test walk that registry and fail on anything unclassified. Now 'we always remember' has been replaced by a mechanism, and that is the thing you owe the reviewer. ## The stronger control nobody reaches for first For an internal admin API, the best answer is often not middleware at all. Serve the admin surface from a second `http.Server` bound to an internal or loopback address, separate from the public listener. Network reachability is a coarser but far more robust control than a header comparison: a misconfigured route on a listener the internet cannot reach is a much smaller incident. The credential check still belongs there — defence in depth — but it is no longer the only thing standing between a forgotten handler and the world. ## Fail closed at startup too The same default-deny logic applies to configuration. If the expected secret is read from the environment and the variable is unset, the value is the empty string; a caller presenting an empty credential then matches. Validate at process start and refuse to serve, rather than logging a warning and continuing. A service that will not boot is an incident of minutes; a service that boots with no effective authentication is an incident of unknown length. ## Costs to be honest about Wrapping everything is not free. Probes that must answer before the service is ready cannot require credentials. Some internal tooling may call paths you did not think of. A migration from per-route to mux-wide will break callers you did not know existed, so it wants a period where the check logs what it would have rejected before it starts rejecting — with a hard end date, because a permanently permissive mode is the original problem wearing a disguise. ## How to present the decision Name the default you are choosing, name the exceptions and where the list lives, name the mechanism that keeps it honest (a registry and a test, not diligence), name the startup behaviour when the secret is missing, and name the blast radius if you are wrong. A reviewer who disagrees is then disagreeing with a specific line rather than with a vibe, which is the whole point of framing it this way.

  • Which endpoints legitimately sit outside the credential check, and how do you keep that list honest?
    Liveness and readiness probes, and whatever endpoint issues or exchanges the credential itself. Keep them as one explicit list in one file, reviewed like any other code, and assert its contents in a test so an addition shows up in a diff rather than in a handler somebody wrapped differently.
  • How would you enforce this so a new handler cannot ship unauthenticated?
    Stop calling mux.Handle directly. Register every route through one helper that records the pattern and requires an explicit authenticated-or-public choice, then have a test iterate that registry and fail on anything unclassified. Go's ServeMux cannot list its own patterns, so the registry is the only thing a test can walk.
  • When is a separate listener a better control than a credential middleware?
    When the admin surface should not be reachable from the public network at all. Bind a second http.Server to an internal address and serve the admin mux there. Reachability is coarser but far harder to get wrong than a header check, and the credential check still belongs on top of it.
  • The team wants to migrate from per-route to mux-wide checks. How do you sequence it?
    Run the check in a mode that logs what it would reject while still serving, for a bounded period, so unknown callers surface without an outage. Then flip to enforcing on a committed date. An unbounded permissive mode is just the original default-allow problem with extra logging.

saying these in an interview costs you the question

  • Argues per-route auth is safe because the team reviews every PR
  • Treats an unset secret as a warning and serves anyway
  • Says wrapping the mux is impossible because probes must stay public
  • Scatters the exemptions across handlers instead of one list
  • Leaves a log-only enforcement mode running with no end date
  • Presents the choice as a style preference rather than a default