Building an API Gateway in Go from Scratch: Routing, Authentication, and Rate Limiting
Introduction: Why Build Your Own API Gateway?
An API Gateway is a single entry point for all client requests in a microservice architecture. It handles routing, authentication, load balancing, and rate limiting, freeing business services from cross-cutting concerns.
Ready-made solutions — Kong, Traefik, AWS API Gateway — cover most use cases. But there are scenarios where a custom Go implementation makes sense: strict performance requirements, non-standard authentication protocols, full control over error handling logic, or simply licensing constraints. Go is an ideal fit for this task: low overhead, built-in concurrency, and a rich standard library.
API Gateway Architecture: Core Components
A well-designed API Gateway consists of the following layers:
- Router — matches incoming requests to target microservices by path, method, and headers.
- Middleware chain — a sequence of handlers: authentication, rate limiting, logging, tracing.
- Reverse proxy — proxies the request to the upstream service and returns the response to the client.
- Circuit breaker / Retry — protects the system from cascading failures.
- Observability — collection of metrics, logs, and traces.
Implementing Request Routing to Microservices
For routing, we'll use the popular gorilla/mux router or the built-in net/http package. Below is a minimalist reverse proxy implementation with dynamic routing.
package main\n\nimport (\n \"log\"\n \"net/http\"\n \"net/http/httputil\"\n \"net/url\"\n)\n\ntype Route struct {\n Prefix string\n Target string\n}\n\nvar routes = []Route{\n {Prefix: \"/users\", Target: \"http://user-service:8081\"},\n {Prefix: \"/orders\", Target: \"http://order-service:8082\"},\n {Prefix: \"/products\", Target: \"http://product-service:8083\"},\n}\n\nfunc proxyHandler(target string) http.Handler {\n url, _ := url.Parse(target)\n proxy := httputil.NewSingleHostReverseProxy(url)\n proxy.ModifyResponse = func(resp *http.Response) error {\n resp.Header.Set(\"X-Gateway\", \"go-gateway/1.0\")\n return nil\n }\n return proxy\n}\n\nfunc main() {\n mux := http.NewServeMux()\n for _, r := range routes {\n handler := proxyHandler(r.Target)\n mux.Handle(r.Prefix+\"/\", http.StripPrefix(r.Prefix, handler))\n }\n log.Println(\"Gateway listening on :8080\")\n log.Fatal(http.ListenAndServe(\":8080\", mux))\n}The router iterates over registered routes, finds a prefix match, and proxies the request through httputil.ReverseProxy. For more advanced routing (path parameters, regex), plug in chi or gorilla/mux.
Authentication and Authorization: JWT and Middleware
Handling authentication at the Gateway level eliminates the need for each microservice to validate tokens independently. Let's implement a JWT validation middleware using the golang-jwt/jwt library.
package middleware\n\nimport (\n \"context\"\n \"fmt\"\n \"net/http\"\n \"strings\"\n\n \"github.com/golang-jwt/jwt/v5\"\n)\n\ntype contextKey string\nconst UserIDKey contextKey = \"userID\"\n\nvar jwtSecret = []byte(\"super-secret-key\")\n\nfunc JWTAuth(next http.Handler) http.Handler {\n return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n authHeader := r.Header.Get(\"Authorization\")\n if !strings.HasPrefix(authHeader, \"Bearer \") {\n http.Error(w, \"missing or invalid token\", http.StatusUnauthorized)\n return\n }\n tokenStr := strings.TrimPrefix(authHeader, \"Bearer \")\n token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {\n if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {\n return nil, fmt.Errorf(\"unexpected signing method: %v\", t.Header[\"alg\"])\n }\n return jwtSecret, nil\n })\n if err != nil || !token.Valid {\n http.Error(w, \"unauthorized\", http.StatusUnauthorized)\n return\n }\n claims, _ := token.Claims.(jwt.MapClaims)\n userID := claims[\"sub\"].(string)\n ctx := context.WithValue(r.Context(), UserIDKey, userID)\n // Pass userID in a header for downstream services\n r.Header.Set(\"X-User-ID\", userID)\n next.ServeHTTP(w, r.WithContext(ctx))\n })\n}The middleware extracts the userID from JWT claims, stores it in the request context, and sets the X-User-ID header, which downstream services can read directly — without re-parsing the token.
Rate Limiting: Token Bucket and Sliding Window with Redis
Rate limiting protects microservices from overload. Let's look at two popular algorithms and their Redis-backed implementations.
Token Bucket
Tokens accumulate at a constant rate and are consumed with each request. Implemented using atomic Redis operations:
package ratelimit\n\nimport (\n \"context\"\n \"fmt\"\n \"time\"\n\n \"github.com/redis/go-redis/v9\"\n)\n\ntype TokenBucketLimiter struct {\n client *redis.Client\n capacity int64\n rate int64 // tokens per second\n}\n\nfunc (l *TokenBucketLimiter) Allow(ctx context.Context, key string) (bool, error) {\n now := time.Now().Unix()\n bucketKey := fmt.Sprintf(\"tb:%s\", key)\n\n pipe := l.client.TxPipeline()\n getTokens := pipe.Get(ctx, bucketKey)\n _, err := pipe.Exec(ctx)\n _ = err\n\n tokens, _ := getTokens.Int64()\n if tokens <= 0 {\n tokens = l.capacity\n }\n _ = now\n\n if tokens > 0 {\n l.client.Decr(ctx, bucketKey)\n l.client.Expire(ctx, bucketKey, time.Second*60)\n return true, nil\n }\n return false, nil\n}\nSliding Window with Redis
A more precise algorithm: counts the number of requests within a sliding time window using a Redis Sorted Set.
package ratelimit\n\nimport (\n \"context\"\n \"fmt\"\n \"time\"\n\n \"github.com/redis/go-redis/v9\"\n)\n\ntype SlidingWindowLimiter struct {\n client *redis.Client\n limit int64\n window time.Duration\n}\n\nfunc (l *SlidingWindowLimiter) Allow(ctx context.Context, key string) (bool, error) {\n now := time.Now()\n windowStart := now.Add(-l.window).UnixMilli()\n swKey := fmt.Sprintf(\"sw:%s\", key)\n\n pipe := l.client.TxPipeline()\n pipe.ZRemRangeByScore(ctx, swKey, \"0\", fmt.Sprintf(\"%d\", windowStart))\n count := pipe.ZCard(ctx, swKey)\n pipe.ZAdd(ctx, swKey, redis.Z{\n Score: float64(now.UnixMilli()),\n Member: now.UnixNano(),\n })\n pipe.Expire(ctx, swKey, l.window)\n _, err := pipe.Exec(ctx)\n if err != nil {\n return false, err\n }\n return count.Val() < l.limit, nil\n}\nThe rate limiting middleware is inserted into the chain before authentication (to guard against DDoS on open endpoints) or after it (for per-user limits based on userID):
func RateLimitMiddleware(limiter *SlidingWindowLimiter) func(http.Handler) http.Handler {\n return func(next http.Handler) http.Handler {\n return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n key := r.RemoteAddr // or userID from context\n allowed, err := limiter.Allow(r.Context(), key)\n if err != nil || !allowed {\n http.Error(w, \"rate limit exceeded\", http.StatusTooManyRequests)\n return\n }\n next.ServeHTTP(w, r)\n })\n }\n}Error Handling: Circuit Breaker and Retry
When an upstream service degrades, the Gateway must respond gracefully. We use the sony/gobreaker library for circuit breaking:
package circuit\n\nimport (\n \"net/http\"\n \"time\"\n\n \"github.com/sony/gobreaker\"\n)\n\nfunc NewBreakerProxy(target string, next http.Handler) http.Handler {\n cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{\n Name: target,\n MaxRequests: 5,\n Interval: 10 * time.Second,\n Timeout: 30 * time.Second,\n ReadyToTrip: func(counts gobreaker.Counts) bool {\n return counts.ConsecutiveFailures > 3\n },\n })\n\n return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n _, err := cb.Execute(func() (interface{}, error) {\n rr := &responseRecorder{ResponseWriter: w}\n next.ServeHTTP(rr, r)\n if rr.statusCode >= 500 {\n return nil, fmt.Errorf(\"upstream error: %d\", rr.statusCode)\n }\n return nil, nil\n })\n if err != nil {\n http.Error(w, \"service unavailable\", http.StatusServiceUnavailable)\n }\n })\n}For retry logic, use exponential backoff: retry the request up to 3 times with delays of 100ms, 200ms, and 400ms before returning an error to the client.
Logging and Request Tracing
Every request through the Gateway should receive a unique trace ID, passed via the X-Request-ID header to all downstream calls. We use go.opentelemetry.io/otel for distributed tracing:
func TracingMiddleware(tracer trace.Tracer) func(http.Handler) http.Handler {\n return func(next http.Handler) http.Handler {\n return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n ctx, span := tracer.Start(r.Context(), r.URL.Path)\n defer span.End()\n\n requestID := r.Header.Get(\"X-Request-ID\")\n if requestID == \"\" {\n requestID = uuid.New().String()\n }\n span.SetAttributes(attribute.String(\"request.id\", requestID))\n r.Header.Set(\"X-Request-ID\", requestID)\n\n next.ServeHTTP(w, r.WithContext(ctx))\n })\n }\n}Structured logging via slog (Go 1.21+) makes it easy to parse logs in ELK or Loki:
slog.Info(\"request\",\n \"method\", r.Method,\n \"path\", r.URL.Path,\n \"duration_ms\", time.Since(start).Milliseconds(),\n \"status\", statusCode,\n \"request_id\", requestID,\n)Deploying the API Gateway to Kubernetes
The API Gateway is deployed as a Deployment with HPA for automatic scaling. Example manifest:
apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: api-gateway\nspec:\n replicas: 3\n selector:\n matchLabels:\n app: api-gateway\n template:\n metadata:\n labels:\n app: api-gateway\n spec:\n containers:\n - name: gateway\n image: myregistry/api-gateway:v1.0.0\n ports:\n - containerPort: 8080\n env:\n - name: REDIS_URL\n valueFrom:\n secretKeyRef:\n name: redis-secret\n key: url\n - name: JWT_SECRET\n valueFrom:\n secretKeyRef:\n name: jwt-secret\n key: value\n resources:\n requests:\n cpu: \"100m\"\n memory: \"128Mi\"\n limits:\n cpu: \"500m\"\n memory: \"512Mi\"\n readinessProbe:\n httpGet:\n path: /healthz\n port: 8080\n initialDelaySeconds: 5\n periodSeconds: 10\n---\napiVersion: v1\nkind: Service\nmetadata:\n name: api-gateway-svc\nspec:\n type: LoadBalancer\n selector:\n app: api-gateway\n ports:\n - port: 80\n targetPort: 8080In Kubernetes, it is essential to configure readiness and liveness probes so that traffic is not routed to pods during cold start or degradation. For storing route configuration, use a ConfigMap with hot-reload support via fsnotify.
Comparison with Ready-Made Solutions: When Is a Custom Gateway Worth It?
| Criterion | Custom Go Gateway | Kong / Traefik |
|---|---|---|
| Time to production | 2–4 weeks | 1–3 days |
| Logic flexibility | Maximum | Limited to plugins |
| Performance | Optimized for the use case | High, but with overhead |
| Operational burden | High (code maintenance) | Low |
| Cost | Developer time | Free / Enterprise licenses |
A custom API Gateway in Go is justified when:
- Non-standard authentication protocols or routing business logic that cannot be expressed through configuration.
- Extreme latency requirements (sub-millisecond overhead).
- Full control over dependencies and no vendor lock-in.
- The team is proficient in Go and prepared to maintain the codebase.
In all other cases, Traefik with Kubernetes Ingress or Kong with plugins will cover 95% of needs faster and more reliably.
Conclusion
We built a fully functional API Gateway in Go: from dynamic routing of requests to microservices to JWT authentication, rate limiting with Redis (Token Bucket and Sliding Window), circuit breaking, distributed tracing, and deployment to Kubernetes. Go is perfectly suited for this task — minimal overhead, a powerful standard library, and a straightforward concurrency model.
Key principles when building your own Gateway: keep the middleware chain linear and testable, externalize route configuration to a hot-reloadable source (Redis, ConfigMap), and never neglect observability from day one. A well-designed Gateway becomes the reliable foundation of your entire microservice platform.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →