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: STRICT

With 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 auth

Mount 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: 50051

Kubernetes 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.router

Common Mistakes#

  1. 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.
  2. Not implementing RequireTransportSecurity() on per-RPC credentials. Without this, a misconfigured client silently sends tokens over plaintext connections.
  3. Skipping auth on health check endpoints. Health probes need to reach /grpc.health.v1.Health/Check without credentials. Explicitly exclude health endpoints from auth interceptors.
  4. 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.
  5. 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.