Test Middleware and Authorization Rules in Go Handlers
Test Go auth middleware with a table of requests: bad tokens get 401, a wrong role gets 403 and the right role reaches the handler. No server is needed.

Authorization code is short, it sits in front of everything, and a mistake in it is a security hole, not a bug. That is the profile of code that deserves an exhaustive test. It is also easy to test: a middleware is a function from handler to handler, and a table of requests exercises it completely without a server.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This article tests a bearer token middleware on Go 1.25.6 with a table of six requests, and lists the cases that production middleware usually forgets.
The middleware
package main
import (
"net/http"
"strings"
)
type User struct {
Name string
Role string
}
var tokens = map[string]User{
"t-ada": {"ada", "admin"},
"t-grace": {"grace", "viewer"},
}
// Require wraps h: no token is 401, a token without the role is 403.
func Require(role string, h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
u, known := tokens[tok]
if !ok || !known {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if u.Role != role {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
w.Write([]byte("hello " + u.Name))
})
}
func main() {}Require(role, next) returns a handler. It reads the Authorization header, requires the Bearer prefix, looks the token up, and then compares the user's role. No token or an unknown one is a 401 with WWW-Authenticate: Bearer, as the HTTP specification requires for a challenge. A known user with the wrong role is a 403. The handler for the success path is inline here, to keep the lab short; in real code it would call next.
The distinction between the two statuses is the core of the logic. 401 Unauthorized means the request has no valid credentials, so authenticate and try again. 403 Forbidden means the credentials are fine and the answer is still no. A middleware that returns 403 for a missing token, or 401 for a wrong role, makes clients do the wrong thing: refresh a token that was valid, or give up on one that was missing.
The table
package main
import (
"net/http/httptest"
"testing"
)
func TestRequire(t *testing.T) {
next := Require("admin", nil)
cases := []struct {
name string
header string
want int
}{
{"no header", "", 401},
{"wrong scheme", "Basic t-ada", 401},
{"unknown token", "Bearer nope", 401},
{"viewer on admin route", "Bearer t-grace", 403},
{"admin", "Bearer t-ada", 200},
{"token without prefix", "t-ada", 401},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/admin", nil)
if c.header != "" {
req.Header.Set("Authorization", c.header)
}
rec := httptest.NewRecorder()
next.ServeHTTP(rec, req)
t.Logf("status=%d body=%q www-authenticate=%q", rec.Code, rec.Body.String(), rec.Header().Get("WWW-Authenticate"))
if rec.Code != c.want {
t.Fatalf("got %d want %d", rec.Code, c.want)
}
})
}
}go test -count=1 -v . 2>&1 | grep -vE "^(=== RUN|=== PAUSE|=== CONT|PASS|ok)" | sed -E "s/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #" status=401 body="unauthorized\n" www-authenticate="Bearer"
status=401 body="unauthorized\n" www-authenticate="Bearer"
status=401 body="unauthorized\n" www-authenticate="Bearer"
status=403 body="forbidden\n" www-authenticate=""
status=200 body="hello ada" www-authenticate=""
status=401 body="unauthorized\n" www-authenticate="Bearer"
--- PASS: TestRequire
--- PASS: TestRequire/no_header
--- PASS: TestRequire/wrong_scheme
--- PASS: TestRequire/unknown_token
--- PASS: TestRequire/viewer_on_admin_route
--- PASS: TestRequire/admin
--- PASS: TestRequire/token_without_prefixEach row is a name, an Authorization header and the expected status. The subtest builds a request with httptest.NewRequest, calls ServeHTTP on a recorder and logs what came back. The structure is the one from the recorder article: no port, no network, the whole handler chain in memory.
The log shows the contract plainly:
- No header, wrong scheme, unknown token, bare token:
401, bodyunauthorized, andWWW-Authenticate: Bearer. - Viewer on an admin route:
403, bodyforbidden, and no challenge header, because authenticating again will not help. - Admin:
200andhello ada.
The table asserts only the status, and the log lines record the rest. In your own tests, assert the header on the 401 rows as well. A missing WWW-Authenticate on a 401 is a specification violation that some clients handle badly, and a one-line check catches it.
The malformed cases
The interesting rows are the ones that look like they should work. Basic t-ada has a valid token and the wrong scheme. A middleware using strings.Contains(header, token) or strings.TrimPrefix without checking that the prefix was present would accept it. The row t-ada with no scheme at all is the same bug from a different angle. Add rows until they feel excessive:
- lower case
bearer, which the scheme comparison should treat case-insensitively according to the HTTP specification, so decide and test your choice; - two spaces after
Bearer, and a trailing space; - an empty token after the prefix,
Bearer; - a very long token, to check that lookup does not misbehave;
- a token in the query string instead of the header, which should not be accepted unless you designed for it;
- multiple
Authorizationheaders.
Each of these is a way real clients and attackers produce malformed input, and each is a place where a lenient parser turns into a bypass. Because the table is cheap, the cost of another row is one line.
Test the rule, not only the code
A table over statuses proves the middleware does what it does. The authorization rule is about which users can reach which routes, and that is a matrix. If a project has roles and routes, write the matrix as data and test every cell: a loop over routes and roles that asserts allowed or denied. The matrix catches the route that was added last month without the middleware, which no test of the middleware itself can find. Build the router the way production does, and send each request through the outermost handler.
Order matters when there are several middlewares. A rate limiter before authentication spends budget on unauthenticated traffic, and one after it lets attackers probe tokens without limit. Logging after authentication misses failed attempts. Test the chain as assembled, not each piece alone, and include a case where an earlier middleware short-circuits and the later ones must not run.
Identity inside the handler
On success the middleware usually puts the user in the request context, and the handler reads it. Test that the handler sees the right user, and that a request without the middleware gets the zero value or an error, not another request's user. The context key should be an unexported type so that other packages cannot collide with it, and a test can check that a user set by one request does not leak into the next when handlers run concurrently, which the race detector helps with.
Failing closed
The safest default for an authorization layer is to deny. A middleware that returns early on every error path cannot accidentally let a request through, and one that sets a flag and falls through to the handler can. When you review or test the code, look for the path where an error is logged and the request continues. A test for it needs a way to make the lookup fail: a store that returns an error, a token with an invalid signature, a panic in the verifier. The expected result is a 401 or 500, never a 200, and the handler must not run. Count the handler calls in the test with a counter in the inner handler, and assert zero on every rejected row.
For routes that have no middleware by design, such as health checks and the login endpoint, keep an explicit allow list in the test and fail when a route appears that is on neither list. New routes then have to be classified on purpose. The standard testing pieces for handlers are described in the httptest documentation, and the routing side is covered in the testing strategy article.
What this does not cover
Fixed tokens in a map are a stand-in. A real system validates a signed token (a JWT, a session cookie) and the tests for that need expired, not-yet-valid, wrongly signed, and wrong-audience tokens, with a clock they control. Those cases are covered in the articles on token handling, and they follow the same table pattern. Authorization tests also cannot show that the secret is stored well or that TLS is on; those are deployment checks. The OWASP authorization cheat sheet lists the failure classes worth a row each, and the RFC 9110 definition of 401 is the source for the challenge header. For the testing technique itself, return to the recorder article and the testing guide.


