Skip to content

gRPC Middleware Chaining — Composing Interceptors for Logging, Auth, and Metrics

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about grpc middleware chaining. We cover key concepts, practical examples, and best practices to help you master this topic.

gRPC middleware chaining composes multiple interceptors into a pipeline, where each interceptor handles a cross-cutting concern like logging, authentication, rate limiting, or metrics before passing control to the next interceptor and eventually the handler.

What You'll Learn

  • Composing multiple interceptors in order
  • Building reusable interceptor libraries
  • Error handling in interceptor chains
  • Passing data between interceptors
  • Performance considerations for long chains
  • Testing interceptor chains

Why It Matters

A production gRPC service needs logging, auth, rate limiting, metrics, tracing, and error handling — all implemented as interceptors. Proper chaining ensures they compose correctly without interfering. DodaTech's Durga Antivirus Pro uses a chain of 7 interceptors per service: logging, auth, rate limit, tracing, metrics, request validation, and error recovery.

Real-World Use

A request arrives at the threat analysis service. The interceptor chain processes it in order: logging records the incoming request, auth validates the JWT, rate limiting checks quota, tracing creates a span, metrics increments the request counter, validation checks the message, and error recovery catches any panics.

flowchart LR
    A["Incoming RPC"] --> B["1. Logging\nInterceptor"]
    B --> C["2. Auth\nInterceptor"]
    C --> D["3. Rate Limit\nInterceptor"]
    D --> E["4. Tracing\nInterceptor"]
    E --> F["5. Metrics\nInterceptor"]
    F --> G["6. Validation\nInterceptor"]
    G --> H["7. Recovery\nInterceptor"]
    H --> I["Handler"]
    I --> J["Response"]
    style A fill:#dbeafe,stroke:#2563eb
    style I fill:#bbf7d0,stroke:#16a34a

Code Examples

Example 1: Chaining Interceptors in Go

package main

import (
    "google.golang.org/grpc"
)

func main() {
    s := grpc.NewServer(
        grpc.ChainUnaryInterceptor(
            loggingInterceptor,
            authInterceptor,
            rateLimitInterceptor,
            tracingInterceptor,
            recoveryInterceptor,
        ),
        grpc.ChainStreamInterceptor(
            streamLoggingInterceptor,
            streamAuthInterceptor,
        ),
    )
    
    pb.RegisterThreatServiceServer(s, &server{})
}

func loggingInterceptor(ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    
    log.Info("request started",
        "method", info.FullMethod,
        "request", req,
    )
    
    resp, err := handler(ctx, req)
    
    log.Info("request completed",
        "method", info.FullMethod,
        "error", err,
    )
    
    return resp, err
}

func authInterceptor(ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (interface{}, error) {
    
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, status.Error(codes.Unauthenticated,
            "missing metadata")
    }
    
    token := md.Get("authorization")
    if len(token) == 0 {
        return nil, status.Error(codes.Unauthenticated,
            "missing auth token")
    }
    
    // Validate and inject user context
    claims, err := validateJWT(token[0])
    if err != nil {
        return nil, status.Error(codes.Unauthenticated,
            "invalid token")
    }
    
    newCtx := context.WithValue(ctx, "user", claims)
    return handler(newCtx, req)
}

Example 2: Python Interceptor Chain

import grpc
from grpc_interceptor import ServerInterceptor
from grpc_interceptor.exceptions import GrpcException

class LoggingInterceptor(ServerInterceptor):
    def intercept(self, method, request, context, method_name):
        print(f"[{method_name}] request: {request}")
        try:
            response = method(request, context)
            print(f"[{method_name}] success")
            return response
        except Exception as e:
            print(f"[{method_name}] error: {e}")
            raise

class AuthInterceptor(ServerInterceptor):
    def __init__(self, jwt_validator):
        self.jwt_validator = jwt_validator
    
    def intercept(self, method, request, context, method_name):
        auth = dict(context.invocation_metadata())
        token = auth.get("authorization", "")
        
        if not token:
            context.abort(
                grpc.StatusCode.UNAUTHENTICATED,
                "Missing auth token",
            )
        
        user = self.jwt_validator.validate(token)
        if not user:
            context.abort(
                grpc.StatusCode.UNAUTHENTICATED,
                "Invalid auth token",
            )
        
        # Set user in context for downstream
        context.user = user
        return method(request, context)

# Build chain
server = grpc.server(
    grpc.insecure_server(),
    interceptors=[
        LoggingInterceptor(),
        AuthInterceptor(jwt_validator),
        RateLimitInterceptor(),
        MetricsInterceptor(),
    ],
)

Example 3: Passing Data Between Interceptors in Node.js

const grpc = require('@grpc/grpc-js');

class InterceptorChain {
  constructor() {
    this.interceptors = [];
  }
  
  add(interceptor) {
    this.interceptors.push(interceptor);
  }
  
  build() {
    return (options, nextCall) => {
      let currentIndex = 0;
      
      const runInterceptor = (callOptions) => {
        if (currentIndex >= this.interceptors.length) {
          return nextCall(callOptions);
        }
        
        const interceptor = this.interceptors[currentIndex];
        currentIndex++;
        
        return interceptor(callOptions, runInterceptor);
      };
      
      return runInterceptor(options);
    };
  }
}

// Shared context between interceptors
class InterceptorContext {
  constructor() {
    this.data = new Map();
  }
  
  set(key, value) {
    this.data.set(key, value);
  }
  
  get(key) {
    return this.data.get(key);
  }
}

// Example interceptors
const authInterceptor = (options, next) => {
  const ctx = options.context;
  const metadata = options.metadata;
  
  const token = metadata.get('authorization')[0];
  if (!token) {
    throw new Error('Missing token');
  }
  
  ctx.set('user', decodeJWT(token));
  return next(options);
};

const loggingInterceptor = (options, next) => {
  console.log(`Starting ${options.methodDefinition.path}`);
  const start = Date.now();
  
  try {
    const result = next(options);
    console.log(`${options.methodDefinition.path} took ${Date.now() - start}ms`);
    return result;
  } catch (error) {
    console.error(`${options.methodDefinition.path} failed:`, error);
    throw error;
  }
};

// Usage
const chain = new InterceptorChain();
chain.add(loggingInterceptor);
chain.add(authInterceptor);
chain.add(rateLimitInterceptor);

const client = new ThreatServiceClient(
  'localhost:50051',
  grpc.credentials.createInsecure(),
  {
    interceptors: [chain.build()],
  },
);

Common Mistakes

  1. Putting interceptors in the wrong order — auth should come before rate limiting (no point rate-limiting unauthenticated requests), and logging should be first to capture all requests.
  2. Modifying request between interceptors — if auth modifies the request context, downstream interceptors must use the modified context, not the original.
  3. Not handling panics in interceptors — a panic in one interceptor crashes the entire server. Add a recovery interceptor at the start of the chain.
  4. Making interceptors too slow — interceptors add latency to every request. Keep them fast (under 1ms each). Move heavy processing (e.g., async logging) to background.
  5. Testing interceptors in isolation only — test the full chain to verify interaction between interceptors, like auth setting context that logging reads.

Practice Questions

  1. What is the correct order of interceptors in a typical production chain?
  2. How do you pass data between interceptors in the chain?
  3. Why should auth come before rate limiting?
  4. How do you handle errors from downstream interceptors?
  5. What happens if an interceptor panics in the middle of the chain?

Challenge: Design a chain of 8 interceptors for a production gRPC service: request ID generation, structured logging, auth (JWT validation), rate limiting (per-user), tracing (OpenTelemetry), metrics (Prometheus), request validation, and panic recovery.

Mini Project

Build a reusable gRPC interceptor chain library with: 10 pre-built interceptors (logging, auth, rate limit, tracing, metrics, validation, recovery, request ID, compression, Caching), chain Builder with ordering validation, and per-method interceptor configuration.

FAQ

Does interceptor order matter?

Yes. The first interceptor in the chain runs first on the way in and last on the way out. Logging should be outermost, recovery should be outermost as well.

How many interceptors is too many?

Performance-wise, 10-15 interceptors is fine if each takes under 1ms. Beyond that, the overhead of function calls adds up.

Can interceptors modify the request?

Yes, but be careful. If auth adds user info to context, all downstream interceptors see it. Document what each interceptor adds or modifies.

How do I skip an interceptor for specific methods?

Check info.FullMethod in the interceptor. Skip auth for the HealthCheck method, skip rate limiting for internal methods.

Can I use different chains for different services?

Yes. Create different gRPC servers for different service groups with their own interceptor chains. Internal services may have simpler chains than external-facing ones.

What's Next

Learn more about gRPC interceptors

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro