Skip to Content
ConceptsCore ProtocolInternal gRPC mTLS

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 serversPort
Issuer Registry50053
Credential Issuance50051
Verification Engine50052
Program Catalog50054
Learner Records50055
Custodian Registry50056
gRPC clientsCalls
Core EngineAll servers above
Verification EngineIssuer Registry, Credential Issuance
Credential IssuanceIssuer 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 by deployment/scripts/generate-certs.sh into deployment/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.sh generates certs automatically on first start if deployment/certs/ca.crt is missing; deploy.sh purge wipes 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: REQUIRE

client-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

CheckExpected result
grpcurl -plaintext against any gRPC server portTimes out at the TLS handshake
mTLS dial with the correct client cert + CAgrpc.health.v1.Health/Check → SERVING
Dial with a cert whose SAN doesn’t match the target hostCertificate 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-issued Secret at the same /certs path — 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:bootRun

Standard Spring Boot relaxed binding maps the env vars to grpc.server.security.* — no code change required.