When service A calls service B inside the cluster, it seems like everything is safe — internal network, VPC, "ours." But a compromised container, network interception, or an attack from the inside are real scenarios. That's exactly why inter-service traffic is authenticated just as strictly as user traffic.
In Go there are two established approaches: mTLS and the Client Credentials Flow. Let's look at both.
Why authenticate traffic between your own services
Imagine: the order service calls the product service over HTTP without an Authorization header. If someone gains access to the cluster's pods, they can hit any service, pretending to be any other one. An anonymous inter-service call isn't "saving on authentication," it's a hole in the perimeter.
The rule is simple: every request between services carries a credential — either in the transport (mTLS) or in the header (Authorization: Bearer).
Approach 1: mTLS
mTLS (mutual TLS) is two-way TLS, where both sides present a certificate. The client proves to the server "I'm the order service," the server proves to the client "I'm the product service." No passwords, no tokens — the identity is built into the transport layer.
In a Kubernetes cluster with Istio or Linkerd everything happens automatically: each pod is issued a SPIFFE certificate, a sidecar proxy encrypts the traffic and verifies the certificates. The Go application writes no auth code at all — the sidecar does everything.
If a Service Mesh isn't available (local development, a test bench without Istio), mTLS can be configured directly through the standard library:
// adapters/out/product/client.go
func NewMTLSClient(certFile, keyFile, caFile string) (*http.Client, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("load client cert: %w", err)
}
caCert, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read ca cert: %w", err)
}
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caCert)
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
MinVersion: tls.VersionTLS12,
}
return &http.Client{
Transport: &http.Transport{TLSClientConfig: tlsCfg},
Timeout: 10 * time.Second,
}, nil
}
The paths to the certificate files come from environment variables — Kubernetes mounts them from a Secret. No paths straight in the code:
// cmd/app/config.go
type Config struct {
MTLS struct {
CertFile string `envconfig:"MTLS_CERT_FILE,required"`
KeyFile string `envconfig:"MTLS_KEY_FILE,required"`
CAFile string `envconfig:"MTLS_CA_FILE,required"`
}
ProductSvcURL string `envconfig:"PRODUCT_SVC_URL,required"`
}
The main advantage of mTLS: the identity can't be "forgotten" in the request — it's in the transport. With Istio, certificate rotation is automatic, once every 24 hours.
Approach 2: Client Credentials Flow
When a Service Mesh isn't available or isn't planned, the Client Credentials Flow is used — the standard OAuth2 mechanism for machine-to-machine interaction.
The scheme is simple:
- The order service requests a token from the IdP (Keycloak, Auth0): "I'm order-service, here's my
client_idandclient_secret, I want a token with the scopeservice:product:read." - The IdP returns an access_token.
- The order service puts the token into the
Authorization: Bearer ...header and calls the product service. - The product service validates the JWT: signature, expiration, scope.
In Go this is implemented by the golang.org/x/oauth2/clientcredentials package. The key feature: TokenSource caches the token and automatically refreshes it before expiration. There's no need to store the token by hand or refresh it yourself — the package does that.
// cmd/app/wire.go
import "golang.org/x/oauth2/clientcredentials"
ccCfg := clientcredentials.Config{
ClientID: cfg.S2S.ClientID,
ClientSecret: cfg.S2S.ClientSecret,
TokenURL: cfg.S2S.TokenURL,
Scopes: []string{"service:product:read"},
}
tokenSrc := oauth2.ReuseTokenSource(nil, ccCfg.TokenSource(ctx))
productClient := product.NewClient(tokenSrc, cfg.ProductSvcURL)
oauth2.ReuseTokenSource adds a cache on top of the base TokenSource: a repeated call to .Token() returns the already-obtained token right up until it expires. Without ReuseTokenSource every request would go to the IdP for a new token.
The service client that uses the token:
// adapters/out/product/client.go
type Client struct {
http *http.Client
tokenSrc oauth2.TokenSource
baseURL string
}
func NewClient(tokenSrc oauth2.TokenSource, baseURL string) *Client {
return &Client{
http: &http.Client{Timeout: 10 * time.Second},
tokenSrc: tokenSrc,
baseURL: baseURL,
}
}
func (c *Client) GetProduct(ctx context.Context, productID string) (Product, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.baseURL+"/products/"+productID, nil)
if err != nil {
return Product{}, fmt.Errorf("build request: %w", err)
}
tok, err := c.tokenSrc.Token()
if err != nil {
return Product{}, fmt.Errorf("get token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
resp, err := c.http.Do(req)
// ...
}
Note: the token is set in the client (adapters/out/), not in the incoming-request handler. This is important — the auth logic for incoming and outgoing traffic isn't mixed.
Scope is set per operation
A mistake is to give one service a single broad scope service for everything. The right way is a separate scope for each operation:
// adapters/out/inventory/client.go
ccInventory := clientcredentials.Config{
ClientID: cfg.S2S.ClientID,
ClientSecret: cfg.S2S.ClientSecret,
TokenURL: cfg.S2S.TokenURL,
Scopes: []string{"service:inventory:reserve"}, // not "service"
}
// adapters/out/customer/client.go
ccCustomer := clientcredentials.Config{
Scopes: []string{"service:customer:read"},
}
Different scope means different rights. If the order service's token leaks, an attacker will only be able to reserve inventory, but not read customer data and not do anything else. The blast radius is limited.
Configuration: secrets only from the environment
client_secret must not be hardcoded in the sources or in configuration files in the repository. Only from environment variables or Vault:
// cmd/app/config.go
type S2SConfig struct {
ClientID string `envconfig:"S2S_CLIENT_ID,required"`
ClientSecret string `envconfig:"S2S_CLIENT_SECRET,required"`
TokenURL string `envconfig:"S2S_TOKEN_URL,required"`
}
envconfig with the required tag will fail at startup if the variable isn't set — better to fail at startup than to quietly work without authentication. And never log the whole Config struct — client_secret must not end up in the logs.
Common mistakes
An anonymous call through http.DefaultClient. The most common mistake is to use http.Get(...) or create an http.Client{} without a TokenSource or tls.Config. It looks harmless, but in reality it's a request without a credential:
// Wrong: an anonymous call to another service
func (c *CustomerClient) GetCustomer(ctx context.Context, id string) (Customer, error) {
resp, err := http.Get(c.baseURL + "/customers/" + id)
// ...
}
The right way is a TokenSource in the client, as in the example above.
Manual token management. Some developers store the token in a variable and check expires_in themselves. That's reinventing the wheel — oauth2.ReuseTokenSource already does everything correctly.
One scope for all calls. scope=service or scope=internal is too broad. If one client is compromised, all operations are at risk.
A secret in the code. client_secret: "abc123" straight in config.go — the secret ends up in the git history forever.
In short
- Anonymous inter-service traffic is a security hole; every request is authenticated.
- mTLS — identity in the transport; with Istio/Linkerd the application writes no auth code at all.
- Client Credentials Flow — OAuth2 for machines; the service gets a token from the IdP and puts it into
Authorization: Bearer. - In Go —
clientcredentials.Config+oauth2.ReuseTokenSource; the token is cached and refreshed automatically. - Scope is per operation (
service:product:read), not one broad one for all calls. client_secret— only from environment variables, never in code or git.- The token is set in the outgoing client (
adapters/out/), not in the incoming-request handler.
What to read next
- JWT validation — how the receiving service validates the token.
- PII and secrets —
client_secretthrough Vault, PII not in logs. - RBAC: roles — the
systemrole for S2S calls.