gRPC Security#
gRPC uses HTTP/2 as its transport, which means TLS is not just a security feature — it is a practical necessity. Many load balancers, proxies, and clients expect HTTP/2 over TLS (h2) rather than plaintext HTTP/2 (h2c). Securing gRPC means configuring TLS correctly, authenticating clients, authorizing RPCs, and handling the gRPC-specific gotchas that do not exist with REST APIs.
gRPC Over TLS#
Server-Side TLS in Go#
import (
"crypto/tls"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func main() {
cert, err := tls.LoadX509KeyPair("server-cert.pem", "server-key.pem")
if err != nil {
log.Fatal(err)
}
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS13,
}
creds := credentials.NewTLS(tlsConfig)
server := grpc.NewServer(grpc.Creds(creds))
pb.RegisterMyServiceServer(server, &myService{})
lis, _ := net.Listen("tcp", ":50051")
server.Serve(lis)
}Client-Side TLS in Go#
import (
"crypto/x509"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
)
func main() {
// For public CAs (Let's Encrypt, etc.), use system cert pool
creds := credentials.NewTLS(&tls.Config{
MinVersion: tls.VersionTLS13,
})
// For internal CAs, load the CA cert explicitly
caCert, _ := os.ReadFile("ca-cert.pem")
certPool := x509.NewCertPool()
certPool.AppendCertsFromPEM(caCert)
creds = credentials.NewTLS(&tls.Config{
RootCAs: certPool,
MinVersion: tls.VersionTLS13,
})
conn, err := grpc.NewClient("api.internal:50051",
grpc.WithTransportCredentials(creds),
)
defer conn.Close()
client := pb.NewMyServiceClient(conn)
}TLS in Python#
import grpc
# Server
server_credentials = grpc.ssl_server_credentials(
[(open("server-key.pem", "rb").read(), open("server-cert.pem", "rb").read())]
)
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
pb_grpc.add_MyServiceServicer_to_server(MyService(), server)
server.add_secure_port("[::]:50051", server_credentials)
server.start()
# Client
ca_cert = open("ca-cert.pem", "rb").read()
channel_credentials = grpc.ssl_channel_credentials(root_certificates=ca_cert)
channel = grpc.secure_channel("api.internal:50051", channel_credentials)
client = pb_grpc.MyServiceStub(channel)Mutual TLS for gRPC#
mTLS is the strongest authentication model for service-to-service gRPC. Each service has a certificate, and both sides verify each other.
mTLS Server in Go#
caCert, _ := os.ReadFile("ca-cert.pem")
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
serverCert, _ := tls.LoadX509KeyPair("server-cert.pem", "server-key.pem")
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{serverCert},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: caCertPool,
MinVersion: tls.VersionTLS13,
}
creds := credentials.NewTLS(tlsConfig)
server := grpc.NewServer(grpc.Creds(creds))mTLS Client in Go#
caCert, _ := os.ReadFile("ca-cert.pem")
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
clientCert, _ := tls.LoadX509KeyPair("client-cert.pem", "client-key.pem")
tlsConfig := &tls.Config{
Certificates: []tls.Certificate{clientCert},
RootCAs: caCertPool,
MinVersion: tls.VersionTLS13,
}
creds := credentials.NewTLS(tlsConfig)
conn, _ := grpc.NewClient("api.internal:50051",
grpc.WithTransportCredentials(creds),
)mTLS in Python#
# Server with client cert verification
server_credentials = grpc.ssl_server_credentials(
[(open("server-key.pem", "rb").read(), open("server-cert.pem", "rb").read())],
root_certificates=open("ca-cert.pem", "rb").read(),
require_client_auth=True,
)
# Client with cert
channel_credentials = grpc.ssl_channel_credentials(
root_certificates=open("ca-cert.pem", "rb").read(),
private_key=open("client-key.pem", "rb").read(),
certificate_chain=open("client-cert.pem", "rb").read(),
)
channel = grpc.secure_channel("api.internal:50051", channel_credentials)Extracting Client Identity from mTLS#
In mTLS, the client certificate contains the identity. Extract it in your RPC handlers:
import "google.golang.org/grpc/peer"
import "google.golang.org/grpc/credentials"
func (s *myService) GetData(ctx context.Context, req *pb.Request) (*pb.Response, error) {
p, ok := peer.FromContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "no peer info")
}
tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo)
if !ok || len(tlsInfo.State.PeerCertificates) == 0 {
return nil, status.Error(codes.Unauthenticated, "no client certificate")
}
clientCert := tlsInfo.State.PeerCertificates[0]
clientID := clientCert.Subject.CommonName
// Use clientID for authorization decisions
}Token-Based Authentication#
For cases where mTLS is impractical (external clients, mobile apps, browser-based gRPC-Web), use token-based authentication via gRPC metadata.
Per-RPC Credentials#
gRPC has a built-in mechanism for attaching credentials to every RPC:
// Client: attach token to every RPC
type tokenAuth struct {
token string
}
func (t tokenAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
return map[string]string{
"authorization": "Bearer " + t.token,
}, nil
}
func (t tokenAuth) RequireTransportSecurity() bool {
return true // Refuse to send tokens over plaintext
}
conn, _ := grpc.NewClient("api.example.com:443",
grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})),
grpc.WithPerRPCCredentials(tokenAuth{token: "eyJhbGci..."}),
)RequireTransportSecurity() bool returning true ensures the token is never sent over an unencrypted connection. This is a critical safety net.
Server-Side Token Validation#
Use a unary interceptor to validate tokens before the RPC handler runs:
func authInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
// Skip auth for health checks
if info.FullMethod == "/grpc.health.v1.Health/Check" {
return handler(ctx, req)
}
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "no metadata")
}
authHeader := md.Get("authorization")
if len(authHeader) == 0 {
return nil, status.Error(codes.Unauthenticated, "no authorization header")
}
token := strings.TrimPrefix(authHeader[0], "Bearer ")
claims, err := validateJWT(token)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
// Add claims to context for use in handler
ctx = context.WithValue(ctx, "claims", claims)
return handler(ctx, req)
}
server := grpc.NewServer(
grpc.Creds(creds),
grpc.UnaryInterceptor(authInterceptor),
)Streaming Interceptor#
Streaming RPCs need a separate interceptor:
func streamAuthInterceptor(
srv interface{},
ss grpc.ServerStream,
info *grpc.StreamServerInfo,
handler grpc.StreamHandler,
) error {
md, ok := metadata.FromIncomingContext(ss.Context())
if !ok {
return status.Error(codes.Unauthenticated, "no metadata")
}
authHeader := md.Get("authorization")
if len(authHeader) == 0 {
return status.Error(codes.Unauthenticated, "no authorization header")
}
token := strings.TrimPrefix(authHeader[0], "Bearer ")
if _, err := validateJWT(token); err != nil {
return status.Error(codes.Unauthenticated, "invalid token")
}
return handler(srv, ss)
}
server := grpc.NewServer(
grpc.Creds(creds),
grpc.UnaryInterceptor(authInterceptor),
grpc.StreamInterceptor(streamAuthInterceptor),
)Authorization: Per-Method Access Control#
Authentication proves who the caller is. Authorization decides what they can do. Implement per-method authorization in the interceptor:
// Method-level RBAC
var methodPermissions = map[string][]string{
"/myapp.v1.UserService/GetUser": {"user:read", "admin"},
"/myapp.v1.UserService/DeleteUser": {"admin"},
"/myapp.v1.DataService/Export": {"data:export", "admin"},
}
func authzInterceptor(
ctx context.Context,
req interface{},
info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
claims := ctx.Value("claims").(*Claims)
requiredPerms := methodPermissions[info.FullMethod]
if !hasAnyPermission(claims.Roles, requiredPerms) {
return nil, status.Errorf(codes.PermissionDenied,
"method %s requires one of %v", info.FullMethod, requiredPerms)
}
return handler(ctx, req)
}Chain multiple interceptors using grpc.ChainUnaryInterceptor:
server := grpc.NewServer(
grpc.Creds(creds),
grpc.ChainUnaryInterceptor(
loggingInterceptor,
authInterceptor,
authzInterceptor,
rateLimitInterceptor,
),
)gRPC Security in Kubernetes#
Service Mesh (Istio / Linkerd)#
The simplest path to mTLS for gRPC in Kubernetes is a service mesh. Istio and Linkerd inject sidecar proxies that handle TLS transparently:
# Istio: enable strict mTLS for a namespace
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
name: default
namespace: my-app
spec:
mtls:
mode: STRICTWith this, all gRPC traffic between services in my-app is encrypted with mTLS. Applications use plaintext gRPC internally — the sidecar handles encryption. This is operationally simpler than configuring TLS in every application, but adds sidecar resource overhead and debugging complexity.
cert-manager for Application-Level TLS#
If you prefer application-level TLS without a service mesh:
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: grpc-server-cert
namespace: my-app
spec:
secretName: grpc-server-tls
duration: 720h
renewBefore: 168h
issuerRef:
name: internal-ca-issuer
kind: ClusterIssuer
dnsNames:
- grpc-server
- grpc-server.my-app.svc
- grpc-server.my-app.svc.cluster.local
usages:
- server auth
- client authMount the secret and configure the gRPC server to read certificates from the mounted path. Implement certificate file watching to pick up renewals without restart.
Health Checks#
gRPC has a standard health checking protocol. Kubernetes probes need to reach it:
containers:
- name: grpc-server
ports:
- containerPort: 50051
livenessProbe:
grpc:
port: 50051
readinessProbe:
grpc:
port: 50051Kubernetes native gRPC probes (available since 1.24) work over plaintext. If your gRPC server requires TLS, use grpc_health_probe with TLS flags or expose a separate HTTP health endpoint.
gRPC-Web and Browser Clients#
Browsers cannot make native gRPC calls (no HTTP/2 trailers support). gRPC-Web is a protocol variant that works over HTTP/1.1 and HTTP/2 through a proxy.
Security considerations for gRPC-Web:
- Always terminate TLS at the proxy. Envoy or grpc-web-proxy sits between the browser and the gRPC backend.
- Use CORS headers. Browsers enforce CORS. Configure allowed origins on the proxy.
- Token auth, not mTLS. Browsers cannot present client certificates reliably. Use JWT or OAuth2 tokens in metadata.
# Envoy filter for gRPC-Web
http_filters:
- name: envoy.filters.http.grpc_web
- name: envoy.filters.http.cors
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.cors.v3.Cors
- name: envoy.filters.http.routerCommon Mistakes#
- Using plaintext gRPC (h2c) between services without a service mesh. If there is no sidecar encrypting traffic, your RPCs and metadata (including auth tokens) are visible to anything on the network.
- Not implementing
RequireTransportSecurity()on per-RPC credentials. Without this, a misconfigured client silently sends tokens over plaintext connections. - Skipping auth on health check endpoints. Health probes need to reach
/grpc.health.v1.Health/Checkwithout credentials. Explicitly exclude health endpoints from auth interceptors. - Using the same interceptor for unary and streaming RPCs. Unary and streaming interceptors have different signatures. A unary-only interceptor does not protect streaming RPCs. Register both.
- Not validating the full method path in authorization. gRPC methods are
/<package>.<service>/<method>. A typo in the authorization map silently allows access. Log unauthorized access attempts to catch misconfigurations.