ALL POSTS
goapi keysmiddlewareapi design

Golang API key authentication middleware: the parts tutorials skip

The twelve-line API key middleware everyone ships is fine until customer number two. Here is what to build after it in Go, including the ResponseWriter wrapper that silently breaks streaming and the route label your auth layer cannot see.

Sorower

Sorower

Co-founder

Jul 28, 202614 min read
In this article

The auth middleware went in on a Tuesday. Twelve lines, code review took four minutes, tests green. On Thursday someone opened a ticket saying the events endpoint had stopped streaming. Not erroring. Not timing out. Just buffering everything and delivering it in one lump at the end, which for a server-sent-events feed is the same as being broken.

The Golang API key authentication middleware was correct, as far as authentication goes. It read the header, it checked the key, it called the next handler. It also wrapped http.ResponseWriter to capture the status code for usage logging, and that one line is what killed the streaming.

This post is about writing Golang API key authentication middleware that survives contact with real customers: comparing keys without leaking anything, storing them so customer number two doesn't trigger a rewrite, handing identity to the handler, and the two net/http behaviors that quietly break the moment your auth layer also wants to meter usage. Every snippet below was compiled and run against Go 1.26.4, and the output blocks are pasted from actual runs.

Five-step diagram of what API key middleware does on every request: extract the key, look it up, allow or deny, run the handler, record usage

Every Golang API key authentication middleware starts here

Here is the middleware in every "API key auth in Go" tutorial, including the ones ranking above this one:

func requireAPIKey(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("X-API-Key") != "supersecret123" {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}

Most articles follow this with "of course, don't do this in production," and then end. That is the unhelpful half of the internet. So: this snippet is genuinely fine. If you have one internal service, one consumer, and the key lives in an environment variable, ship it and go do something that matters.

It stops being fine at a specific, predictable moment, and the moment is not "in production." It is customer number two. That is when a single string becomes a set, a set needs storage, storage needs lookup, and lookup needs to tell you who just called so the handler can scope its query. Four problems arrive at once, and the twelve-line snippet has an answer for none of them.

Infographic of four things that break after the hardcoded key: comparison leaks timing, storage needs a rebuild, the handler cannot tell who called, and there is no way to revoke one key

Compare the key without answering questions

!= on a string returns as soon as it finds a differing byte. In theory an attacker measures that and walks the key one byte at a time. Go ships the fix in the standard library:

import "crypto/subtle"

func sameKey(got, want string) bool {
	return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}

Is a remote timing attack on an API key realistic? Honestly, usually not. You are measuring nanoseconds through a network stack that jitters by milliseconds, against a key with far too much entropy to brute-force even with a perfect oracle. Anyone telling you your == is an emergency is selling something.

Use the constant-time compare anyway, for the boring reason that it costs one import and removes an entire class of argument from your next security review. But know exactly what it does and does not promise. The standard library documentation says the time taken depends on the length of the inputs and not their contents, and then adds this:

If the lengths of x and y do not match it returns 0 immediately.

Read that again. It leaks length. Constant-time comparison protects the contents of the secret, never its size. That is fine for fixed-format keys and worth knowing before you rely on it for something else.

The bigger point: if you are comparing raw keys at all, you are storing raw keys, and that is the actual problem. Constant-time compare is a patch on a design you are about to replace.

Store the hash, index the prefix

Store a SHA-256 of the key, never the key itself. Two questions follow immediately.

Why not bcrypt or Argon2? Password KDFs are deliberately slow because passwords are low-entropy and users reuse them. An API key is 128+ bits of randomness your system generated, so there is nothing to brute-force, and you are verifying it on every single request rather than once per login. Deliberate slowness on the hot path is a self-inflicted latency budget. Plain SHA-256 is the right tool here.

How do you find the row without the plaintext? Give every key a prefix (acme_7f3d9c21…), store the prefix in the clear, index it, and look up by prefix then compare hashes. The prefix also gives leak scanners something to match on, which is why every vendor's keys look like that.

type Consumer struct {
	ID     string
	Name   string
	Scopes []string
}

type KeyStore interface {
	ByHash(ctx context.Context, prefix, hash string) (*Consumer, error)
}

func hashKey(raw string) string {
	sum := sha256.Sum256([]byte(raw))
	return hex.EncodeToString(sum[:])
}

Hand the handler an identity, not a string

The handler needs to know who called. The obvious move is context.WithValue(ctx, "consumer", c), and it is wrong: a plain string key sits in a namespace shared with every library in your binary, so any dependency using "consumer" silently overwrites yours. Use an unexported type so collision is impossible by construction:

type ctxKey struct{}

func WithConsumer(ctx context.Context, c *Consumer) context.Context {
	return context.WithValue(ctx, ctxKey{}, c)
}

func ConsumerFrom(ctx context.Context) (*Consumer, bool) {
	c, ok := ctx.Value(ctxKey{}).(*Consumer)
	return c, ok
}

An empty struct costs zero bytes and no other package can construct your unexported type. Now the middleware, with the failure paths written out, because a middleware without error handling is a demo:

func RequireAPIKey(store KeyStore) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			raw := bearer(r)
			if raw == "" {
				deny(w, http.StatusUnauthorized, "missing_api_key", "An API key is required.")
				return
			}
			prefix, _, found := strings.Cut(raw, "_")
			if !found {
				deny(w, http.StatusUnauthorized, "invalid_api_key", "The API key is invalid.")
				return
			}
			consumer, err := store.ByHash(r.Context(), prefix, hashKey(raw))
			if err != nil {
				deny(w, http.StatusServiceUnavailable, "key_lookup_failed", "Try again shortly.")
				return
			}
			if consumer == nil {
				deny(w, http.StatusUnauthorized, "invalid_api_key", "The API key is invalid.")
				return
			}
			next.ServeHTTP(w, r.WithContext(WithConsumer(r.Context(), consumer)))
		})
	}
}

Note the 503 when the store is unreachable, separate from the 401 for a bad key. Returning 401 on a database outage tells every customer their credentials broke, and they will all rotate keys at once, which is a genuinely miserable afternoon. That choice is a fail-closed policy, and it deserves a deliberate decision rather than a default: we wrote up the trade-off in fail open or fail closed.

Running it:

no key:
  status=401 body={"error":"missing_api_key","message":"An API key is required."}
wrong key:
  status=401 body={"error":"invalid_api_key","message":"The API key is invalid."}

The wrapper that quietly breaks streaming

Now the part no Go API-key tutorial covers, and the reason that ticket got opened on Thursday.

Authentication alone never needs the response. The moment you want usage data (status codes, bytes, latency, anything you would bill or chart) you need to observe what the handler wrote, and http.ResponseWriter gives you no way to ask. So everyone writes this:

type meteredWriter struct {
	http.ResponseWriter
	status int
}

func (w *meteredWriter) WriteHeader(status int) {
	if w.status == 0 {
		w.status = status
	}
	w.ResponseWriter.WriteHeader(status)
}

Embedding http.ResponseWriter promotes its three methods, so this compiles and passes every test you will think to write. It also silently discards every optional interface the real writer implemented. http.Flusher, http.Hijacker, http.Pusher are not part of ResponseWriter; handlers reach them with a type assertion, and a type assertion against your wrapper fails. Streaming handlers do not crash. They just stop flushing.

Here is the actual output of a handler checking both the old and new way, against an unwrapped writer, a plain wrapper, and a wrapper with an Unwrap method:

== unwrapped (baseline) ==
  handler sees http.Flusher via type assertion: true
  NewResponseController(w).Flush() error: <nil>
== naive wrapper ==
  handler sees http.Flusher via type assertion: false
  NewResponseController(w).Flush() error: feature not supported
== wrapper with Unwrap() ==
  handler sees http.Flusher via type assertion: false
  NewResponseController(w).Flush() error: <nil>

Go 1.20 added ResponseController precisely because, as the release notes put it, those optional interfaces "are not discoverable and clumsy to use." It walks the wrapper chain, and the documentation spells out the contract: pass it either the original writer your handler was given, or one exposing an Unwrap method that returns it.

So add Unwrap. But look hard at the third block above, because this is the part that will bite you: the type assertion still fails. Unwrap fixes ResponseController and nothing else. Any handler or third-party library still writing w.(http.Flusher), which is most code written before 2023, remains broken. Fixing one caller is not fixing the wrapper.

If you control the handlers, add Unwrap and move them to ResponseController. If you do not, implement the methods too, delegating through the controller so you are not re-implementing the chain:

func (w *meteredWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

func (w *meteredWriter) Flush() {
	_ = http.NewResponseController(w.ResponseWriter).Flush()
}
Comparison of three response writers: no wrapper streams correctly, a plain wrapper silently breaks streaming, and a wrapper with Unwrap streams correctly again

How do you test for this? Assert the capability, not the bytes. One test that calls http.NewResponseController(w).Flush() inside a handler and requires a nil error will fail the day someone adds a wrapper without Unwrap. Without it, nothing in your suite notices, because a buffered response and a streamed response contain identical bytes. That is exactly why it reached Thursday.

Your middleware cannot see the route it is protecting

The second trap is about labels, and it decides whether your usage analytics are useful or garbage.

Go 1.22 taught ServeMux methods and wildcards, so you register "GET /users/{id}". Go 1.23 added Request.Pattern, which "contains the ServeMux pattern (if any) that matched the request." That field is what you want on a usage event: group by GET /users/{id} and you get one row per endpoint, group by r.URL.Path and you get one row per user ID, which is not analytics, it is a cardinality bomb with a bill attached.

The catch is when the field is populated. Middleware that wraps the mux runs before the mux has matched anything. A probe reading r.Pattern on both sides of next.ServeHTTP:

GET /users/42
  before next.ServeHTTP: r.Pattern=""
  after  next.ServeHTTP: r.Pattern="GET /users/{id}"
GET /health
  before next.ServeHTTP: r.Pattern=""
  after  next.ServeHTTP: r.Pattern="GET /health"
GET /nope
  before next.ServeHTTP: r.Pattern=""
  after  next.ServeHTTP: r.Pattern=""

Empty on the way in, populated on the way out, because ServeMux fills the field in on the same request value your middleware is holding. Which gives a rule worth writing on a sticky note:

Metering can see the route. Authorization cannot.

Record usage in a defer and you get the route template for free, even from middleware wrapping the whole mux. But any decision you make before calling the handler is made blind: per-route credit costs, route-specific rate limits, scope checks per endpoint. At that instant the router has not run, and the only thing you have is a raw path. An unmatched request stays empty forever, so label it explicitly rather than letting a 404 masquerade as a real endpoint.

Your three options, in the order I would try them: register the middleware per route so it runs inside the match; do the routing decision yourself with a small path matcher for the handful of endpoints that need different pricing; or accept a flat cost per request and let the metering layer sort out attribution afterwards. Same asymmetry applies to any router that resolves the route after your outer middleware has already started, which is most of them.

Wired up, with metering deferred:

func Meter(record func(UsageEvent)) 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()
			mw := &meteredWriter{ResponseWriter: w}
			defer func() {
				route := r.Pattern
				if route == "" {
					route = "unmatched"
				}
				var id string
				if c, ok := ConsumerFrom(r.Context()); ok {
					id = c.ID
				}
				if mw.status == 0 {
					mw.status = http.StatusOK
				}
				record(UsageEvent{
					ConsumerID: id, Method: r.Method, Route: route,
					Status: mw.status, Latency: time.Since(start),
				})
			}()
			next.ServeHTTP(mw, r)
		})
	}
}
GET /users/42
  usage: consumer=consumer_42 GET route="GET /users/{id}" status=200
GET /users/98371
  usage: consumer=consumer_42 GET route="GET /users/{id}" status=200

Two different user IDs, one route label. That is the whole point.

Common errors

  • Status recorded as 0. A handler that only calls Write never calls WriteHeader, so your counter never fires. Default to 200 when Write runs, and again before recording.
  • Every request logged as 200 despite panics. A panic unwinds past your recording code unless it is in a defer. Put the record call in the deferred function, then let the panic continue.
  • Auth applied to /health. Wrapping the whole mux protects your health check too, and your load balancer does not have a key. Keep an explicit skip list, and remember it is matched against the raw path.
  • Middleware order inverted. RequireAPIKey(Meter(mux)) and Meter(RequireAPIKey(mux)) differ in whether rejected requests are metered. Neither is wrong; pick deliberately, since the second is what tells you a customer has been hammering you with a revoked key.
  • Keys in query strings. They land in access logs, proxy logs, and browser history. Header or bust.

When to stop building this

Everything above is maybe 150 lines and a table. What follows is not: key issuance and revocation UI, per-consumer quotas, refills, an audit trail, getting a revocation to every place that caches a decision, and a dashboard so support can answer "why did my key stop working" without you.

Rolling your own key layer is a two-day project that ships in six weeks. Sometimes six weeks is the right call, and if keys are your product, build them.

Otherwise, ReqKey's Go SDK is the same middleware contract you have been reading, with validation and metering already correlated. It is published as github.com/Req-Key/reqkey-go and requires Go 1.25 or newer, because the current framework adapters target Gin, Echo and Fiber releases that do:

protect := reqkey.MustHTTPMiddleware(reqkey.MiddlewareOptions{
	ProjectKey: os.Getenv("REQKEY_PROJECT_KEY"),
	APIID:      "api_payments",
})

_ = http.ListenAndServe(":8080", protect(mux))

Inside the handler, reqkey.DecisionFromContext(r.Context()) returns the decision with CreditsRemaining, RequestID and Reason. Being a plain func(http.Handler) http.Handler, it drops into Chi and Gorilla/Mux unchanged.

Two things worth knowing before you reach for it. It defaults to fail-closed, so a ReqKey outage rejects your traffic until you set FailureMode: reqkey.FailureOpen, and that default is a decision you should make rather than inherit. And the default mode validates and ingests, which means one incoming customer request costs two billable ReqKey requests; if you only need the gate, run validate-only and halve it. The free tier is 100,000 requests a month, which is enough to run this properly against real traffic before deciding.

Also worth noting for the route problem above: the SDK labels usage with the request path by default, and exposes a PathResolver hook precisely so you can hand it the route template instead. Same trap, same fix.

Doing this in another language? We wrote the same walkthrough for Express and FastAPI, and the Express one has its own ordering trap that Go does not share.

If you would rather hear the key-design half from someone who is not us, Zuplo's Josh Twist covers issuance, prefixes and rotation well:

Video thumbnail: Don't build an API Key service until you watch this, API key best practices from Zuplo

Key takeaways

  • The hardcoded key is fine until customer number two, not until production. That is the moment one string becomes storage, lookup, identity and revocation all at once, so plan the seam before you are under pressure.
  • Hash keys with SHA-256 and index a prefix; skip bcrypt and Argon2. Slow KDFs exist to protect low-entropy passwords verified once per login. A random API key verified on every request needs a fast hash and an indexed lookup.
  • Any wrapper around ResponseWriter needs an Unwrap method, and probably a Flush too. Unwrap satisfies ResponseController but not the older w.(http.Flusher) assertion, so wrappers break streaming in a way no byte-comparison test catches. Assert the capability in a test.
  • Read r.Pattern after the handler returns, never before. It is empty when your auth decision runs and populated by the time metering records, so label usage by route template and stop your dashboards exploding into one row per ID.
  • Return 503, not 401, when your key store is down. A 401 tells every customer at once that their credentials are broken, and they will all rotate simultaneously.

If you want the metering half without writing the wrapper twice, the Go SDK already handles the Unwrap chain and correlates each validation with its usage event. Start with validate-only mode, point it at a staging API, and see what your route labels look like before you commit to anything.

Share this post

Put your API keys on autopilot.

Keys, credits, plans, and real-time traffic analytics — free for your first 100k requests a month.