The wire is never safe
You have two services. One handles user requests, the other talks to a database. They communicate over gRPC. You deploy them to a Kubernetes cluster or a cloud VPC. Someone with network access captures the traffic and reads your queries in plain text. That's the risk. gRPC does not encrypt traffic by default. It assumes you will configure security. TLS stops the wiretap. It encrypts the data and proves the server's identity. In Go, you provide the certificates and wrap them in gRPC credentials. The framework handles the handshake.
Credentials bridge the gap
gRPC abstracts transport details behind the grpc.Creds interface. TLS is one implementation of credentials. You build a standard crypto/tls.Config, wrap it in credentials.NewTLS, and pass that to the server or client options. The server presents its certificate during the handshake. The client verifies the certificate against a trusted Certificate Authority. If verification succeeds, the connection is encrypted. If it fails, the connection drops.
gRPC is not HTTP/1.1. It uses HTTP/2. You cannot drop a tls.Config into a gRPC server like you do with net/http. gRPC has its own server implementation that expects credentials via options. This separation keeps protocol details distinct and allows gRPC to support other credential types like OAuth2 or mutual TLS without changing the core API.
Server setup
Here's the server configuration: load the certificate and key, build the TLS config, wrap it in credentials, and pass it to the server constructor.
package main
import (
"crypto/tls"
"log"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// StartServer launches a gRPC server with TLS enabled.
func StartServer() {
// Load the certificate and private key from PEM files.
cert, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
log.Fatalf("failed to load key pair: %v", err)
}
// Build the TLS config with the loaded certificate.
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
// Set MinVersion to drop support for deprecated protocols.
MinVersion: tls.VersionTLS12,
}
// Wrap the TLS config into gRPC transport credentials.
creds := credentials.NewTLS(tlsConfig)
// Create the server with the credentials option.
server := grpc.NewServer(grpc.Creds(creds))
// Listen on port 50051.
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
log.Println("serving gRPC with TLS on :50051")
if err := server.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
Run gofmt on your code. The indentation of the TLS config doesn't matter to the compiler, but it matters to your team. Most editors run gofmt on save. Trust the tool. Argue logic, not formatting.
gRPC doesn't guess your certs. Hand them the credentials or keep the wire open.
What happens under the hood
tls.LoadX509KeyPair parses the PEM files and returns a tls.Certificate struct containing the public certificate and private key. You put that in the Certificates slice of tls.Config. The credentials.NewTLS function takes this config and returns a credentials.TransportCredentials object. gRPC uses this object during the connection handshake.
When server.Serve starts, it accepts connections. For each connection, gRPC invokes the ServerHandshake method on the credentials object. This method performs the TLS handshake. It reads the client's hello, sends the certificate chain, negotiates the cipher suite, and establishes the secure session. Only after the handshake succeeds does gRPC start processing gRPC frames. Your service handlers never see raw TLS bytes. They see decrypted streams.
The tls.Config struct controls the security policy. Go's defaults are permissive to support ancient clients. If you pass a nil config or an empty struct, you might get TLS 1.0 support. Modern services should drop those. Set MinVersion: tls.VersionTLS12 explicitly. If you need TLS 1.3, set MinVersion: tls.VersionTLS13. You can also restrict cipher suites for compliance. gRPC enforces whatever policy you define in the config.
Client verification
Clients need to verify the server. Load the CA certificate, build a pool, and pass it in the TLS config so the client rejects unknown servers.
package main
import (
"crypto/tls"
"crypto/x509"
"fmt"
"log"
"os"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
// ConnectClient creates a gRPC client connection with TLS verification.
func ConnectClient() (*grpc.ClientConn, error) {
// Load the CA certificate that signed the server's cert.
caCert, err := os.ReadFile("ca.crt")
if err != nil {
return nil, fmt.Errorf("read ca cert: %w", err)
}
// Create a certificate pool and add the CA cert.
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(caCert) {
return nil, fmt.Errorf("failed to parse ca cert")
}
// Configure TLS to verify the server against the CA pool.
tlsConfig := &tls.Config{
RootCAs: certPool,
MinVersion: tls.VersionTLS12,
}
creds := credentials.NewTLS(tlsConfig)
// Dial the server with transport credentials.
conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(creds))
if err != nil {
return nil, fmt.Errorf("dial: %w", err)
}
return conn, nil
}
Wrap errors with fmt.Errorf and %w so the caller can unwrap the root cause later. This is standard Go practice. It preserves the error chain and makes debugging easier.
Trust is explicit. Load the CA or reject the connection.
Common pitfalls
The most common error is the client rejecting the server certificate. If you don't load the CA into the client's RootCAs, the compiler won't stop you, but the runtime fails with tls: failed to verify certificate: x509: certificate signed by unknown authority. This means the client sees the cert but doesn't know who signed it. The connection drops immediately.
Another trap is mixing insecure and secure connections. If the server expects TLS and the client connects without credentials, the handshake fails. The server logs tls: first record does not look like a TLS handshake if the client sends plain text to a TLS port. Conversely, if the client sends TLS to an insecure server, the client gets a connection refused or protocol error.
Never use InsecureSkipVerify: true in production. This disables certificate verification. It opens the door to man-in-the-middle attacks. Use it only for local testing with self-signed certs, and remove it before deployment. The worst TLS bug is the one that works in dev and fails in prod. Verify the CA.
Certificate expiry is another silent killer. If the server cert expires, new connections fail. Existing connections might keep working until they renegotiate. Monitor cert expiry dates and automate renewal. Tools like certbot or cloud load balancers handle this for you.
Decision matrix
Use credentials.NewTLS with server certs when you need encrypted transport between services on an untrusted network. Use mutual TLS when both sides must authenticate each other, such as in zero-trust architectures or internal service meshes. Use grpc.WithInsecure() only in local development where performance matters more than security and no sensitive data flows. Use a reverse proxy like Envoy or an ALB for TLS termination when you want to offload certificate management and let services talk over plain gRPC internally. Use credentials.NewTLS with ClientAuth set to tls.RequireAndVerifyClientCert when the server must verify the client's identity via certificate.
Encryption is cheap. Identity is expensive. Pick the credential that matches your threat model.