In a microservices backend, services call other services constantly. Order service calls billing service. Billing service calls notification service. These internal calls need authentication, but the mechanism you choose matters. Too simple and you have no security. Too complex and you have an operational nightmare. Here is how we handle it in production Go backends.
In a microservices backend, services call other services constantly. Order service calls billing service. Billing service calls notification service. Inventory service calls supplier service. These internal calls need authentication: a service that accepts unauthenticated internal requests becomes an attack surface, and in multi-tenant SaaS, an unauthenticated internal endpoint can leak data across tenant boundaries.
The authentication mechanism you choose matters. Too simple and you have no security. Too complex and you have an operational nightmare. This post covers what we actually use for Go backends in MENA-deployed SaaS products.
The problem with doing nothing
The most common approach early in a microservices project is no internal authentication. Services run inside a VPC, the security group only allows traffic from other services in the same VPC, so why add auth overhead?
This fails in two ways. First, network perimeter security alone is not defense in depth. If any service in the VPC is compromised, it can call any other service with no credential requirement. Second, when you start adding new endpoints and realize you need to know which caller is making the request (for logging, rate limiting, or tenant context propagation), you have to add auth retroactively to every service.
Adding auth early costs one sprint. Retrofitting auth across fifteen services later costs a month and produces inconsistent implementations.
Option 1: Shared secret in headers (simplest)
The simplest approach: every internal service is configured with the same secret, passed as a header on all internal calls. The receiving service checks for the header and rejects requests that lack it or have the wrong value.
// In the caller
const internalAuthHeader = "X-Internal-Token"
func (c *InternalClient) do(ctx context.Context, req *http.Request) (*http.Response, error) {
req.Header.Set(internalAuthHeader, c.internalToken)
return c.httpClient.Do(req.WithContext(ctx))
}
// In the receiver's middleware
func InternalAuthMiddleware(token string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Internal-Token") != token {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
}
The token comes from an environment variable, injected into each service's ECS task definition from AWS Secrets Manager:
token := os.Getenv("INTERNAL_SERVICE_TOKEN")
if token == "" {
log.Fatal("INTERNAL_SERVICE_TOKEN not set")
}
This approach is appropriate for small deployments where all services are controlled by the same team. The token is shared across all services, so you cannot identify which caller made a request, and rotation requires updating all services simultaneously.
Option 2: Per-service short-lived JWT tokens (more structured)
For backends with more than five services or where you need to know which caller made a request, short-lived JWT tokens per service are a better pattern.
Each service is configured with its own identity (a service name or ID) and a shared signing key. When calling another service, it generates a short-lived JWT identifying itself:
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
type ServiceTokenClaims struct {
Service string `json:"svc"`
Scope string `json:"scope"`
jwt.RegisteredClaims
}
func GenerateServiceToken(signingKey []byte, serviceName string) (string, error) {
claims := ServiceTokenClaims{
Service: serviceName,
Scope: "internal",
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "internal-auth",
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(5 * time.Minute)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(signingKey)
}
The receiving service validates the token and extracts the caller identity:
func ValidateServiceToken(signingKey []byte, tokenString string) (*ServiceTokenClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &ServiceTokenClaims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return signingKey, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*ServiceTokenClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
if claims.Scope != "internal" {
return nil, errors.New("token not scoped for internal use")
}
return claims, nil
}
With this approach, every incoming internal request has an associated caller identity. You can log it, enforce per-service permissions, and detect if a specific service is misbehaving.
Token generation should happen once per request, or better, cache the token in memory and regenerate it shortly before expiry:
type TokenCache struct {
mu sync.Mutex
token string
expiresAt time.Time
signingKey []byte
serviceName string
}
func (c *TokenCache) Get() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if time.Until(c.expiresAt) > 30*time.Second {
return c.token, nil
}
tok, err := GenerateServiceToken(c.signingKey, c.serviceName)
if err != nil {
return "", err
}
c.token = tok
c.expiresAt = time.Now().Add(5 * time.Minute)
return c.token, nil
}
This generates a new token only when the current one is within 30 seconds of expiry, avoiding the overhead of JWT signing on every request.
Option 3: HMAC request signing (when you need request-level integrity)
For financial operations or operations where you need to verify that the request body has not been modified in transit, HMAC request signing adds a signature over the request method, path, body hash, and timestamp:
func SignRequest(req *http.Request, secret []byte) error {
ts := strconv.FormatInt(time.Now().Unix(), 10)
var bodyBytes []byte
if req.Body != nil {
var err error
bodyBytes, err = io.ReadAll(req.Body)
if err != nil {
return err
}
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
}
bodyHash := sha256.Sum256(bodyBytes)
bodyHashHex := hex.EncodeToString(bodyHash[:])
payload := strings.Join([]string{
req.Method,
req.URL.Path,
bodyHashHex,
ts,
}, "\n")
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(payload))
sig := hex.EncodeToString(mac.Sum(nil))
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Signature", sig)
return nil
}
The receiving service reconstructs the payload and verifies the signature. Include a timestamp check to reject replayed requests:
ts := r.Header.Get("X-Timestamp")
if ts == "" {
http.Error(w, "missing timestamp", http.StatusUnauthorized)
return
}
unixTs, err := strconv.ParseInt(ts, 10, 64)
if err != nil || time.Since(time.Unix(unixTs, 0)) > 5*time.Minute {
http.Error(w, "request expired", http.StatusUnauthorized)
return
}
HMAC signing is heavier to implement correctly and adds latency from body reading. Use it only when request integrity matters, not as a default for all internal calls.
What we skip: mTLS and service meshes
Mutual TLS provides strong cryptographic identity for services. Service meshes like Istio or Linkerd automate mTLS at the infrastructure level without code changes. For large organizations with dedicated platform teams, these are excellent choices.
For most SaaS teams in Lebanon and MENA operating with small engineering teams, the operational complexity of a service mesh is not justified by the security gain over properly implemented JWT or HMAC auth. mTLS requires certificate management, rotation, a mesh control plane, and detailed knowledge of the mesh configuration to debug.
JWT tokens stored in Secrets Manager and injected via ECS task definitions achieve most of the security guarantees at a fraction of the operational overhead.
Separating internal and external endpoints
Do not expose internal endpoints on the same port or path namespace as external customer-facing endpoints. A clean pattern: internal endpoints on a separate port (e.g., 8081) that the ALB security group does not expose to the internet, only to the service VPC security group.
// External API: port 8080 with full auth middleware
externalMux := http.NewServeMux()
externalMux.Handle("/api/", authMiddleware(publicRouter))
// Internal API: port 8081 with internal-only auth
internalMux := http.NewServeMux()
internalMux.Handle("/internal/", internalAuthMiddleware(internalRouter))
go http.ListenAndServe(":8080", externalMux)
go http.ListenAndServe(":8081", internalMux)
ECS task definitions reference both ports, and security group rules ensure only 8081 is reachable within the VPC.
Key lessons from production
- Always authenticate internal service calls. Network perimeter alone is not defense in depth.
- For small deployments, a shared secret token in Secrets Manager is practical and sufficient.
- For larger deployments, per-service short-lived JWTs give you caller identity and make debugging easier.
- HMAC signing is for operations where request body integrity matters, not as a default.
- Skip mTLS unless you have a dedicated platform team and genuine need for its complexity.
- Expose internal endpoints on a separate port not reachable from the internet.
- Cache generated tokens in memory and regenerate before expiry to avoid per-request signing overhead.
Enjoying this article?
Enter your email and get a clean, formatted PDF of this article - free, no spam.
Not sure where to start?
We build production Go microservices backends for companies in Lebanon and across MENA. If you are designing the security model for your internal services or troubleshooting an existing architecture, we can help. Reach out at https://voxire.com/get-a-quote/



