The guard at the door
You are building a task manager. Anyone can read the public documentation, but only logged-in users can create or delete lists. You need a way to verify credentials before the route handler ever touches the business logic. Go does not ship with a built-in authentication decorator or a magic @auth annotation. Instead, it gives you a composable handler chain and leaves the security plumbing to you.
The standard approach wraps your route handlers in a middleware function. That wrapper inspects the incoming request, validates a token or cookie, and either stops the request with a 401 response or passes it downstream. When the request passes, the middleware attaches the authenticated user identity to the request context so every downstream function can read it without re-parsing the token.
Middleware is just a function that takes a handler and returns a handler. It sits between the router and your business logic. Think of it like a series of security checkpoints at an airport. Each checkpoint inspects your documents, stamps your passport, and hands you to the next line. If a checkpoint finds a problem, it stops you right there. The final destination never sees the request until every checkpoint has cleared it.
How middleware chains together
Go's net/http package defines a single interface for handling requests: http.Handler. It has one method, ServeHTTP(http.ResponseWriter, *http.Request). Any type that implements that method can handle a request. Middleware leverages this interface by returning a new http.Handler that wraps the original one.
When you chain middleware, you are building a stack. The outermost wrapper runs first. It performs its check, then calls next.ServeHTTP to hand control to the next layer. That layer runs its check, calls next.ServeHTTP again, and so on until the final handler executes. If any layer returns early, the chain stops. The request never reaches the inner handlers.
This pattern keeps your route handlers focused on business logic. You do not repeat token parsing in every endpoint. You write the validation once, wrap the routes that need it, and move on. The standard library's http.ServeMux accepts http.Handler values, which means you can wrap individual routes or entire sub-routers without changing the router configuration.
Middleware is composition, not inheritance. Wrap what you need, leave the rest exposed.
A minimal auth wrapper
Here is the simplest middleware that checks an Authorization header and passes the request along if the header exists. It demonstrates the signature, the early return pattern, and the next.ServeHTTP handoff.
package main
import (
"net/http"
"strings"
)
// AuthMiddleware wraps a handler and checks for a Bearer token.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract the header. Empty string means the client sent nothing.
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
// Stop the chain immediately. Return 401 and exit.
http.Error(w, "missing authorization header", http.StatusUnauthorized)
return
}
// Strip the "Bearer " prefix to isolate the raw token string.
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
// The prefix was not present. The format is wrong.
http.Error(w, "invalid token format", http.StatusUnauthorized)
return
}
// Token format looks correct. Pass control to the next handler.
next.ServeHTTP(w, r)
})
}
The wrapper returns an http.HandlerFunc because the standard library provides a convenient adapter. It lets you write a plain function with the correct signature and automatically satisfies the http.Handler interface. The next.ServeHTTP call is the bridge. Without it, the request dies in the middleware.
Middleware should fail fast. Return errors before doing expensive work.
Walking through the request lifecycle
When a client sends a request to a protected route, the router matches the path and calls the outermost handler. That handler is your middleware. It reads the header, validates the format, and calls next.ServeHTTP. Control moves to the next layer in the chain. If you have multiple middleware functions, they run in the exact order you wrapped them.
Once the request reaches your actual route handler, you need a way to access the authenticated user. Go provides context.Context for this exact purpose. The context travels with the request through every function call. You attach values to it, and downstream functions read them. The context also carries cancellation signals and deadlines, which makes it the standard way to pass request-scoped data.
You never modify the original request. The http.Request struct is treated as immutable in the standard library. Instead, you call r.WithContext(newCtx). That method returns a shallow copy of the request with the updated context. The original request remains unchanged, which prevents accidental state leaks across concurrent requests.
Context is plumbing. Run it through every long-lived call site.
Production-ready context handling
Real authentication requires parsing a token, verifying its signature, and extracting claims. JSON Web Tokens are the most common choice for stateless APIs. You parse the token, validate it against a secret key, and inject the user identifier into the context.
Here is the claims structure and a helper that parses the token safely. It uses a custom context key type to avoid collisions with other packages.
package main
import (
"context"
"errors"
"net/http"
"strings"
"github.com/golang-jwt/jwt/v5"
)
// contextKey is a custom type to prevent key collisions in context.
type contextKey string
const userIDKey contextKey = "userID"
// UserClaims holds the custom fields we expect inside the JWT.
type UserClaims struct {
UserID string `json:"user_id"`
jwt.RegisteredClaims
}
// parseToken validates the JWT and returns the claims if successful.
func parseToken(tokenString, secret string) (*UserClaims, error) {
claims := &UserClaims{}
// ParseWithClaims validates the signature and expiration automatically.
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
// Enforce HMAC signing to prevent algorithm confusion attacks.
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
return nil, err
}
return claims, nil
}
The custom contextKey type is a Go convention. If you use a plain string like "userID", another package might accidentally use the same string and overwrite your value. A custom type guarantees uniqueness. The parseToken function isolates the cryptographic validation. It checks the signing method to prevent algorithm confusion attacks, where an attacker swaps the expected HMAC verification for an unverified public key.
Here is the middleware that ties parsing to context injection, plus a handler that reads the value.
package main
import (
"context"
"encoding/json"
"net/http"
)
const secretKey = "replace-with-random-bytes-in-production"
// AuthMiddleware validates the JWT and attaches the user ID to context.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "missing authorization header", http.StatusUnauthorized)
return
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
if tokenString == authHeader {
http.Error(w, "invalid token format", http.StatusUnauthorized)
return
}
claims, err := parseToken(tokenString, secretKey)
if err != nil {
http.Error(w, "invalid or expired token", http.StatusUnauthorized)
return
}
// Create a new context carrying the authenticated user ID.
ctx := context.WithValue(r.Context(), userIDKey, claims.UserID)
// Pass the updated request to the next handler in the chain.
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// ProtectedHandler reads the user ID from context and returns a JSON response.
func ProtectedHandler(w http.ResponseWriter, r *http.Request) {
// Retrieve the value using the same custom key type.
userID, ok := r.Context().Value(userIDKey).(string)
if !ok {
http.Error(w, "user not found in context", http.StatusInternalServerError)
return
}
// Marshal the response safely instead of string concatenation.
resp := map[string]string{"message": "access granted", "user_id": userID}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)
}
The if err != nil pattern is verbose by design. The Go community accepts the boilerplate because it makes the unhappy path impossible to ignore. You cannot accidentally swallow an error behind a silent assignment. The handler uses a type assertion with the comma-ok idiom to safely extract the string. If the context does not contain the key, or if the value is the wrong type, ok becomes false and the handler returns a 500 error.
Never trust the context without a type check. Assert, verify, then proceed.
Where things go wrong
Authentication middleware looks simple until you hit edge cases. The most common mistake is using a plain string for the context key. The compiler will not catch it. You get context key collision at runtime, which usually manifests as a missing user ID or a type assertion failure. The compiler complains with interface conversion: context.Value is string, not string if you mix up types, but that error only appears when the assertion fails. Use a custom type for keys.
Another trap is forgetting to pass the context through long-running operations. If you spawn a goroutine to process a request, you must pass the context explicitly. Goroutines do not inherit the parent request context. If you forget, the background task runs forever even after the client disconnects. The worst goroutine bug is the one that never logs. Always attach a cancellation path.
Token validation errors also need careful handling. The jwt.ParseWithClaims function returns an error for expired tokens, invalid signatures, and malformed payloads. You should not expose the exact failure reason to the client. Return a generic 401 response. Logging the specific error internally helps with debugging without leaking security details.
If you try to mutate the request directly instead of using r.WithContext, the compiler rejects the program with cannot assign to field Context in r. The request struct fields are not exported for mutation. Copy the request, update the context, and pass the copy downstream.
Trust the standard library's imm guarantees. Copy the request, update the context, move on.
Picking your auth strategy
Authentication is not one-size-fits-all. The right choice depends on your deployment model, client type, and security requirements. Use JSON Web Tokens when you need stateless authentication across multiple services or microservices. Use session cookies when you are building a traditional web application where the server manages state and CSRF protection matters. Use API keys when third-party developers need long-lived access to machine-to-machine endpoints. Use OAuth2 delegation when users should grant your application access to their data on another platform without sharing credentials. Use mutual TLS when you are securing internal service mesh traffic and can manage certificate rotation. Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.