Internal gRPC mTLS
Every internal gRPC connection — server and client — requires mutual TLS. A server that can’t load its certificate fails to start; a client without a valid client certificate is rejected at the TLS handshake, before any RPC is dispatched. There is no plaintext fallback and no feature flag to disable it.
Scope
| gRPC servers | Port |
|---|---|
| Issuer Registry | 50053 |
| Credential Issuance | 50051 |
| Verification Engine | 50052 |
| Program Catalog | 50054 |
| Learner Records | 50055 |
| Custodian Registry | 50056 |
| gRPC clients | Calls |
|---|---|
| Core Engine | All servers above |
| Verification Engine | Issuer Registry, Credential Issuance |
| Credential Issuance | Issuer Registry |
Core Engine runs no gRPC server of its own (REST-in, gRPC-out) — client identity cert only. External REST/HTTPS traffic (browser ↔ Gateway, Gateway ↔ Core Engine) is unaffected; this is purely the internal gRPC mesh.
Cert generation and handshake
- One local CA, one leaf cert per service (
CN=<service>), generated bydeployment/scripts/generate-certs.shintodeployment/certs/(git-ignored, idempotent). - Each leaf’s SAN covers both the Docker Compose service DNS name and
localhost/127.0.0.1, so the same cert works whether the mesh runs fully in Compose or a service runs standalone via./gradlew :backends:<svc>:bootRun. deploy.shgenerates certs automatically on firststartifdeployment/certs/ca.crtis missing;deploy.sh purgewipes them along with other generated artifacts.
Server config
grpc:
server:
security:
enabled: true
certificate-chain: file:/certs/<service>.crt
private-key: file:/certs/<service>.key
trust-cert-collection: file:/certs/ca.crt
client-auth: REQUIREclient-auth: REQUIRE is what makes this mutual, not one-way, TLS.
Client config
A shared factory (backends/shared/.../grpc/MtlsChannelFactory.java) builds every outbound
channel, so the SSL-context logic isn’t duplicated across the 3 client call sites (Core Engine,
Verification Engine, Credential Issuance):
public static ManagedChannel build(String host, int port, String certPath, String keyPath,
String caPath, int maxInboundMessageSize) {
SslContext sslContext = GrpcSslContexts.forClient()
.keyManager(new File(certPath), new File(keyPath))
.trustManager(new File(caPath))
.build();
return NettyChannelBuilder.forAddress(host, port)
.sslContext(sslContext)
.maxInboundMessageSize(maxInboundMessageSize)
.directExecutor()
.build();
}Verifying it’s working
| Check | Expected result |
|---|---|
grpcurl -plaintext against any gRPC server port | Times out at the TLS handshake |
| mTLS dial with the correct client cert + CA | grpc.health.v1.Health/Check → SERVING |
| Dial with a cert whose SAN doesn’t match the target host | Certificate verification fails (SAN check active) |
Production and Kubernetes
- Deployed Compose has no secret-generation of its own — certs follow the same
${CERTS_DIR:?set CERTS_DIR}convention as every other deploy secret: pre-provisioned on the deploy host, or swapped for certs from a real internal CA. - No Kubernetes manifests exist yet. A future K8s migration replaces the bind mount with a
cert-manager-issuedSecretat the same/certspath — no application code changes needed.
Running a service outside Docker
export GRPC_SERVER_SECURITY_CERTIFICATE_CHAIN=file:$(pwd)/deployment/certs/issuer-registry.crt
export GRPC_SERVER_SECURITY_PRIVATE_KEY=file:$(pwd)/deployment/certs/issuer-registry.key
export GRPC_SERVER_SECURITY_TRUST_CERT_COLLECTION=file:$(pwd)/deployment/certs/ca.crt
./gradlew :backends:issuer-registry:bootRunStandard Spring Boot relaxed binding maps the env vars to grpc.server.security.* — no code
change required.