Dev Skills

Step 4 — Checking the token on every request

One middleware, at the edge, so no handler ever has to remember.

Topic 69 of 98 · 5 min to read · 50 min to build
After this topic you can
  • Write auth middleware that checks a Bearer token and puts the user ID in the context.
  • Explain why the context key should be an unexported struct type.
  • Test the middleware against missing, expired and tampered tokens.

Every command for this step

terminal — run these in order
touch internal/middleware/auth.go
go build ./... && go run ./cmd/api
# no token
curl -si localhost:8080/v1/me | head -1 # 401
# rubbish token
curl -si -H "Authorization: Bearer rubbish" localhost:8080/v1/me | head -1 # 401
# valid token
curl -si -H "Authorization: Bearer $TOKEN" localhost:8080/v1/me | head -1 # 200
# tampered signature
BAD="${TOKEN%?}X"
curl -si -H "Authorization: Bearer $BAD" localhost:8080/v1/me | head -1 # 401

The problem

Every protected route has the same four questions: is a token present, is the signature valid, has it expired, and which user is it? Writing that in ten handlers means repeating it ten times and, sooner or later, forgetting it once. A forgotten check raises no error; it makes a route public.

So it goes in middleware: one function that wraps a handler, runs before it, and either rejects the request or lets it through.

How the client sends the token

the convention
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.4pRXK...
# "Bearer" means: whoever bears this token is treated as that user.
# Which is exactly why it must travel over HTTPS and never appear in a URL.

File 1 — the middleware

internal/middleware/auth.go — create this file
package middleware
// An unexported type, so no other package can collide with or read our key.
type ctxKey struct{}
func RequireAuth(secret []byte) 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 || raw == "" {
httpx.Error(w, 401, "missing bearer token")
return
}
tok, err := jwt.ParseWithClaims(raw, &jwt.RegisteredClaims{},
func(t *jwt.Token) (any, error) {
// CRITICAL — see the warning below
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return secret, nil
})
if err != nil || !tok.Valid {
httpx.Error(w, 401, "invalid token")
return
}
sub, err := tok.Claims.GetSubject()
id, convErr := strconv.ParseInt(sub, 10, 64)
if err != nil || convErr != nil {
httpx.Error(w, 401, "invalid token")
return
}
// Pass the user id down to the handler through the context.
ctx := context.WithValue(r.Context(), ctxKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// UserID is the only way any other package reads it.
func UserID(ctx context.Context) (int64, bool) {
id, ok := ctx.Value(ctxKey{}).(int64)
return id, ok
}
Watch out: That signing-method check is not optional: it stops the token from choosing how it is verified. golang-jwt already refuses "alg": "none" unless you opt in, but other libraries and older versions have accepted unsigned tokens. A server that also holds RSA keys can be tricked into checking an HS256 signature using its public key as the secret. Either mistake is a complete authentication bypass, and both have shipped in real libraries.

Why the context key is a struct

Context values are looked up by key, and a key can be any comparable value. If the key were the string "userID", any other package, including a library you did not write, could use the same string and read or shadow your value.

An unexported type fixes it. ctxKey{} from your package is a different key from ctxKey{} in anyone else’s, because the types differ. Export a typed function like UserID and never export the key itself.

File 2 — applying it, one route at a time

cmd/api/main.go
protect := middleware.RequireAuth(cfg.JWTSecret)
// public
mux.HandleFunc("POST /v1/auth/register", userH.Register)
mux.HandleFunc("POST /v1/auth/login", userH.Login)
// protected — mux.Handle, because protect returns an http.Handler
mux.Handle("GET /v1/me", protect(http.HandlerFunc(userH.Me)))
mux.Handle("POST /v1/expenses", protect(http.HandlerFunc(expH.Create)))
mux.Handle("GET /v1/expenses", protect(http.HandlerFunc(expH.List)))
Wrapping route by route is on purpose. "Protect everything under /v1 except these two" is easy to get subtly wrong, and a route that is public by accident is the most damaging bug this file can have.

File 3 — reading it in a handler

internal/user/handler.go
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserID(r.Context())
if !ok {
httpx.Error(w, 401, "unauthorized") // belt and braces
return
}
u, err := h.svc.GetByID(r.Context(), userID)
if err != nil {
httpx.FromError(w, err)
return
}
httpx.JSON(w, 200, u)
}

Prove it works, including the attacks

terminal
curl -si localhost:8080/v1/me | head -1
# 401 — no token
curl -si -H "Authorization: Bearer rubbish" localhost:8080/v1/me | head -1
# 401 — not a token
curl -si -H "Authorization: Bearer $TOKEN" localhost:8080/v1/me | head -1
# 200 — and the body is your own user
# change one character of the signature
BAD="${TOKEN%?}X"
curl -si -H "Authorization: Bearer $BAD" localhost:8080/v1/me | head -1
# 401 — the signature no longer matches
The short version
  • Check the token once, in middleware, so no handler can forget to.
  • Always assert the signing method, so a token cannot choose how it is verified.
  • Use an unexported struct type as the context key, and export a typed accessor.
  • Wrap protected routes individually so nothing becomes public by accident.
Remember: Authentication happens once, at the edge. Everything downstream reads the user id from the context and never parses a token again.
Build this before moving on
Add the middleware and GET /v1/me. Then run all four curl checks above, including the tampered signature.
Then try these in your own editor
  1. 1Hand-craft a token with "alg":"none" and confirm yours rejects it.
  2. 2Remove the signing-method check, try the same token again, and work out what is still protecting you. Then put the check back.
  3. 3Register a route without protect and see how easy that is to miss in a diff.
  4. 4Call middleware.UserID from a handler that is not behind the middleware and handle the false.
Check yourselfTake the Project 2 — Expense tracker test