gRPC Security Code Review Guide
Table of Contents
Introduction
gRPC is a high-performance RPC framework built on HTTP/2 and Protocol Buffers. It powers internal service meshes, mobile backends, and public APIs at companies like Google, Netflix, and Square. Its binary protocol and streaming primitives make it fast, but they also hide attack traffic from traditional web defenses.
Unlike REST, gRPC payloads are opaque to most WAFs, proxies, and logging pipelines. Security reviewers must therefore focus on the channel (TLS/mTLS), the schema (.proto files), the interceptors (middleware), and every streaming handler, because the usual HTTP-layer controls will not see the traffic.
The gRPC Security Blind Spot
gRPC traffic is binary, framed over HTTP/2, and often flows directly between services without going through an API gateway. WAF rules, access logs, and request body size limits that protect your REST endpoints typically do not apply. You must secure gRPC at the framework level, not the proxy level.
What makes gRPC security fundamentally different from REST API security?
gRPC Attack Surface
A gRPC service exposes more than a set of RPC methods. Each layer — transport, framing, schema, interceptors, handlers — contributes its own class of bugs that a reviewer must evaluate independently.
gRPC Attack Surface
Reflection probes, malformed protobuf, metadata smugglingAuthorization, Databases, Downstream RPCs, Message parsingKey Insight: gRPC rides on HTTP/2 and binary Protobuf, so attackers can hide payloads that web application firewalls cannot inspect. Every interceptor, every streaming handler, and every metadata field is a potential bypass point.
gRPC Security Concerns by Layer
| Layer | Security Concern | Potential Impact |
|---|---|---|
| Transport (TLS) | Plaintext / weak ciphers / no mTLS | Credential and data theft |
| HTTP/2 | HPACK bombs, window exhaustion | Denial of service |
| Reflection API | Schema disclosure | Recon / attack planning |
| Protobuf parser | Recursive or oversized messages | Memory exhaustion, crash |
| Metadata | Token leakage, header smuggling | Auth bypass, log poisoning |
| Interceptors | Missing/ordered incorrectly | Authz bypass |
| Streams | Long-lived, unbounded | Resource exhaustion |
| Handlers | Injection / IDOR / RCE | Data breach, takeover |
Sample Service Definition with Red Flags
1syntax = "proto3";
2
3package payments.v1;
4
5service PaymentService {
6 // Unary - standard risks
7 rpc GetPayment(GetPaymentRequest) returns (Payment);
8
9 // Client streaming - attacker controls message count
10 rpc ImportPayments(stream Payment) returns (ImportResult);
11
12 // Server streaming - server keeps sending
13 rpc StreamLedger(LedgerQuery) returns (stream LedgerEvent);
14
15 // Bidirectional streaming - long-lived, hardest to secure
16 rpc Reconcile(stream ReconcileCmd) returns (stream ReconcileAck);
17}
18
19message Payment {
20 string id = 1;
21 string user_id = 2; // IDOR risk - is caller allowed to read this?
22 int64 amount_cents = 3;
23 string raw_metadata = 4; // Free-text field - injection into downstream?
24 repeated Payment related = 5; // Self-reference - recursion/zip-bomb
25}In the .proto above, which construct most deserves extra scrutiny during review?
Insecure Channels & TLS
The single most common gRPC misconfiguration is using insecure credentials. Teams often start with plaintext channels during development and ship to production without flipping them. Because gRPC traffic is binary, plaintext is not obviously wrong during testing, but it trivially leaks tokens, PII, and internal schemas to anyone with network access.
Insecure Channel Anti-Patterns
1// VULNERABLE: plaintext server
2lis, _ := net.Listen("tcp", ":50051")
3s := grpc.NewServer() // No TLS credentials!
4pb.RegisterPaymentServiceServer(s, &server{})
5s.Serve(lis)
6
7// VULNERABLE: plaintext client
8conn, _ := grpc.Dial(
9 "payments.internal:50051",
10 grpc.WithTransportCredentials(insecure.NewCredentials()),
11)
12
13// VULNERABLE: TLS but skipping verification
14conn, _ := grpc.Dial(
15 "payments.internal:50051",
16 grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
17 InsecureSkipVerify: true, // accepts any cert!
18 })),
19)Mutual TLS (mTLS) Setup
1// SECURE: server requires client certs
2caPool := x509.NewCertPool()
3caPool.AppendCertsFromPEM(caPEM)
4
5serverCert, _ := tls.LoadX509KeyPair("server.crt", "server.key")
6
7creds := credentials.NewTLS(&tls.Config{
8 Certificates: []tls.Certificate{serverCert},
9 ClientAuth: tls.RequireAndVerifyClientCert,
10 ClientCAs: caPool,
11 MinVersion: tls.VersionTLS13,
12})
13
14s := grpc.NewServer(grpc.Creds(creds))
15
16// Client presents its own cert
17clientCert, _ := tls.LoadX509KeyPair("client.crt", "client.key")
18clientCreds := credentials.NewTLS(&tls.Config{
19 Certificates: []tls.Certificate{clientCert},
20 RootCAs: caPool,
21 ServerName: "payments.internal",
22 MinVersion: tls.VersionTLS13,
23})
24
25conn, _ := grpc.NewClient(
26 "payments.internal:50051",
27 grpc.WithTransportCredentials(clientCreds),
28)Why mTLS matters inside the cluster
East-west traffic often carries the most sensitive data (tokens, PII, replicated state). Plaintext inside a cluster makes lateral movement trivial once any pod is compromised. Service meshes (Istio, Linkerd) can automate mTLS for you, but the mesh does not replace authorization — it only establishes who the caller is.
A team argues mTLS is unnecessary because their gRPC services run inside a private VPC. What is the best response?
Metadata & Authentication
gRPC metadata is the equivalent of HTTP headers. It commonly carries bearer tokens, trace IDs, tenant IDs, and internal routing hints. Because it is attacker-controlled on the inbound path, treating metadata as trusted — especially internal "x-" headers — is a classic source of privilege escalation.
Metadata Authentication Anti-Patterns
1// VULNERABLE: trusting x-user-id set by a gateway,
2// but the service is also reachable directly
3func (s *server) GetPayment(ctx context.Context, req *pb.GetPaymentRequest) (*pb.Payment, error) {
4 md, _ := metadata.FromIncomingContext(ctx)
5 userID := md.Get("x-user-id")[0] // attacker can just set this header
6 return s.store.GetForUser(userID, req.Id)
7}
8
9// VULNERABLE: no auth at all — relies on "only internal callers"
10func (s *server) DeletePayment(ctx context.Context, req *pb.DeletePaymentRequest) (*emptypb.Empty, error) {
11 return &emptypb.Empty{}, s.store.Delete(req.Id)
12}
13
14// VULNERABLE: validates token but never checks scope/audience
15func authFromMetadata(ctx context.Context) (*User, error) {
16 md, _ := metadata.FromIncomingContext(ctx)
17 raw := md.Get("authorization")[0]
18 token := strings.TrimPrefix(raw, "Bearer ")
19 claims, _ := jwt.ParseUnverified(token) // !!
20 return &User{ID: claims["sub"].(string)}, nil
21}Secure Authentication Interceptor
1func AuthUnaryInterceptor(verifier TokenVerifier) grpc.UnaryServerInterceptor {
2 return func(
3 ctx context.Context,
4 req interface{},
5 info *grpc.UnaryServerInfo,
6 handler grpc.UnaryHandler,
7 ) (interface{}, error) {
8 // Skip only explicitly public methods
9 if publicMethods[info.FullMethod] {
10 return handler(ctx, req)
11 }
12
13 md, ok := metadata.FromIncomingContext(ctx)
14 if !ok {
15 return nil, status.Error(codes.Unauthenticated, "missing metadata")
16 }
17
18 auth := md.Get("authorization")
19 if len(auth) == 0 {
20 return nil, status.Error(codes.Unauthenticated, "missing authorization")
21 }
22
23 token := strings.TrimPrefix(auth[0], "Bearer ")
24 claims, err := verifier.Verify(ctx, token) // verifies signature, exp, aud, iss
25 if err != nil {
26 return nil, status.Error(codes.Unauthenticated, "invalid token")
27 }
28
29 // Attach authenticated identity; IGNORE any x-user-id from the client
30 ctx = context.WithValue(ctx, userKey, &User{
31 ID: claims.Subject,
32 Scopes: claims.Scopes,
33 })
34 return handler(ctx, req)
35 }
36}Never trust client-supplied identity metadata
Headers like x-user-id, x-tenant-id, and x-role must be derived server-side from a verified token — never copied from incoming metadata. Attackers who can reach the gRPC port (including through SSRF, a compromised sidecar, or misconfigured ingress) will set these freely.
A service reads x-tenant-id from metadata and uses it to scope all database queries. What is the core flaw?
Reflection Abuse
gRPC server reflection lets a client enumerate services, methods, and message types at runtime. This is great for grpcurl and debugging — and catastrophic when left enabled on production services that an attacker can reach. Reflection hands over your .proto schema, which is often the entire recon phase of a pentest.
Reconnaissance via grpcurl
1# List every service on the server
2grpcurl payments.example.com:443 list
3
4# List methods on a service
5grpcurl payments.example.com:443 list payments.v1.PaymentService
6
7# Describe the full request/response schema
8grpcurl payments.example.com:443 describe payments.v1.Payment
9
10# Call a method with no prior knowledge
11grpcurl -d '{"id": "p_123"}' payments.example.com:443 \
12 payments.v1.PaymentService/GetPaymentDisabling Reflection in Production
1s := grpc.NewServer(grpc.Creds(creds))
2pb.RegisterPaymentServiceServer(s, &server{})
3
4// Only register reflection in non-prod
5if os.Getenv("ENV") != "production" {
6 reflection.Register(s)
7}Python / Java Equivalents
1# Python: guard the import and registration
2import os
3if os.getenv("ENV") != "production":
4 from grpc_reflection.v1alpha import reflection
5 SERVICE_NAMES = (
6 payments_pb2.DESCRIPTOR.services_by_name["PaymentService"].full_name,
7 reflection.SERVICE_NAME,
8 )
9 reflection.enable_server_reflection(SERVICE_NAMES, server)
10
11# Java: only add the service in dev
12ServerBuilder<?> builder = ServerBuilder.forPort(port)
13 .useTransportSecurity(certChain, privateKey)
14 .addService(new PaymentService());
15if (!"production".equals(System.getenv("ENV"))) {
16 builder.addService(ProtoReflectionService.newInstance());
17}
18Server server = builder.build().start();Even with reflection off, the schema may still leak
Compiled client bundles, error messages that echo field names, and tools like protobuf-inspector can still reconstruct the schema. Treat reflection as defense-in-depth, not a privacy boundary. Anything you rely on hiding in the schema should be treated as already known to a motivated attacker.
Why should gRPC server reflection be disabled in production?
DoS via Streams & Messages
Because gRPC supports client, server, and bidirectional streaming, an attacker who controls one end of the connection can hold resources for arbitrary amounts of time and send arbitrarily large messages. Defaults in most gRPC libraries are permissive — 4 MB max message size, no deadline, no per-connection stream cap.
Streaming RPC Abuse Patterns
// Client opens stream // then writes 1 byte / hour // Holds goroutine + memory // forever (no deadline)
message Nested {
repeated Nested children = 1;
}
// 50 MB compressed
// expands to 50 GB parsedrpc Tail(Req) returns (stream Event); // Server sends forever // No send-window cap
Dangerous Default Server
1// VULNERABLE: no limits on anything
2s := grpc.NewServer(grpc.Creds(creds))
3// - No MaxRecvMsgSize (default 4 MB — but handler may still OOM)
4// - No MaxConcurrentStreams (unlimited per connection)
5// - No ConnectionTimeout
6// - No KeepaliveEnforcementPolicy
7// - No timeouts on handlers
8// - No recursion / depth limits on protobuf parsingHardened Server Options
1s := grpc.NewServer(
2 grpc.Creds(creds),
3 grpc.MaxRecvMsgSize(1*1024*1024), // 1 MB per message
4 grpc.MaxSendMsgSize(4*1024*1024), // 4 MB outbound
5 grpc.MaxConcurrentStreams(100), // cap multiplexed streams
6 grpc.ConnectionTimeout(10*time.Second), // handshake deadline
7 grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
8 MinTime: 30 * time.Second, // reject chatty pings
9 PermitWithoutStream: false,
10 }),
11 grpc.KeepaliveParams(keepalive.ServerParameters{
12 MaxConnectionIdle: 5 * time.Minute,
13 MaxConnectionAge: 30 * time.Minute,
14 Time: 1 * time.Minute,
15 Timeout: 20 * time.Second,
16 }),
17 grpc.ChainUnaryInterceptor(
18 TimeoutInterceptor(5*time.Second), // enforce per-call deadline
19 AuthUnaryInterceptor(verifier),
20 RateLimitUnaryInterceptor(limiter),
21 ),
22)Safe Server-Streaming Handler
1func (s *server) StreamLedger(req *pb.LedgerQuery, stream pb.PaymentService_StreamLedgerServer) error {
2 ctx, cancel := context.WithTimeout(stream.Context(), 30*time.Second)
3 defer cancel()
4
5 const maxEvents = 10_000
6 sent := 0
7
8 for event := range s.events.Subscribe(ctx, req.Filter) {
9 if err := ctx.Err(); err != nil {
10 return status.Error(codes.DeadlineExceeded, "stream deadline")
11 }
12 if err := stream.Send(event); err != nil {
13 return err // client disconnected; stop work
14 }
15 if sent++; sent >= maxEvents {
16 return status.Error(codes.ResourceExhausted, "max events per stream")
17 }
18 }
19 return nil
20}DoS Prevention Controls
| Control | Purpose | Recommended Value |
|---|---|---|
| MaxRecvMsgSize | Cap inbound message size | 1 MB (raise per endpoint if needed) |
| MaxConcurrentStreams | Prevent HTTP/2 multiplexing abuse | 100 per connection |
| Keepalive enforcement | Stop ping floods | MinTime 30s, no ping without stream |
| Per-call deadline | Bound handler work | 1–30s depending on method |
| Stream length cap | Bound streaming handlers | 10k–100k messages |
| Rate limiter | Per-caller operation cap | 100–1000 rpm per subject |
| Protobuf depth | Prevent recursion bombs | 100 levels (library-specific) |
Why is MaxRecvMsgSize alone not sufficient to prevent parser-based DoS?
Prevention Techniques
Defense-in-depth for gRPC pairs transport-level controls (mTLS, HTTP/2 limits) with framework-level controls (interceptors, deadlines) and handler-level controls (input validation, per-resource authorization).
gRPC Security Checklist
| Control | Implementation | Priority |
|---|---|---|
| Enforce TLS 1.3 + mTLS | Server and client credentials | Critical |
| Disable reflection in prod | Gate reflection.Register on ENV | Critical |
| Auth interceptor | Verify token signature, exp, aud, iss | Critical |
| Per-method authorization | Check subject against resource | Critical |
| MaxRecvMsgSize + depth cap | Protobuf parsing limits | Critical |
| Per-call deadlines | Timeout interceptor | High |
| Rate limiting | Per-subject, per-method | High |
| Error sanitization | Strip internal details from status | High |
| Structured audit logging | Log method, caller, decision | High |
| Keepalive limits | Reject chatty clients | Medium |
End-to-End Hardened Bootstrap
1func newServer() *grpc.Server {
2 creds := mustLoadMTLS()
3
4 authz := NewAuthorizer(policyBundle)
5 verifier := NewJWTVerifier(jwksURL, "payments-api")
6 limiter := NewSubjectRateLimiter(redisClient, 200, time.Minute)
7
8 s := grpc.NewServer(
9 grpc.Creds(creds),
10 grpc.MaxRecvMsgSize(1*1024*1024),
11 grpc.MaxConcurrentStreams(100),
12 grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{
13 MinTime: 30 * time.Second,
14 }),
15 grpc.ChainUnaryInterceptor(
16 RecoveryInterceptor(), // panics -> codes.Internal, no stack trace
17 TraceInterceptor(),
18 TimeoutInterceptor(5*time.Second),
19 AuthUnaryInterceptor(verifier),
20 RateLimitUnaryInterceptor(limiter),
21 AuthorizeUnaryInterceptor(authz),
22 AuditInterceptor(auditLog),
23 ),
24 grpc.ChainStreamInterceptor(
25 StreamRecoveryInterceptor(),
26 AuthStreamInterceptor(verifier),
27 RateLimitStreamInterceptor(limiter),
28 AuthorizeStreamInterceptor(authz),
29 StreamDeadlineInterceptor(30*time.Second),
30 ),
31 )
32
33 pb.RegisterPaymentServiceServer(s, &server{})
34
35 if env := os.Getenv("ENV"); env != "production" {
36 reflection.Register(s)
37 }
38
39 // health checks without leaking service names
40 healthpb.RegisterHealthServer(s, health.NewServer())
41 return s
42}Error Sanitization
1func RecoveryInterceptor() grpc.UnaryServerInterceptor {
2 return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, h grpc.UnaryHandler) (resp interface{}, err error) {
3 defer func() {
4 if r := recover(); r != nil {
5 log.Error().Interface("panic", r).Str("method", info.FullMethod).Msg("grpc panic")
6 err = status.Error(codes.Internal, "internal error")
7 }
8 }()
9 resp, err = h(ctx, req)
10 if err != nil {
11 // Never echo raw DB/driver errors to the client
12 if !isWellFormedStatus(err) {
13 log.Error().Err(err).Msg("unexpected handler error")
14 return nil, status.Error(codes.Internal, "internal error")
15 }
16 }
17 return resp, err
18 }
19}Why is interceptor ordering security-relevant?