API Gateway Concerns
Handle authentication, rate limiting and request shaping once at the gateway instead of in every service behind it.
http.Handle("/", requestID(rateLimit(authenticate(shape(proxy)))))An API gateway is the single service every outside request passes through before it reaches anything else. It is a reverse proxy with rules attached.
What belongs at the edge
Work that is identical for every service. Authentication — working out who is calling — happens once: check the API key or token, then forward a header the services trust. Rate limiting — refusing a caller who sends too much — belongs here because the whole point is to refuse before any real work starts. Shaping means tidying the request before it goes on: cap the body size, delete headers the services must never receive from outside, add a request ID, set a deadline.
What stays in the service
Anything that needs the data. "May this user edit order 7?" is authorisation, and only the orders service knows who owns order 7. A gateway that starts answering questions like that needs a copy of every service's rules, and copies drift apart.
What trips people up
Trusting a header from outside. If the gateway sets X-User-ID and the services trust it, then a client that sends its own X-User-ID becomes whoever it likes. Delete the header on the way in, every single time, before you set it. The same goes for X-Forwarded-For. And the rules only hold if the services cannot be reached any other way, so keep them off the public network.
Example
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"sync"
)
var mu sync.Mutex
// used counts the requests each API key has spent. A real limiter refills
// the allowance over time; this one does not, so the output is stable.
var used = map[string]int{}
func allow(key string) bool {
mu.Lock()
defer mu.Unlock()
used[key]++
return used[key] <= 2
}
// edge does the gateway's work, then hands the request to the service.
func edge(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := r.Header.Get("X-Api-Key")
if key == "" {
http.Error(w, "missing api key", http.StatusUnauthorized)
return
}
if !allow(key) {
w.Header().Set("Retry-After", "1")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
r.Header.Del("X-Api-Key") // the credential stops here
r.Header.Set("X-Tenant", "acme") // what the service is told instead
next.ServeHTTP(w, r)
})
}
func main() {
service := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "tenant:", r.Header.Get("X-Tenant"))
})
h := edge(service)
for _, key := range []string{"", "k1", "k1", "k1"} {
req := httptest.NewRequest(http.MethodGet, "/orders", nil)
req.Header.Set("X-Api-Key", key)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Printf("%d %s", rec.Code, rec.Body.String())
}
}
// Output:
// 401 missing api key
// 200 tenant: acme
// 200 tenant: acme
// 429 rate limited
When to use it
- Check API keys once at the edge instead of in six separate services.
- Rate limit per customer before any traffic reaches your database.
- Strip inbound X-User headers so a client cannot claim to be someone else.
- Route /orders and /users to different services behind one hostname.
More examples
Checking the token once, at the edge
This is the pattern that saves every service behind the gateway from knowing how tokens work. The two Del calls at the top are the security of the whole arrangement, and they come before any check so that no early return can skip them. Keep forwarding the token instead of stripping it when a service genuinely needs to verify it itself, for instance when it is not on your private network.
// authenticate verifies the token and replaces it with headers the services
// trust. parseToken is your own verifier; it returns the user it proved.
func authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Delete first: the client may have sent these itself.
r.Header.Del("X-User-ID")
r.Header.Del("X-User-Role")
// strings.CutPrefix needs Go 1.20 or later.
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
user, err := parseToken(token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
r.Header.Del("Authorization") // the credential does not travel on
r.Header.Set("X-User-ID", user.ID)
r.Header.Set("X-User-Role", user.Role)
next.ServeHTTP(w, r)
})
}Shaping the request
Reach for this the first time a service falls over on a request nobody expected: a 400 MB upload, a missing request ID, a header a client was not supposed to be able to set. ReverseProxy already drops hop-by-hop headers such as Connection from the outbound request, so the list here is for headers of your own that the services treat as trustworthy.
const maxBody = 1 << 20 // 1 MiB
// shape makes every request look the way the services expect.
func shape(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Reading past the limit returns an error instead of filling memory.
r.Body = http.MaxBytesReader(w, r.Body, maxBody)
// One ID for the whole chain, so logs from every hop can be joined.
id := r.Header.Get("X-Request-ID")
if id == "" {
id = newID() // your own generator
r.Header.Set("X-Request-ID", id)
}
w.Header().Set("X-Request-ID", id)
// Headers only the gateway is allowed to set.
for _, h := range []string{"X-Internal-Token", "X-Tenant"} {
r.Header.Del(h)
}
next.ServeHTTP(w, r)
})
}The routing table
This is the shape of a small gateway: a mux deciding which service a path belongs to, wrapped in the middleware that applies to everything. Note that /healthz is inside the wrapper too, which is usually wrong — a load balancer's probe has no API key. Register that route on an outer mux, or on a second listener that is not public at all.
// One gateway, several services, each one a proxy of its own.
func gateway(orders, users http.Handler) http.Handler {
mux := http.NewServeMux()
mux.Handle("/orders/", http.StripPrefix("/orders", orders))
mux.Handle("/users/", http.StripPrefix("/users", users))
// A method in the pattern needs Go 1.22 or later. Of two patterns that
// both match, the more specific one wins, so this beats "/".
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
// The edge rules wrap the whole table, not each route.
return requestID(authenticate(rateLimit(shape(mux))))
}
Discussion