Strengthening Microservices: Implementing mTLS over gRPC for Trusted Communication
Introduction
In microservices, service-to-service communication must be secure, especially in distributed or cloud environments. While gRPC handles fast binary messaging, it doesn’t protect against untrusted sources by default.
That’s where mTLS (mutual TLS) comes in — a secure handshake where both services authenticate each other using certificates from a shared CA.
In this post, we’ll start by breaking down how TLS works, then dive into the algorithms behind it and how to issue your own certs. From there, we’ll walk through a step-by-step implementation using NestJS + gRPC + mTLS, and finish with a working setup, real results, and a wrap-up of best practices.
Understanding TLS
TLS is the backbone of secure communication on the internet. It protects your data from being read or tampered with by attackers. Let’s break down how TLS works and what those mysterious cipher suites actually mean.
The TLS Handshake: Securely Switching to Symmetric
- Client Hello
The client says:
“Hi! I support these TLS versions and cipher suites. Let’s agree on how to talk securely.” - Server Hello
The server replies with:
- The chosen TLS version
- The selected cipher suite
- Its certificate (which includes the public key) - Certificate Verification
The client:
- Verifies the server’s certificate (e.g., using a CA it trusts). If this check fails, the handshake terminates immediately.
- Extracts the public key from the certificate - Key Exchange (ECDHE)
- The server sends Diffie-Hellman parameters (curve, ECDHE Pubkey, etc.)
- These parameters are digitally signed using the server’s private key. Hence, the client can use the public key (from the verified certificate) to validate the signature and ensure the parameters weren’t tampered with (defending against MITM) - Shared Key Generation
- After verifying everything, the client sends its own ECDHE Pubkey
- Now both sides compute the same symmetric key using ECDHE. This key is never sent over the wire — it’s generated independently on both ends. - Secure Session Begins
- All future communication is encrypted using the symmetric key (e.g., AES-256-GCM)
- Integrity is ensured using hashing (e.g., SHA-384)
What’s Inside a Cipher Suite?
A cipher suite is a collection of algorithms TLS will use for:
- Key exchange (e.g., Diffie-Hellman, ECDHE, RSA)
- Authentication (e.g., RSA, ECDSA)
- Symmetric encryption (e.g., AES-GCM, ChaCha20)
- Message integrity (e.g., SHA256, SHA384)
Here’s an example:
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256- ECDHE: Elliptic Curve Diffie-Hellman Ephemeral (secure key exchange)
- ECDSA: Elliptic Curve Digital Signature Algorithm — used for authentication, meaning the server proves its identity using an ECDSA certificate (instead of RSA).
- AES_128_GCM: Advanced Encryption Standard with 128-bit key in Galois/Counter Mode — used for encrypting the actual data.
- SHA256: Used for message integrity, ensuring the encrypted message hasn’t been tampered with.
Before we dive deeper, let’s cover a few key concepts that will help you understand how TLS actually works.
Symmetric Encryption
- Uses one shared key for both encrypting and decrypting.
- Much faster and more efficient for large amounts of data.
- Used in: bulk data encryption during a session.
📦 Example: AES, ChaCha20
Hashing Algorithms: Ensuring Data Integrity
Encryption keeps your data confidential, but how do we make sure it wasn’t tampered with in transit?
That’s where hashing algorithms come in.
A hash is like a digital fingerprint of your data — a fixed-length string of characters that uniquely represents the original message. Even a tiny change in the input (like changing one letter) results in a completely different hash.
Example:
sha256("hello") => 2cf24dba5fb0a...
sha256("hello!") => b1946ac92492d...In TLS, a hashing algorithm like SHA-256 or SHA-384 is used to generate a digest of the handshake data (e.g. key exchange parameters), then be signed by the server’s private key (e.g., RSA or ECDSA), we’ll see this in the next section with Asymmetric Encryption
If the hashes match, the data hasn’t been modified — this proves integrity and authenticity.
This is a core part of why TLS is secure — even if an attacker intercepts the traffic, they can’t modify anything without breaking the signature or causing a hash mismatch.
Asymmetric Encryption
- Uses two keys: a public key (shared with the world) and a private key (kept secret).
- Messages encrypted with the public key can only be decrypted by the private key.
- Used in: authentication, digital signatures, and secure key exchange.
📦 Example: RSA, ECDSA
In TLS, asymmetric encryption (public/private key crypto) is not used to encrypt all the communication — that would be too slow and computationally expensive, as well as requiring the public-private keys pair on both sides is considered complicated.
Instead, it’s only used for validating chain of trust of certificates (out of scope for this post) and be used during the key exchange phase, specifically to:
- Generate a digest of the handshake data (e.g. key exchange parameters)
- Sign this digest using the server’s private key (e.g., RSA or ECDSA)
- Send the signed hash along with the server’s certificate
Then the client:
- Uses the server’s public key (from the cert) to verify the signature
- Recomputes the hash of the same handshake data
- Compares it to the signed version
The encryption of data sent between client and server during communication is performed through Symmetric Encryption — a much faster and more efficient way. But how to create a shared one and securely transmit that in the public network is not that easy. That leads us to the concept of Diffie-Hellman algorithm.
With ephemeral key exchange (ECDHE), each session gets a new symmetric key, so even if it’s compromised later, past sessions stay safe → Perfect Forward Secrecy.
Alright, now that we’ve covered the essentials, let’s get back into how TLS actually works.
The Magic of Diffie-Hellman (Key Exchange)
Diffie-Hellman (especially ECDHE) is how both sides generate the same symmetric key — without ever sending it directly.
Each side generates a private key, computes a public version, exchanges that public data, and ends up with the same secret.
Here’s a snapshot captured using Wireshark, showing a packet exchanged during the TLS key exchange process.
When using Elliptic Curve Diffie-Hellman Ephemeral (ECDHE), the server sends the following:
- Curve Type: Tells the client the form of the elliptic curve equation. Typically, this is something like
y² = x³ + ax + b. The curve type determines the values ofaandb. - Named Curve: Instead of sending full curve parameters, a predefined curve (like
secp256r1) is used. This includes the starting generator pointG. - Public Key (Pubkey): This is the result of multiplying the server’s private key with the base point
G. Think of it as:
PubKey = privKey × GThe client uses this information, performs a similar operation with its own private key, then send its PubKey back to the server. Eventually, and both end up with the same shared secret key.
Key = privKey × PubKeyEven though the PubKey are exchanged in the open, the private keys are never sent. Thanks to the hardness of the elliptic curve discrete logarithm problem, an attacker can’t reverse-engineer the private key from the PubKey. If someone compromises the TLS cert in the future, they still can’t decrypt past messages.
How mTLS Works: Algorithms and Certificate Issuance
Normally in TLS, the server has a TLS certificate and a public/private key pair, while the client does not.
In mTLS, however, both the client and server have a certificate, and both sides authenticate using their public/private key pair. Compared to regular TLS, there are additional steps in mTLS to verify both parties.
- Client connects to server
- Server presents its TLS certificate
- Client verifies the server’s certificate
- Client presents its TLS certificate
- Server verifies the client’s certificate
- Key Exchange (ECDHE), both sides perform Diffie-Hellman key exchange to derive the same shared secret
- Client and server exchange information over encrypted TLS connection
The organization implementing mTLS acts as its own certificate authority. This contrasts with standard TLS, in which the certificate authority is an external organization that checks if the certificate owner legitimately owns the associated domain.
Get BrianKim’s stories in your inbox
Join Medium for free to get updates from this writer.
A “root” TLS certificate is necessary for mTLS; this enables an organization to be their own certificate authority. The certificates used by authorized clients and servers have to correspond to this root certificate. The root certificate is self-signed, meaning that the organization creates it themselves.
Step-by-Step Implementing mTLS in NestJS Microservices with gRPC
Init Both Services
nest new user-service
nest new order-serviceChoose:
- Package manager:
npm - Skip testing if you want a clean base
then install Packages & Setup Auto Proto Generation
npm install @nestjs/microservices @grpc/grpc-js @grpc/proto-loader
npm install --save-dev @protobuf-ts/protoc @protobuf-ts/pluginand adding this script in package.json
"scripts": {
"proto:gen": "protoc \
--plugin=protoc-gen-ts=$(pwd)/node_modules/.bin/protoc-gen-ts \
--ts_out=src/generated \
--proto_path=proto \
proto/*.proto"
}For user-service/main.ts, it supposes to serve both HTTP and gRPC request:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { join } from 'path';
import * as fs from 'fs';
import * as grpc from '@grpc/grpc-js';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const ca = fs.readFileSync(join(__dirname, '../certs/ca.crt'));
const cert = fs.readFileSync(join(__dirname, '../certs/user.crt'));
const key = fs.readFileSync(join(__dirname, '../certs/user.key'));
// gRPC server for user.proto
app.connectMicroservice<MicroserviceOptions>({
transport: Transport.GRPC,
options: {
url: '0.0.0.0:50051',
package: 'user',
protoPath: join(__dirname, '../proto/user.proto'),
credentials: grpc.ServerCredentials.createSsl(
ca, // root CA
[
{
cert_chain: cert,
private_key: key,
},
],
true, // require client certificate
),
},
});
await app.startAllMicroservices(); // 👈 start gRPC
await app.listen(3000); // 👈 start HTTP
console.log(
'🚀 user-service running on http://localhost:3000 and gRPC on :50051',
);
}
bootstrap();The same setting for order-service, except it does not listen for serving any HTTP API. Next, we register order client in user service app module:
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import * as fs from 'fs';
import { join } from 'path';
import * as grpc from '@grpc/grpc-js';
@Module({
imports: [
ClientsModule.register([
{
name: 'ORDER_PACKAGE',
transport: Transport.GRPC,
options: {
url: 'order-service:50051',
package: 'order',
protoPath: join(__dirname, '../proto/order.proto'),
credentials: grpc.credentials.createSsl(
fs.readFileSync(join(__dirname, '../certs/ca.crt')), // root CA
fs.readFileSync(join(__dirname, '../certs/user.key')), // client key
fs.readFileSync(join(__dirname, '../certs/user.crt')), // client cert
),
},
},
]),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}From now on, we can inject that client to any service that needs to communicate with the order service. That’s all we need to bring microservices to live. The rest of the implementation for controllers, services, or any app behaviours are up to you, or you can follow this Nest official document.
Setup mTLS with Certs
From project root:
mkdir -p certs && cd certs
# 1. Generate CA
openssl genrsa -out ca.key 4096
openssl req -x509 -new -key ca.key -sha256 -days 365 -out ca.crt -subj "/CN=ca.local"
# 2. order-service (with SAN = order-service)
openssl genrsa -out order.key 2048
openssl req -new -key order.key -out order.csr -subj "/CN=order-service"
openssl x509 -req -in order.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out order.crt -days 365 \
-extfile <(printf "subjectAltName=DNS:order-service")
# 3. user-service (with SAN = user-service)
openssl genrsa -out user.key 2048
openssl req -new -key user.key -out user.csr -subj "/CN=user-service"
openssl x509 -req -in user.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out user.crt -days 365 \
-extfile <(printf "subjectAltName=DNS:user-service")Then copy certs into each service:
cp ca.crt user.crt user.key user-service/certs/
cp ca.crt order.crt order.key order-service/certs/Private keys (*.key) and certificates (*.crt) must not be committed to Git. Doing so would expose your infrastructure to severe security risks, we’ll create environment secrets later on for storing them in Github and retrieving once the deployment happens.
Creating Dockerfile & Docker Compose
Same structure for both user and order services but only exposes port 50051 for the later. And, we will treat the user-service as a gateway of handling client requests passing from Nginx web server.
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run proto:gen && npm run build
EXPOSE 3000
EXPOSE 50051
CMD ["node", "dist/main"]For local development, create docker-compose.yaml
version: "3.9"
services:
user-service:
build: ./user-service
ports:
- "3000:3000" # HTTP (for NGINX / REST clients)
- "50052:50051" # gRPC (for internal microservice comms)
depends_on:
- order-service
order-service:
build: ./order-service
ports:
- "50051:50051"
nginx:
image: nginx:latest
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- user-serviceBut for the production, we will replace images configured in docker-compose file with the latest one built in the CD step, then create another one for prod:
version: "3.9"
services:
user-service:
image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/user-service:latest # 🔁 Will be replaced by GitHub Actions (sed)
ports:
- "3000:3000" # HTTP (for NGINX / REST clients)
- "50052:50051" # gRPC (for internal microservice comms)
restart: always
environment:
- NODE_ENV=production
depends_on:
- order-service
order-service:
image: 123456789012.dkr.ecr.us-east-1.amazonaws.com/order-service:latest # 🔁 Will be replaced by GitHub Actions (sed)
ports:
- "50051:50051"
restart: always
environment:
- NODE_ENV=production
nginx:
image: nginx:latest
ports:
- "80:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- user-serviceNGINX Reverse Proxy
Simply passing all request to the user-service, from that, the user-service will internally talks to the order-service.
events {}
http {
upstream user_api {
server user-service:3000;
}
server {
listen 80;
location / {
proxy_pass http://user_api;
}
}
}GitHub Actions: CI/CD with ECR + EC2
In this configuration, we authenticate github with AWS by OIDC, then build images and push to ECR. Later on, login to EC2 with SSH and pull the newest images to EC2 instance and initialize docker container with docker-compose.prod.yaml file.
name: Deploy Services to EC2 via ECR
on:
push:
branches: [dev]
jobs:
deploy:
name: Deploy to EC2 (Dev)
runs-on: ubuntu-latest
environment: dev
permissions:
id-token: write
contents: read
env:
IMAGE_TAG: ${{ github.sha }}
APP_DIR: /home/ec2-user
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Configure AWS OIDC Credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: ${{ secrets.ROLE_ARN }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Log in to Amazon ECR
run: |
aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | \
docker login --username AWS --password-stdin \
${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com
- name: Inject mTLS Certs into user-service and order-service
run: |
mkdir -p user-service/certs order-service/certs
echo "${{ secrets.USER_CRT }}" > user-service/certs/user.crt
echo "${{ secrets.USER_KEY }}" > user-service/certs/user.key
echo "${{ secrets.CA_CRT }}" > user-service/certs/ca.crt
echo "${{ secrets.ORDER_CRT }}" > order-service/certs/order.crt
echo "${{ secrets.ORDER_KEY }}" > order-service/certs/order.key
echo "${{ secrets.CA_CRT }}" > order-service/certs/ca.crt
- name: Build & Push Docker Images
run: |
# USER SERVICE
docker build -t user-service:${{ env.IMAGE_TAG }} ./user-service
docker tag user-service:${{ env.IMAGE_TAG }} \
${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_USER_REPO }}:${{ env.IMAGE_TAG }}
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_USER_REPO }}:${{ env.IMAGE_TAG }}
# ORDER SERVICE
docker build -t order-service:${{ env.IMAGE_TAG }} ./order-service
docker tag order-service:${{ env.IMAGE_TAG }} \
${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_ORDER_REPO }}:${{ env.IMAGE_TAG }}
docker push ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_ORDER_REPO }}:${{ env.IMAGE_TAG }}
- name: 📦 Copy docker-compose and nginx config to EC2
uses: appleboy/scp-action@v0.1.4
with:
host: ${{ secrets.EC2_HOST }}
username: ec2-user
key: ${{ secrets.EC2_SSH_KEY }}
source: "docker-compose.prod.yaml,nginx/nginx.conf"
target: ${{ env.APP_DIR }}
debug: true
- name: 🚀 SSH Deploy to EC2
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.EC2_HOST }}
username: ec2-user
key: ${{ secrets.EC2_SSH_KEY }}
script: |
cd $APP_DIR
# Update docker-compose.prod.yaml with the latest image tags
sed -i "s|image:.*user-service.*|image: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_USER_REPO }}:${{ env.IMAGE_TAG }}|" docker-compose.prod.yaml
sed -i "s|image:.*order-service.*|image: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com/${{ secrets.ECR_ORDER_REPO }}:${{ env.IMAGE_TAG }}|" docker-compose.prod.yaml
# 🚀 Log in to ECR using EC2 IAM role
aws ecr get-login-password --region ${{ secrets.AWS_REGION }} | \
docker login --username AWS --password-stdin \
${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ secrets.AWS_REGION }}.amazonaws.com
docker-compose -f docker-compose.prod.yaml pull
docker-compose -f docker-compose.prod.yaml down
docker-compose -f docker-compose.prod.yaml up -dDon’t forget to assign a role for your EC2 instance so that it can pull images from your ECR. Below is the recommended minimal ECR Pull Policy for EC2:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchCheckLayerAvailability",
"ecr:GetDownloadUrlForLayer",
"ecr:BatchGetImage"
],
"Resource": "*"
}
]
}Practical Demo: Running and Verifying Secure Communication
After completing the deployment process, we verified that secure mTLS communication between services was working correctly.
To test the system:
- Deployed both
user-serviceandorder-serviceto the EC2 instance using GitHub Actions and Docker Compose. - Ensured mTLS certificates were injected properly into each service during the CI/CD pipeline.
- Verified logs confirmed mutual authentication between services on startup.
Postman Test
We sent a request to the user-service endpoint using Postman:
GET http://<your-domain>/user/1- The
user-servicemade a secure gRPC call toorder-service. - The
order-serviceresponded with the user's order history. - The response was returned to Postman successfully.
If you try with an invalid certificate, for example, re-sign the order certificate by changing subjectAltName=DNS:localhost. Rebuild and test it with Postman, you’ll encounter the following error:
🛡️ Verified Security
- All inter-service communication is encrypted and authenticated using mutual TLS.
- Unauthorized services without valid certificates are rejected during the handshake.
This confirms the setup is secure, resilient, and production-ready for handling internal service communication via gRPC and mTLS.
Conclusion
In this guide, we explored how to protect microservice communication using mutual TLS (mTLS) over gRPC. We covered:
- The need for trust in service-to-service communication
- How TLS and mTLS work under the hood
- Generating and managing certificates with OpenSSL
- Implementing a real-world setup using NestJS, Docker, and GitHub Actions
By enforcing mTLS, you’re not just encrypting traffic — you’re ensuring that only verified services can talk to each other. This protects against internal threats, spoofed services, and unauthorized access.
As your system grows, securing internal APIs with mTLS becomes just as important as securing public ones. It’s a small effort for a massive gain in trust and resilience.
Start small, secure early — and scale with confidence.











