A Go scaffold should commit to the decisions that get expensive later — cmd/ for binaries, internal/ for everything the compiler should keep private, handlers that don't know how storage works — and it should be loud about everything it hasn't built yet. It should not commit to pkg/, and it should not describe itself as the standard Go project layout, because there is no such thing: the golang-standards/project-layout repository is unaffiliated with the Go team, and Russ Cox opened an issue on it in 2021 to say so. Keytide, mine, gets the first half roughly right and the second half wrong in several places, and this post walks through both.
Update, September 2026: The scaffold has since been changed on the strength of this piece: the middleware is now actually applied (it wasn't), isValidToken is gone in favour of a verifier that fails closed, routes are pinned to methods, logging is log/slog, and the middleware has tests. Real JWT verification is still absent. What follows describes the code as it stood, because that is what the argument is about.
I wrote the original version of this in June 2025. Coming back in September 2026, the repository is still exactly what it was, so nothing below is a redemption arc.
What is actually in the repository#
cmd/api/main.go
internal/
server/server.go
handlers/product.go + product_test.go
repository/product.go + product_test.go
models/product.go
pkg/
middleware/auth.go
middleware/logging.go
utils/response.go
api/openapi.yaml
configs/app.env
Makefile Dockerfile.dev docker-compose.yml .air.tomlgo.mod says go 1.24.4 and declares no dependencies at all. Everything here is standard library, which I like more now than I did then — it's the same pleasure as building a shop with no framework, where nothing between you and the platform can be blamed for anything.
Two corrections to what I originally claimed for it. api/openapi.yaml is zero bytes — I listed it among the things the scaffold got right, and it is an empty file with an ambitious name. And the handler doesn't use the repository: GetProducts returns a hardcoded slice of two products, while InMemoryProductRepository holds the same two products in a map that nothing reads. The layer separation I was congratulating myself for exists as directories, not as calls.
Is there a standard Go project layout?#
No, and I should not have written that sentence.
What I called "the standard Go project layout" is golang-standards/project-layout, a community repository with a GitHub org name that does a lot of unearned work. On 9 April 2021 Russ Cox opened issue #117 on it, titled "this is not a standard Go project layout", arguing that even the softer claim — that it collects common patterns from the Go ecosystem — isn't accurate, that most Go repositories are considerably simpler, and that the vast majority don't use a pkg/ directory at all. It is not a Go team artefact and never was.
The nearest thing to official guidance is Organizing a Go module on go.dev, and it's structured as a progression rather than a template: a single package, then a single command, then a command with supporting packages in internal/, then multiple commands under cmd/, then a server project. It recommends internal/. It recommends cmd/ once you have more than one binary or a mix of binaries and libraries. It never mentions pkg/.
So the honest version of my original claim is narrower: I adopted a widely-copied community layout, and two of its three directories happen to match official guidance. That's a defensible thing to do. Calling it "standard" was me borrowing authority I hadn't checked.
Why internal/ earns its place and pkg/ doesn't#
This is the part of the original post I still stand behind, with one correction.
internal/ is real. It's a rule in the compiler, not a convention: a package under internal/ can only be imported by code rooted at the parent of that internal/ directory. Nothing outside the module can import github.com/acekavi/keytide/internal/repository, and there's no flag to make it. That means every type in there can change shape without a major version bump, which is exactly the freedom you want while a service is being figured out.
pkg/ is not real. I described it as "a public commitment that anything in there is importable" — that's true but backwards, because everything not under internal/ is importable whether or not it sits in a directory called pkg. The toolchain gives pkg/ no meaning at all. It's a comment you write with mkdir.
Which leaves Keytide with pkg/middleware and pkg/utils exported to the entire internet, imported by nobody, including Keytide — pkg/utils/response.go defines JSONResponse and JSONError that the product handler doesn't call, encoding its own JSON instead. If I were starting again I'd have one directory, internal/, and I'd promote things out of it the first time an external consumer actually existed. Structure that costs nothing to add later shouldn't be added early. Structure that's expensive to retrofit should — which is the argument I made for getting the entity model right on day one, and it holds here too, just for a smaller set of decisions than I thought.
Do you still need a third-party router in Go?#
For most services, no — and this changed the calculus after I first wrote this.
Go 1.22 (February 2024) taught the standard library's http.ServeMux two things it had lacked for a decade: method matching and path wildcards. A pattern is now "GET /products/{id}". {id} matches one path segment, {path...} matches all remaining segments, {$} anchors an exact match on a trailing slash, and r.PathValue("id") reads the capture. Overlapping patterns resolve by specificity — the pattern that matches fewer requests wins — and genuinely ambiguous registrations panic at startup rather than silently picking one. If you need the pre-1.22 literal-brace behaviour, GODEBUG=httpmuxgo121=1 restores it.
Keytide is on Go 1.24.4 and registers s.Router.HandleFunc("/products", handlers.GetProducts) — no method, no wildcard, a POST to that path hitting the same handler as a GET. That's a pre-1.22 line of code written after 1.22 shipped. The version it should be:
mux := http.NewServeMux()
mux.HandleFunc("GET /products", h.List)
mux.HandleFunc("GET /products/{id}", h.Get) // h.Get reads r.PathValue("id")
mux.HandleFunc("POST /products", h.Create)The interesting consequence is for middleware, not routing. Because ServeMux is itself an http.Handler, and because the middleware signature func(http.Handler) http.Handler composes handlers rather than hooking into a framework, the routing improvements cost you nothing in composability. Chi, Echo and Gin all use the same signature underneath. Choosing the standard shape in 2025 meant that a standard-library upgrade in 2024 made the code better without touching it.
The auth middleware validates nothing, and says so#
// isValidToken checks if the provided token is valid
func isValidToken(token string) bool {
// In a real application, you would validate the token properly
return strings.HasPrefix(token, "Bearer ")
}Any string beginning with Bearer passes. Bearer x passes.
I'm keeping this in the post because the comment is the load-bearing part. A placeholder that announces itself is a different artefact from one that looks finished: the first is a TODO with a compiler-checked home, the second is a vulnerability waiting for the next person to assume it works. Security code fails silently in the happy direction — nothing about a test suite going green tells you a token was checked.
The shape is right, which is the whole point of a scaffold. It's func(http.Handler) http.Handler, so replacing the body changes one function and nothing else.
The middleware is never applied to anything#
Here's what I missed the first time, and it's worse than the placeholder.
cmd/api/main.go builds the mux, registers the products route, and calls http.ListenAndServe(":8080", s.Router). AuthMiddleware and LoggingMiddleware are compiled, exported, documented — and never wired in. The endpoint is unauthenticated, not weakly authenticated. My "honest placeholder" defence was itself slightly dishonest, because the placeholder isn't even in the request path.
Wiring is three lines, and the order matters:
var handler http.Handler = mux
handler = middleware.AuthMiddleware(handler) // inner: runs second
handler = middleware.LoggingMiddleware(handler) // outer: runs first, sees the 401
http.ListenAndServe(":8080", handler)Logging outermost so rejected requests are still logged. An auth layer that turns requests away invisibly is how you find out about a credential-stuffing run from someone else.
The logging middleware should be log/slog#
pkg/middleware/logging.go calls log.Printf("Received request: %s %s", ...). log/slog has been in the standard library since Go 1.21 (August 2023), which predates this repository, so this is a straight miss rather than a dated choice. Structured output is not a nicety at the point where you want to ask "which IPs got 401s in the last hour", and log.Printf can't answer that without a regex.
It also logs the wrong half of the transaction: request in, nothing about what came out. http.ResponseWriter doesn't expose the status after the fact, so you wrap it.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func LoggingMiddleware(log *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
log.InfoContext(r.Context(), "request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rec.status),
slog.Duration("took", time.Since(start)),
)
})
}
}Taking the logger as a parameter and returning func(http.Handler) http.Handler keeps the composable shape while removing the global. Tests get a handler writing to a buffer instead of stderr.
What real JWT verification looks like in the same shape#
Replacing isValidToken is one function's work, and it is the function where a scaffold stops being a scaffold. github.com/dgrijalva/jwt-go is archived; the maintained successor is github.com/golang-jwt/jwt, currently at v5.3.1 (January 2026).
The trap is that verifying a signature is not the same as verifying a token, and the classic version of that mistake is jwt.Parse with a key function that returns a key without checking token.Method. That's the alg: none and algorithm-confusion family — the canonical disclosure is CVE-2015-9235, where libraries trusted the alg header the attacker sent. An attacker sets "alg": "none" and supplies no signature, or flips an RS256 verifier to HS256 and signs with your RSA public key as the HMAC secret (CVE-2016-10555), because the public key is, by definition, something they have. Never allow a symmetric and an asymmetric algorithm in the same allowlist.
func AuthMiddleware(keys jwt.Keyfunc) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
claims := &jwt.RegisteredClaims{}
if _, err := jwt.ParseWithClaims(raw, claims, keys,
jwt.WithValidMethods([]string{"RS256"}), // reject none, reject HS*
jwt.WithIssuer("https://keytide.local"),
jwt.WithAudience("products-api"),
jwt.WithExpirationRequired(),
); err != nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), subjectKey, claims.Subject)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}Four parser options, each closing a different hole. WithValidMethods pins the algorithm so the header can't choose it. WithExpirationRequired means a token with no exp is rejected rather than treated as eternal. WithIssuer and WithAudience stop a valid token minted for a different service being replayed against this one — signature validity is not authorisation. The outer signature is still func(http.Handler) http.Handler; only the constructor changed, to take the key source.
I tested the CRUD and not the security decision#
internal/handlers/product_test.go
internal/repository/product_test.goTwo test files, both on layers doing CRUD, none on the layer making a security decision. That inversion is common and worth naming: CRUD is easy to test, so it gets tested; auth needs key fixtures, expiry cases and a forged token or two, so it waits. It's the wrong way round. A broken handler returns wrong data. A broken auth middleware returns anyone's data.
The excuse doesn't survive contact with httptest, because a middleware is just an http.Handler and the whole test is ten lines. The assertion that matters isn't the status code — it's whether the next handler ran at all.
func TestAuthMiddlewareRejectsUnsignedToken(t *testing.T) {
var reached bool
next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })
req := httptest.NewRequest("GET", "/products", nil)
req.Header.Set("Authorization", "Bearer "+algNoneToken(t))
rec := httptest.NewRecorder()
AuthMiddleware(testKeys)(next).ServeHTTP(rec, req)
if reached {
t.Fatal("handler ran for an unsigned token")
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnauthorized)
}
}The reached flag is the point. A middleware that writes 401 and calls next passes a status-code assertion and leaks the body underneath it. Then the table repeats for expired, wrong issuer, wrong audience, missing header, and a token signed with the right algorithm but the wrong key — five cases you cannot write against strings.HasPrefix, which is the real reason they weren't written.
The pattern generalises: test the decision, not the layer that's convenient. It's the same error I made with a music player, where I tested React state while the audio element owned the state that mattered.
The README promises seven things the code doesn't do#
The README lists JWT and OAuth2 and RBAC, a gRPC-first API, Kafka event sourcing, mTLS and token revocation, Prometheus metrics and tracing, and auto-generated SDKs for multiple languages. There is no status section, no roadmap, and no getting-started. The code is one HTTP mux and a hardcoded product list.
Writing the ambitious README first is a legitimate technique — design by press release, and it forces you to articulate what you're building. The failure mode is that a reader has no way to separate aspiration from status, and on a repository with your name on it that reads as overstatement rather than intent. It's also load-bearing for other people's judgement: someone evaluating whether to depend on this gets seven green ticks and no way to check them.
The fix costs one line at the top: Status: scaffold. HTTP server and layout in place; gRPC, Kafka, and all authentication features not started. That converts the document from a claim into a roadmap, and it's still not there.
What the scaffold gets right, and what it doesn't have#
Structure before content, with the list trimmed to what's true. The cmd/ and internal/ split is right and internal/ is enforced. The middleware signature is right, and got better for free when the standard library's router grew up. The repository interface is the right abstraction even though nothing calls it yet. Zero dependencies is a real asset. pkg/ was cargo cult, the OpenAPI file is empty, and the middleware isn't plugged in.
What it doesn't have is the hard part. Token issuance and key rotation, RBAC evaluation that's fast enough to sit in every request path, revocation that works across instances when the token is stateless by design, an event stream that stays correct while a consumer is down — the kind of thing that has to survive being killed halfway through, except with someone's session data on the line. Those are where an IAM service is actually difficult, and none of them are made easier by having chosen a good folder layout. They're just made possible.
Common questions#
Is golang-standards/project-layout an official Go standard?#
No. It's a community repository, unaffiliated with the Go team, whose org name suggests otherwise. Russ Cox opened issue #117 on it in April 2021 arguing it isn't a standard and doesn't accurately reflect common Go practice, noting that most Go repositories are simpler and don't use pkg/. The closest official guidance is "Organizing a Go module" on go.dev, which presents layouts as a progression tied to project size and never mentions pkg/.
What's the actual difference between internal/ and pkg/ in Go?#
internal/ is enforced by the compiler: packages under it can only be imported by code rooted at that directory's parent, so nothing outside your module can depend on them. pkg/ has no meaning to the toolchain whatsoever — anything not under internal/ is importable regardless of which directory it sits in. internal/ is a rule; pkg/ is a comment.
Do I still need Chi or Gorilla Mux after Go 1.22?#
Usually not. Go 1.22 gave http.ServeMux method-based patterns and wildcards — mux.HandleFunc("GET /products/{id}", h) with r.PathValue("id") — plus specificity-based precedence and a startup panic on ambiguous registrations. Third-party routers still win on route groups, per-group middleware and regex constraints. Since they all use the same func(http.Handler) http.Handler middleware signature, starting on the standard library costs you nothing if you later switch.
How do I verify a JWT in Go without hitting the alg:none vulnerability?#
Use github.com/golang-jwt/jwt/v5 (v5.3.1 as of January 2026; dgrijalva/jwt-go is archived) and never let the token's own header choose the algorithm. Pass jwt.WithValidMethods([]string{"RS256"}) so none and HMAC downgrades are rejected outright, and keep symmetric and asymmetric algorithms out of the same allowlist. Then validate claims, not just the signature: WithExpirationRequired, WithIssuer and WithAudience, because a correctly signed token minted for another service is still not authorisation to call yours.
How do you unit test HTTP middleware in Go?#
A middleware is an http.Handler, so wrap a stub handler with it and drive it with httptest.NewRequest and httptest.NewRecorder. The assertion that matters most is a boolean recording whether the wrapped handler ran — a middleware that writes 401 but forgets to return will pass a status-code check while still serving the body. Table-drive it across expired, wrong-issuer, wrong-audience, wrong-key and missing-header tokens.