Your microservices share a VPC, so they trust each other. That trust is ambient authority: the permission to invoke a service is granted not by cryptographic proof, but by network topology. An attacker who breaches the perimeter inherits all of it.

Services absolutely can authenticate without ambient authority. The harder question is why your platform makes it feel like a betrayal.

What ambient authority looks like in production

Most service-to-service “authentication” is really just network segmentation. Services live in a private subnet, behind an API gateway, or inside a Kubernetes cluster with a service mesh. The assumption is simple: if the request comes from an internal IP, it is legitimate.

This model collapses when the perimeter does. A compromised pod, lateral movement after a credentials leak, or a supply-chain injection puts the attacker inside the trust zone. At that point, every service is reachable and most will not ask for proof of identity. The attacker does not need to steal more credentials. They simply inherit the ambient authority of the network.

Real authentication between services means each caller proves who it is, and each callee verifies that proof. The network is just a transport. It should not be a credential.

How capability-based service authentication works

There are two practical approaches: cryptographic identity (every service has a provable name) and capability tokens (every request carries an unforgeable, scoped authorization).

Cryptographic identity with mTLS

In mutual TLS, both the client and the server present X.509 certificates. The server verifies the client’s certificate before handling the request. The client verifies the server’s certificate before sending data. Neither side trusts the other because of the IP address.

The infrastructure challenge is certificate distribution. SPIFFE and SPIRE solve this by giving every workload a cryptographic identity derived from its properties, not its location. A pod running the payments-api gets a SPIFFE ID like spiffe://prod.example.com/payments-api. The certificate is short-lived and rotated automatically.

Here is what this looks like in Go:

package main

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

// loadClientTLS returns a config that verifies the server AND presents a client cert.
func loadClientTLS(caCert, clientCert, clientKey []byte) *tls.Config {
    pool := x509.NewCertPool()
    pool.AppendCertsFromPEM(caCert)

    cert, err := tls.X509KeyPair(clientCert, clientKey)
    if err != nil {
        log.Fatalf("failed to load client cert: %v", err)
    }

    return &tls.Config{
        Certificates: []tls.Certificate{cert},
        RootCAs:      pool,
        // Do not skip verification based on IP or DNS name alone.
        InsecureSkipVerify: false,
    }
}

func main() {
    caCert, _ := os.ReadFile("ca.crt")
    clientCert, _ := os.ReadFile("service.crt")
    clientKey, _ := os.ReadFile("service.key")

    client := &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: loadClientTLS(caCert, clientCert, clientKey),
        },
    }

    resp, err := client.Get("https://orders-api.internal:8443/orders")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(resp.Status)
}

The critical line is InsecureSkipVerify: false. Without it, you are back to ambient trust. With it, the client refuses to connect unless the server presents a valid certificate signed by the CA. The server does the same for the client.

Capability tokens for scoped authorization

Certificates prove identity. Capabilities prove authorization. In many systems, you want both: prove who you are, then prove you are allowed to do this specific thing.

A capability token binds an identity, an action, and a resource into an unforgeable package. The service mints it. The service verifies it. There is no central session store to query.

import hmac
import hashlib
import secrets
import time

SECRET = secrets.token_bytes(32)

def mint_service_capability(
    caller_id: str,
    service: str,
    action: str,
    resource: str,
    ttl_seconds: int = 300
) -> str:
    """Mint a time-bound capability for a specific service action."""
    expires = int(time.time()) + ttl_seconds
    payload = f"{caller_id}:{service}:{action}:{resource}:{expires}"
    sig = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest()[:24]
    return f"{payload}:{sig}"

def verify_service_capability(
    token: str,
    expected_service: str,
    expected_action: str,
    expected_resource: str
) -> bool:
    """Verify a capability token is unexpired and correctly scoped."""
    try:
        caller_id, svc, action, resource, expires, sig = token.rsplit(":", 5)
    except ValueError:
        return False

    if int(expires) < time.time():
        return False
    if svc != expected_service or action != expected_action or resource != expected_resource:
        return False

    payload = f"{caller_id}:{svc}:{action}:{resource}:{expires}"
    expected_sig = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest()[:24]
    return hmac.compare_digest(sig, expected_sig)

# Service A requests a capability to call Service B.
token = mint_service_capability("payments-api", "orders-api", "read", "order-123")

# Service B verifies the capability before handling the request.
if not verify_service_capability(token, "orders-api", "read", "order-123"):
    raise PermissionError("Invalid or expired capability")

The token is self-contained and short-lived. Service B does not need to call an authorization server. It just checks the HMAC and the expiry. The scope is explicit: this token is only valid for reading order-123 from orders-api.

Why this is harder than it should be

The technology exists. The difficulty is operational.

Bootstrapping trust is the first problem. If every service needs a certificate, something needs to issue those certificates. That something, the certificate authority or SPIRE server, becomes a new target. You have not eliminated trust. You have concentrated it.

There is also the revocation problem. If a service is compromised, how do you invalidate its identity? mTLS certificates can be short-lived, which helps, but revoking a specific certificate still requires distributing a certificate revocation list or using OCSP. Capability tokens expire quickly, which limits damage, but a stolen signing key lets an attacker mint unlimited tokens.

Performance is another concern. TLS handshakes add latency. In a service mesh with sidecars, the overhead is usually acceptable (a few milliseconds), but in high-throughput paths, those milliseconds add up. Capability tokens avoid a round trip to an auth server, but HMAC verification is not free at scale.

Where ambient authority still makes sense

Not every internal call needs cryptographic proof. A cron job that runs inside the same container as the database client does not need mTLS to talk to localhost. Health checks between a load balancer and a node do not need capability tokens.

The goal is not zero ambient authority. The goal is knowing where your ambient authority lives and whether the blast radius is acceptable. A shared VPC with 200 services and no internal authentication is a large, invisible blast radius. A local Unix socket with file permission 0600 is a small, explicit one.

How to start removing ambient authority from service calls

You do not need a service mesh on day one. Start with the services that touch sensitive data or sit at trust boundaries.

First, identify your deputies. Which services hold privileges that others do not? Which internal APIs would be dangerous if called by the wrong service? Those are your candidates.

Second, add mTLS to the API layer. If you are using a reverse proxy like Envoy or NGINX, you can terminate mTLS there without changing application code. The proxy passes a header with the verified client identity to the upstream. The upstream checks that header.

Third, scope your tokens. If you already use JWTs for service authentication, stop putting role: service in the payload. Put allowed_services: ["orders-api"] and allowed_resources: ["order:*"]. Make the authorization as specific as the operation.

Frequently asked questions about service authentication without ambient authority

Is mTLS enough, or do I need capabilities too?

mTLS proves identity. Capabilities prove authorization. They solve different problems. A service with a valid certificate should still not be allowed to delete arbitrary data. Use mTLS for transport security and identity, capabilities for access control.

What about service meshes like Istio or Linkerd?

Service meshes automate mTLS and identity between pods. They are a practical way to remove ambient authority without rewriting every service. The mesh handles certificate rotation, identity attestation, and traffic encryption. The trade-off is complexity and a new control plane to secure.

Does this work for serverless functions?

Yes, but it is harder. Serverless functions are ephemeral, so certificate rotation and identity attestation need to happen at invoke time. Cloud providers offer workload identity for this (AWS IAM Roles for Service Accounts, GCP Workload Identity). These are capability-like: the function receives a token scoped to what it is allowed to do, not a blanket credential.

What is the difference between a service account and a capability?

A service account is an identity that carries ambient authority by default. If Service A runs as payments-sa, it can usually do anything payments-sa is allowed to do. A capability is scoped to a specific action on a specific resource. Even if the capability is stolen, it cannot be replayed against a different target.

Start with the perimeter you already have

Your VPC was never a security boundary. It was a network boundary that we pretended was a security boundary. Removing ambient authority means treating every service call as if it crosses that boundary. Cryptographic identity and scoped capabilities are the tools. The hard part is admitting that the old model was convenience dressed up as security.

Pick one internal API. Add client certificate verification. Watch what breaks. Most of the time, the thing that breaks is an assumption you did not know your code was making.