Authenticating both sides of an internal connection via certificate instead of an API key
API keys for internal service-to-service communication have a structural problem: a stolen key can be used from anywhere, without the server noticing that the request does not actually come from the expected service. Mutual TLS solves this by having both sides of the connection identify each other via certificate, not just the server to the client.
Table of Contents
- 1. Why API keys are not enough for internal communication
- 2. Your own certificate authority as the trust anchor
- 3. Configuring mTLS in Symfony clients and servers
- 4. Certificate rotation without downtime
- 5. mTLS as a building block of a zero-trust architecture
- 6. Service mesh as an alternative to manual mTLS implementation
- 7. Common pitfalls in practical rollout
- 8. Systematically narrowing down mTLS connection failures
- 9. mTLS compared to other service authentication methods
- 10. Summary
- 11. FAQ
1. Why API keys are not enough for internal communication
With classic TLS, only the server identifies itself to the client: the client checks the server certificate, but the server has no cryptographic guarantee of who is actually on the other end of the connection, beyond whatever an API key or bearer token in the request claims. If that key is compromised, for example through a leak in a config file or a compromised container, an attacker can impersonate the legitimate service from anywhere.
Mutual TLS (mTLS) extends the TLS handshake with a second direction: the client also presents a certificate, which the server checks against a trusted Certificate Authority (CA). An attacker without the matching private key material cannot establish a valid connection even with a stolen API key, because the TLS handshake itself already fails, long before the application layer ever sees a request.
2. Your own certificate authority as the trust anchor
For internal mTLS between your own services, an internal Certificate Authority is usually run instead of using public CAs like Let's Encrypt, since internal service names (order-service.internal) are not publicly resolvable anyway and public CAs would not sign such names. This internal CA signs both server and client certificates for every service in the infrastructure.
Building your own PKI (Public Key Infrastructure) sounds more elaborate than it actually is with modern tools. Tools like HashiCorp Vault's PKI secrets engine or cert-manager in Kubernetes fully automate issuing, renewing, and revoking certificates, so a single team does not have to maintain the internal CA manually with OpenSSL commands.
# Creating an internal CA and service certificate with Vault PKI (simplified)
vault secrets enable pki
vault secrets tune -max-lease-ttl=87600h pki
vault write pki/root/generate/internal \
common_name="internal-ca.mironsoft.local" \
ttl=87600h
vault write pki/roles/order-service \
allowed_domains="order-service.internal" \
allow_subdomains=true \
max_ttl="720h"
# Issuing a certificate for order-service
vault write pki/issue/order-service \
common_name="order-service.internal" \
ttl="720h"
3. Configuring mTLS in Symfony clients and servers
On the client side, Symfony's HttpClient component configures the client's own certificate and private key through the local_cert and local_pk options, in addition to regular TLS verification of the server certificate against the internal CA. On the server side, the web server (Nginx, Apache) must be configured to demand a client certificate and verify it against the internal CA, before the request is even forwarded to the Symfony application.
The actual Symfony application usually receives information about the validated client certificate through headers set by the web server (e.g. X-SSL-Client-DN), from which the identity of the calling service can be derived, for example for fine-grained authorization decisions beyond pure connection authentication.
<?php
// Configuring Symfony HttpClient with an mTLS client certificate
use Symfony\Component\HttpClient\HttpClient;
$client = HttpClient::create([
'local_cert' => '/etc/certs/order-service.pem',
'local_pk' => '/etc/certs/order-service.key',
'cafile' => '/etc/certs/internal-ca.pem',
'verify_peer' => true,
'verify_host' => true,
]);
$response = $client->request('GET', 'https://pricing-service.internal/api/prices/SKU-123');
4. Certificate rotation without downtime
Certificates should be short-lived, typically days rather than years, to minimize the window of exposure of a compromised certificate. Short-lived certificates, however, require automated renewal, since a manual rotation process for hundreds of services every few days is practically infeasible and would regularly cause outages from expired certificates.
Tools like cert-manager in Kubernetes or a Vault agent sidecar renew certificates automatically before expiry and place them at a defined location in the filesystem, without requiring the service itself to restart, as long as the application re-reads the certificate from the filesystem on every new connection instead of caching it permanently in memory.
5. mTLS as a building block of a zero-trust architecture
mTLS is a central building block of the zero-trust security model, where no trust is implied purely based on network position, such as 'inside the internal network, therefore trustworthy.' Instead, every connection, even within the same internal network, must be cryptographically authenticated, regardless of whether the traffic crosses an internal or external network segment.
This mindset is becoming increasingly relevant because classic network segmentation alone is no longer considered a sufficient security boundary, especially in cloud environments with dynamic infrastructure, where IP addresses change frequently and purely IP-based access control becomes unreliable.
6. Service mesh as an alternative to manual mTLS implementation
Instead of manually implementing mTLS in every single service, service mesh solutions like Istio or Linkerd handle the entire mTLS management transparently through sidecar proxies: every service gets a proxy attached that automatically encrypts and authenticates all incoming and outgoing traffic via mTLS, without the application code itself noticing any of it.
The benefit is noticeably less boilerplate code in every individual service and centralized control over mTLS policies. The downside is additional infrastructure complexity from the service mesh itself, which often does not justify the effort for smaller system landscapes with few services.
7. Common pitfalls in practical rollout
A common mistake is not proactively monitoring certificate expiry dates: an expired certificate causes sudden connection failures between services that at first glance look like a network problem rather than a certificate issue. A monitoring alert that warns a few days before a certificate expires reliably prevents such production surprises.
A second common mistake is inadequate certificate revocation handling: if a service is compromised, its certificate must be revocable immediately, not just after its natural expiry. A working Certificate Revocation List (CRL) or OCSP check is therefore not an optional detail, it is an integral part of a production-ready mTLS rollout.
8. Systematically narrowing down mTLS connection failures
mTLS failures usually show up as generic TLS handshake errors without a meaningful application-level error message, which complicates troubleshooting. A structured approach first checks with openssl s_client whether the server even requests a client certificate, then whether the presented client certificate is valid and signed by the expected CA, and only afterward the actual application logic.
Meaningful logs at the web server level (not just in the Symfony application) are crucial, since a failed TLS handshake never reaches the application itself. Nginx and Apache both offer detailed SSL debug logs that show exactly at which point in the handshake the connection was rejected, for example due to an expired or untrusted client certificate.
# Manually testing an mTLS connection and narrowing down failures
openssl s_client -connect order-service.internal:443 \
-cert /etc/certs/pricing-service.pem \
-key /etc/certs/pricing-service.key \
-CAfile /etc/certs/internal-ca.pem
# Checking a certificate's validity and issuer
openssl x509 -in /etc/certs/pricing-service.pem -noout -dates -issuer
9. mTLS compared to other service authentication methods
The table below compares mTLS to other common approaches to service-to-service authentication, to make the choice easier for your own system.
| Method | Protection if secret is stolen | Implementation effort | Typical use |
|---|---|---|---|
| API key | No protection, usable immediately | Low | Simple internal setups, low security requirements |
| Bearer token/JWT | No protection until expiry | Medium | Most common standard for internal APIs |
| mTLS | High, private key still required | High without service mesh | Zero-trust requirements, regulated industries |
| mTLS via service mesh | High, transparently managed | Medium (mesh setup one-time) | Larger microservice landscapes |
Mironsoft
OpenAPI design, Symfony APIs, and API security
APIs that external teams can integrate without back-and-forth questions?
We review existing REST APIs for inconsistent error formats, missing OpenAPI documentation, and security gaps, then build an API that is clearly documented, versioned, and hardened against abuse.
API Review
Checking the OpenAPI spec, error formats, and status codes for consistency.
Symfony Implementation
Using DTOs, Serializer, and Validator for clean, type-safe request/response models.
Security Audit
Hardening rate limiting, auth schemes, and input validation against real attack surfaces.
10. Summary
mTLS for Service-to-Service APIs: The Essentials at a Glance
Core principle
Both sides of the connection authenticate via certificate, not just the server to the client.
PKI setup
An internal Certificate Authority signs certificates for every service, automated via Vault or cert-manager.
Rotation
Short-lived certificates with automated renewal minimize the risk window of a compromised key.
Service mesh
Istio or Linkerd take over mTLS transparently via sidecar, significantly reducing boilerplate code in every individual service.