DEV Community

Ayi NEDJIMI
Ayi NEDJIMI

Posted on

Building a Zero-Trust API Gateway with Certificate-Based Auth

Most API gateways authenticate callers with API keys or JWTs — shared secrets that get stolen, rotated lazily, or leaked into logs. Certificate-based mutual TLS (mTLS) eliminates that class of risk entirely: every client proves cryptographic identity on every request, and there is no bearer token to exfiltrate. Here is how to build a minimal zero-trust gateway in Go that enforces it.

Why API Keys Keep Failing

API keys work until they don't. They're long-lived, embedded in environment variables, accidentally committed to repos, passed through logging middleware, and shared across services. The OWASP API Security Top 10 consistently ranks broken authentication as the top risk, and API key misuse is a major contributor.

A client certificate solves these problems at the transport layer:

  • Certificates can't be replicated without the private key
  • Revocation is built in (via CRL or OCSP)
  • Identity is tied to a cryptographic keypair, not a string that can be copied with Ctrl+C

The tradeoff: you need a PKI. For an internal API gateway, a self-signed CA is entirely appropriate.

Setting Up a Minimal CA

Before writing Go code, we need certificates. OpenSSL gives us a CA, a server cert, and a client cert:

# Generate the CA key and self-signed certificate
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
  -subj "/CN=Internal CA" -out ca.crt

# Server certificate
openssl genrsa -out server.key 2048
openssl req -new -key server.key -subj "/CN=api.internal" -out server.csr
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
  -CAcreateserial -out server.crt -days 365 -sha256

# Client certificate (one per service or team)
openssl genrsa -out client-service-a.key 2048
openssl req -new -key client-service-a.key \
  -subj "/CN=service-a/O=backend-team" -out client-service-a.csr
openssl x509 -req -in client-service-a.csr -CA ca.crt -CAkey ca.key \
  -CAcreateserial -out client-service-a.crt -days 365 -sha256
Enter fullscreen mode Exit fullscreen mode

The /CN and /O fields in the client cert are your identity claims — we'll extract them in the gateway to make routing decisions.

Building the Gateway in Go

The core of a zero-trust gateway is tls.Config with ClientAuth: tls.RequireAndVerifyClientCert. Go's standard library handles the certificate verification chain; we extract the identity and pass it downstream.

package main

import (
    "crypto/tls"
    "crypto/x509"
    "fmt"
    "log"
    "net/http"
    "net/http/httputil"
    "net/url"
    "os"
    "strings"
)

func loadCA(path string) *x509.CertPool {
    pool := x509.NewCertPool()
    pem, err := os.ReadFile(path)
    if err != nil {
        log.Fatalf("reading CA: %v", err)
    }
    if !pool.AppendCertsFromPEM(pem) {
        log.Fatal("failed to parse CA cert")
    }
    return pool
}

func identityMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 {
            http.Error(w, "client certificate required", http.StatusUnauthorized)
            return
        }
        cert := r.TLS.PeerCertificates[0]
        cn := cert.Subject.CommonName
        org := ""
        if len(cert.Subject.Organization) > 0 {
            org = cert.Subject.Organization[0]
        }
        r.Header.Set("X-Client-CN", cn)
        r.Header.Set("X-Client-Org", org)
        next.ServeHTTP(w, r)
    })
}

var routePolicy = map[string]string{
    "/admin/":    "ops-team",
    "/internal/": "backend-team",
}

func authzMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        org := r.Header.Get("X-Client-Org")
        for prefix, required := range routePolicy {
            if strings.HasPrefix(r.URL.Path, prefix) && org != required {
                http.Error(w, "forbidden", http.StatusForbidden)
                return
            }
        }
        next.ServeHTTP(w, r)
    })
}

func main() {
    upstream, _ := url.Parse("http://127.0.0.1:8080")
    proxy := httputil.NewSingleHostReverseProxy(upstream)

    mux := http.NewServeMux()
    mux.Handle("/", identityMiddleware(authzMiddleware(proxy)))

    cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
    if err != nil {
        log.Fatalf("loading server cert: %v", err)
    }

    srv := &http.Server{
        Addr:    ":8443",
        Handler: mux,
        TLSConfig: &tls.Config{
            Certificates: []tls.Certificate{cert},
            ClientCAs:    loadCA("ca.crt"),
            ClientAuth:   tls.RequireAndVerifyClientCert,
            MinVersion:   tls.VersionTLS13,
        },
    }

    fmt.Println("Gateway listening on :8443 (mTLS enforced)")
    log.Fatal(srv.ListenAndServeTLS("", ""))
}
Enter fullscreen mode Exit fullscreen mode

This gateway:

  1. Terminates TLS and verifies the client certificate against your CA
  2. Rejects any request without a valid cert with 401 Unauthorized
  3. Injects X-Client-CN and X-Client-Org headers before proxying upstream
  4. Enforces TLS 1.3 minimum — no downgrade possible
  5. Checks route-level authorization based on the client organization field

Testing the Gateway

With the certs generated, you can test immediately with curl:

# Should succeed — valid client cert
curl --cert client-service-a.crt --key client-service-a.key \
     --cacert ca.crt https://localhost:8443/internal/status

# Should fail — no cert
curl --cacert ca.crt https://localhost:8443/internal/status
# curl: (56) OpenSSL SSL_read: error:... alert handshake failure

# Should fail — wrong org for /admin/
curl --cert client-service-a.crt --key client-service-a.key \
     --cacert ca.crt https://localhost:8443/admin/users
# HTTP/2 403 forbidden
Enter fullscreen mode Exit fullscreen mode

The TLS handshake failure for the no-cert case happens before any HTTP code runs — the connection is dropped at the transport layer, not the application layer. That is exactly what zero-trust means in practice.

Revocation and Certificate Rotation

Revocation is where cert-based auth gets operationally expensive if you're not deliberate about it.

Short-lived certs are the simplest approach: issue certs valid for 24-72 hours and automate renewal. A compromised cert expires before most incident response workflows could react. Tools like SPIFFE/SPIRE handle this in Kubernetes environments; for simpler setups, a cron job plus a renewal endpoint works fine.

CRL approach: if you need immediate revocation, host a certificate revocation list and embed the CRL distribution point URL at issuance time. Go's x509 package checks CRLs if you configure a custom VerifyPeerCertificate callback.

Serial blocklist: the simplest fast-revocation mechanism is a Redis set of revoked serial numbers checked in identityMiddleware. Not cryptographically rigorous, but operational within minutes of detecting a compromise.

We maintain free security hardening checklists covering PKI hygiene in depth — OCSP stapling, CRL caching, rotation timelines, and cert pinning tradeoffs.

The Takeaway

mTLS-based API gateways eliminate the entire API key leakage risk category. The Go implementation is compact — standard library tls.Config does the verification work. The hard part is operational: issuing certs, rotating them before expiry, and handling revocation when something goes wrong.

Start with long-lived certs and a manual blocklist. Automate rotation only after you've validated the issuance pipeline end-to-end. The zero-trust claim here is precise: every request carries cryptographic proof of identity, verified on every connection, with no session state to steal.


I run AYI NEDJIMI Consultants, a cybersecurity consulting firm. We publish free security hardening checklists — PDF and Excel.

Top comments (0)