Testing a gRPC service from a remote machine fails without a valid certificate — or at least a self-signed one the client can trust. This article walks through generating an OpenSSL certificate with two shell scripts and wiring it into a Kestrel gRPC server, based on excellent write-ups by Daniel Roth and ServiceStack.
1. Install Prerequisites
Install Chocolatey, then install OpenSSL:
choco install openssl2. Generate Certificates
Two shell scripts are available — use gen-dev.https.sh for localhost development and gen-prod.https.sh for a real domain. This example uses the prod script (with an explicit password):
# gen-dev.https.sh — generates a self-signed localhost certificate
PASSWORD=grpc
if [ $# -ge 1 ]; then PASSWORD=$1; fi
cat > dev.config << 'EOT'
[ req ]
default_bits = 2048
default_md = sha256
default_keyfile = dev.key
prompt = no
encrypt_key = no
distinguished_name = dn
req_extensions = v3_req
x509_extensions = x509_req
string_mask = utf8only
[ dn ]
commonName = localhost dev cert
emailAddress = test@localtest.me
countryName = US
stateOrProvinceName = DE
localityName = Wilmington
organizationName = Todo World
[ x509_req ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
basicConstraints = critical, CA:false
keyUsage = critical, keyEncipherment
subjectAltName = @alt_names
nsComment = "OpenSSL Generated Certificate"
[ v3_req ]
subjectKeyIdentifier = hash
basicConstraints = critical, CA:false
subjectAltName = @alt_names
nsComment = "OpenSSL Generated Certificate"
[ alt_names ]
DNS.1 = localhost
EOT
openssl req -config dev.config -new -out dev.csr.pem
openssl x509 -req -days 365 -extfile dev.config -extensions v3_req -in dev.csr.pem -signkey dev.key -out dev.crt
openssl pkcs12 -export -out dev.pfx -inkey dev.key -in dev.crt -password pass:$PASSWORD
rm dev.config dev.csr.pem# gen-prod.https.sh — generates a certificate for a real domain
DOMAIN=todoworld.servicestack.net
if [ $# -ge 1 ]; then DOMAIN=$1; fi
PASSWORD=grpc
if [ $# -ge 2 ]; then PASSWORD=$2; fi
cat > prod.config << 'EOT'
[ req ]
default_bits = 2048
default_md = sha256
default_keyfile = prod.key
prompt = no
encrypt_key = no
distinguished_name = dn
req_extensions = v3_req
x509_extensions = x509_req
string_mask = utf8only
[ dn ]
commonName = TodoWorld prod cert
emailAddress = todoworld@servicestack.net
countryName = US
stateOrProvinceName = DE
localityName = Wilmington
organizationName = Todo World
[ x509_req ]
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid,issuer
basicConstraints = critical, CA:false
keyUsage = critical, keyEncipherment
subjectAltName = @alt_names
nsComment = "OpenSSL Generated Certificate"
[ v3_req ]
subjectKeyIdentifier = hash
basicConstraints = critical, CA:false
subjectAltName = @alt_names
nsComment = "OpenSSL Generated Certificate"
[ alt_names ]
DNS.1 = $DOMAIN
EOT
openssl req -config prod.config -new -out prod.csr.pem
openssl x509 -req -days 365 -extfile prod.config -extensions v3_req -in prod.csr.pem -signkey prod.key -out prod.crt
openssl pkcs12 -export -out prod.pfx -inkey prod.key -in prod.crt -password pass:$PASSWORD
rm prod.config prod.csr.pemRun from PowerShell:
# dev
./gen-dev.https.sh
# prod
./gen-prod.https.sh your.domain.com grpc
3. Kestrel Server — ConfigureEndpoints
The extension method reads endpoint configuration from appsettings.json and loads the certificate from a file path. The certificate password is read from an environment variable mycertpass — never hard-coded. If the environment name is LinuxDevelopment it binds to 0.0.0.0 instead of loopback:
public static class KestrelServerOptionsExtensions
{
public static void ConfigureEndpoints(this KestrelServerOptions options)
{
var configuration = options.ApplicationServices.GetRequiredService<IConfiguration>();
var environment = options.ApplicationServices.GetRequiredService<IWebHostEnvironment>();
var endpoints = configuration
.GetSection("HttpServer:Endpoints")
.GetChildren()
.ToDictionary(s => s.Key, s =>
{
var ep = new EndpointConfiguration();
s.Bind(ep);
return ep;
});
foreach (var endpoint in endpoints)
{
var config = endpoint.Value;
var port = config.Port ?? (config.Scheme == "https" ? 443 : 80);
var ipAddresses = new List<IPAddress>();
if (config.Host == "localhost")
{
if (environment.EnvironmentName == "LinuxDevelopment")
ipAddresses.Add(IPAddress.Parse("0.0.0.0"));
else
{
ipAddresses.Add(IPAddress.IPv6Loopback);
ipAddresses.Add(IPAddress.Loopback);
}
}
else if (IPAddress.TryParse(config.Host, out var address))
ipAddresses.Add(address);
else
ipAddresses.Add(IPAddress.IPv6Any);
foreach (var address in ipAddresses)
{
options.Listen(address, port, listenOptions =>
{
if (config.Scheme == "https")
{
var certPass = Environment.GetEnvironmentVariable("mycertpass")
?? throw new Exception("Environment variable 'mycertpass' not set.");
config.Password = certPass;
listenOptions.UseHttps(LoadCertificate(config, environment));
}
});
}
}
}
private static X509Certificate2 LoadCertificate(
EndpointConfiguration config, IWebHostEnvironment environment)
{
if (config.FilePath != null && config.Password != null)
{
// Normalise path separators and resolve relative paths
string[] parts = config.FilePath.Split('/', '\');
string fixedPath = string.Join(Path.DirectorySeparatorChar, parts);
if (!File.Exists(fixedPath))
fixedPath = Path.Combine(AppContext.BaseDirectory, fixedPath);
if (!File.Exists(fixedPath))
throw new Exception("Certificate not found at the specified path.");
return new X509Certificate2(fixedPath, config.Password);
}
throw new InvalidOperationException("No valid certificate configuration found.");
}
}
public class EndpointConfiguration
{
public string Host { get; set; }
public int? Port { get; set; }
public string Scheme { get; set; }
public string StoreName { get; set; }
public string StoreLocation { get; set; }
public string FilePath { get; set; }
public string Password { get; set; }
}Replace the Kestrel section in appsettings.json with the custom HttpServer:Endpoints block, then add the .pfx file under a certificates/ folder with "Copy if newer" set in build settings:
// appsettings.json — replace the Kestrel section with HttpServer:Endpoints
{
"HttpServer": {
"Endpoints": {
"Http": {
"Host": "localhost",
"Port": 5000,
"Scheme": "http"
},
"Https": {
"Host": "localhost",
"Port": 5001,
"Scheme": "https",
"FilePath": "certificates/prod.pfx"
}
}
}
}Hook it into Program.cs and set the environment variable mycertpass in Windows System Properties (restart Visual Studio after):
// Program.cs — tell Kestrel to use the custom endpoint configuration
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureKestrel(options => options.ConfigureEndpoints());
});For Docker, pass the environment variable in your docker-compose.yml under environment: - mycertpass=grpc. This is acceptable for development; use secrets management in production.
4. Grpc Client
Add the .crt file to the client project (Copy if newer). Because this is a self-signed certificate with no trusted CA, you must accept it by thumbprint via a custom validation callback:
// Console gRPC client — trust the self-signed certificate by thumbprint
var certificate = new X509Certificate2("certificates/prod.crt");
var httpHandler = new HttpClientHandler();
httpHandler.ServerCertificateCustomValidationCallback =
(message, cert, chain, errors) =>
cert.Thumbprint == certificate.Thumbprint || errors == SslPolicyErrors.None;
var channel = GrpcChannel.ForAddress("https://your-server:5001",
new GrpcChannelOptions { HttpHandler = httpHandler });
var client = new Greeter.GreeterClient(channel);
var reply = await client.SayHelloAsync(new HelloRequest { Name = "World" });