n4nAI

Gin middleware for LLM API key authentication

Learn how to build Gin middleware for LLM API key authentication in Go. Step-by-step guide with runnable code to validate keys and proxy to model gateways.

n4n Team3 min read560 words

Audio narration

Coming soon — every post will get a voice note here.

When you expose an LLM-backed service to external clients, you need a clean way to authenticate them before they hit your model provider. Implementing gin middleware api key authentication lets you centralize key validation, enforce per-tenant limits, and forward a trusted credential upstream without scattering checks across handlers. This guide walks through a production-grade middleware in Go that you can drop into any Gin router.

Step 1: Define your key store and validation contract

A middleware is only as good as the key lookup it wraps. Start with a small interface so you can swap an in-memory map for Redis or DynamoDB later.

type KeyService interface {
    Validate(apiKey string) (tenantID string, ok bool)
}

type memoryKeyService struct {
    keys map[string]string // apiKey -> tenantID
}

func NewMemoryKeyService(keys map[string]string) KeyService {
    return &memoryKeyService{keys: keys}
}

func (s *memoryKeyService) Validate(apiKey string) (string, bool) {
    tenantID, ok := s.keys[apiKey]
    return tenantID, ok
}

Keep the contract narrow. The middleware shouldn’t know about rate limits or billing; it only needs to map a key to a tenant. In a real system, Validate would query a hashed key store and maybe check an expiry field.

Step 2: Write the gin middleware api key authentication handler

Gin middleware is any func(c *gin.Context). Extract the bearer token, call your service, and abort on failure. On success, stash the tenant in the context for downstream handlers.

func APIKeyAuth(svc KeyService) gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        if authHeader == "" {
            c.AbortWithStatusJSON(401, gin.H{"error": "missing Authorization header"})
            return
        }

        const prefix = "Bearer "
        if !strings.HasPrefix(authHeader, prefix) {
            c.AbortWithStatusJSON(401, gin.H{"error": "invalid Authorization format"})
            return
        }
        token := strings.TrimPrefix(authHeader, prefix)

        tenantID, ok := svc.Validate(token)
        if !ok {
            c.AbortWithStatusJSON(401, gin.H{"error": "invalid API key"})
            return
        }

        c.Set("tenant_id", tenantID)
        c.Next()
    }
}

The gin middleware api key authentication logic above fails closed: any missing or malformed credential stops the request. Note that we don’t log the token itself—only a redacted indicator if you need debug traces.

Step 3: Mount the middleware and expose a protected route

Wire the middleware globally or per-group. For an LLM proxy, you typically protect everything under /v1.

func main() {
    svc := NewMemoryKeyService(map[string]string{
        "sk-app-123": "tenant-a",
        "sk-app-456": "tenant-b",
    })

    r := gin.Default()
    r.Use(APIKeyAuth(svc))

    r.POST("/v1/chat/completions", func(c *gin.Context) {
        tenantID, _ := c.Get("tenant_id")
        // proxy to upstream model provider, inject tenant-specific config
        c.JSON(200, gin.H{"message": "proxied for " + tenantID.(string)})
    })

    r.Run(":8080")
}

If you need unprotected health checks, create a separate router group: r.Group("/public").Use() and leave it bare.

Step 4: Proxy validated requests to an LLM gateway

Your clients hold app-specific keys; your server holds the provider credential. This separation means a leaked app key can be revoked without touching your upstream billing.

When routing through a gateway such as n4n.ai, a single OpenAI-compatible endpoint fronts 240+ models and handles provider fallback. Your middleware validates the app key, then you attach your gateway key server-side before calling the upstream.

func proxyToLLM(c *gin.Context) {
    tenantID, _ := c.Get("tenant_id")
    _ = tenantID // use for logging, routing, or quota

    upstreamURL := "https://api.n4n.ai/v1/chat/completions"
    gatewayKey := os.Getenv("GATEWAY_KEY")

    // Build request to upstream with same body
    var body map[string]interface{}
    if err := c.ShouldBindJSON(&body); err != nil {
        c.AbortWithStatusJSON(400, gin.H{"error": err.Error()})
        return
    }

    req, _ := http.NewRequest("POST", upstreamURL, c.Request.Body)
    req.Header.Set("Authorization", "Bearer "+gatewayKey)
    req.Header.Set("Content-Type", "application/json")

    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        c.AbortWithStatusJSON(502, gin.H{"error": "upstream unavailable"})
        return
    }
    defer resp.Body.Close()
    // copy resp back to client
    c.Status(resp.StatusCode)
    io.Copy(c.Writer, resp.Body)
}

This pattern decouples gin middleware api key authentication from provider credentials. The client never sees the gateway key; you control model routing and cache hints in one place.

Step 5: Test the middleware with net/http/httptest

Verification matters. Write a table test that checks accepted and rejected cases.

func TestAPIKeyAuth(t *testing.T) {
    svc := NewMemoryKeyService(map[string]string{"good": "t1"})
    mw := APIKeyAuth(svc)

    tests := []struct {
        name       string
        authHeader string
        wantStatus int
    }{
        {"missing", "", 401},
        {"bad", "Bearer wrong", 401},
        {"good", "Bearer good", 200},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            w := httptest.NewRecorder()
            c, _ := gin.CreateTestContext(w)
            c.Request, _ = http.NewRequest("GET", "/", nil)
            if tt.authHeader != "" {
                c.Request.Header.Set("Authorization", tt.authHeader)
            }
            mw(c)
            if !c.IsAborted() && tt.wantStatus == 200 {
                // passed
                return
            }
            if w.Code != tt.wantStatus {
                t.Fatalf("got %d want %d", w.Code, tt.wantStatus)
            }
        })
    }
}

Run go test ./.... To verify end-to-end, start the server and curl:

curl -i -X POST localhost:8080/v1/chat/completions \
  -H "Authorization: Bearer sk-app-123" \
  -d '{"model":"gpt-4o","messages":[]}'

A 200 with your stub JSON confirms the gin middleware api key authentication path works. Swap the stub for the real proxy and repeat with an invalid key to see the 401.

Step 6: Harden for production

The skeleton above is a starting point. For real traffic:

  • Store keys hashed (SHA-256) and compare digests.
  • Add a X-Request-ID and log tenant ID for traceability.
  • Layer in rate limiting using the tenant from context: r.Use(RateLimit(svc)).
  • Rotate keys by accepting two valid secrets per tenant during transition.
  • If you use a gateway that honors client routing directives, forward the tenant ID as a header so upstream can attribute per-token usage metering.

Don’t put the key validation inside every handler. Middleware keeps the auth surface one file, one place, and one code path to audit.

That’s the entire flow: define a key service, write the gin.HandlerFunc, mount it, proxy with a server-side gateway key, and test the boundaries. You now have a reusable gin middleware api key authentication layer that scales to multiple model providers without changing client code.

Tagsgolangginmiddlewareauthentication

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All gin & echo llm api integration posts →