How to Add gRPC Interceptors (Middleware) in Go

Web
Add gRPC interceptors in Go by wrapping your server or client options with UnaryInterceptor or StreamInterceptor functions to handle cross-cutting concerns like logging and auth.

The maintenance trap of copy-pasted RPC logic

You finish your first gRPC service. The methods compile, the proto definitions match, and the client talks to the server. Then the requirements shift. Every RPC needs request logging. Every call needs authentication. Every endpoint needs a latency histogram. Copying that boilerplate into ten different methods creates a maintenance trap. You change the logging format once, then hunt through a dozen files to update it. You miss one method, and your metrics lie.

Go solves this with interceptors. They let you wrap every RPC call with cross-cutting logic without touching the business code. The pattern is explicit, type-safe, and built directly into the google.golang.org/grpc package. You register an interceptor once, and the framework routes every incoming call through it.

How interceptors actually work

Think of an interceptor as a toll booth on a highway. Every car entering the network passes through the booth. The attendant checks the pass, records the entry time, and then opens the gate. The car drives to its destination. When it returns, the attendant records the exit time and calculates the trip duration. If the pass is invalid, the gate never opens and the car turns around.

In Go, the car is the RPC request. The attendant is your interceptor function. The gate is a handler function passed into your interceptor. You control exactly when the gate opens. You can inspect the request before it crosses, modify the context, or wrap the response after it returns. The gRPC framework routes every call through this checkpoint before it ever reaches your method.

The magic is just a closure. When you register an interceptor, gRPC wraps your original RPC method in your function. The original method becomes the handler parameter. Your interceptor becomes the new entry point. When a client sends a request, gRPC calls your interceptor. Your interceptor runs its pre-handling logic, calls handler(ctx, req), runs its post-handling logic, and returns. The call stack grows by one layer per interceptor.

A minimal unary interceptor

Here is the simplest unary interceptor. It logs the method name and passes the request through.

func loggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
    // Capture the start time before touching the request
    start := time.Now()
    
    // Invoke the actual RPC method. This is the gate opening.
    resp, err := handler(ctx, req)
    
    // Calculate duration only after the handler returns
    duration := time.Since(start)
    
    // Log the result with method name and elapsed time
    log.Printf("method=%s duration=%s error=%v", info.FullMethod, duration, err)
    
    // Return the handler's response unchanged
    return resp, err
}

The signature looks dense, but each parameter has a specific job. ctx carries deadlines, cancellation signals, and request metadata. req is the deserialized protobuf message. info holds routing details like the full method name and server instance. handler is the function that actually executes your RPC logic. Calling handler(ctx, req) is mandatory. If you skip it, the request never reaches your service and the client hangs.

The compiler enforces this exact shape. If you swap the parameter order or change the return type, you get cannot use loggingInterceptor (value of type func(...)) as grpc.UnaryInterceptor value in argument. The type system catches signature mistakes before you ever run the server.

Wiring interceptors into a real service

Real services chain multiple interceptors. Authentication runs first. Logging runs second. Metrics run third. The order matters because each interceptor wraps the next one. The first registered interceptor becomes the outermost layer. It sees the request first and the response last.

Here is how you wire them into a server. The grpc.NewServer function accepts a variadic list of options. You pass each interceptor as a grpc.UnaryInterceptor option.

func main() {
    // Chain interceptors. The first one wraps the second one.
    opts := []grpc.ServerOption{
        grpc.UnaryInterceptor(authInterceptor),
        grpc.UnaryInterceptor(loggingInterceptor),
    }
    
    // Create the server with the configured options
    server := grpc.NewServer(opts...)
    
    // Register your proto service implementation
    pb.RegisterUserServiceServer(server, &userServer{})
    
    // Start listening on the configured port
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }
    
    // Block until the server stops
    server.Serve(lis)
}

Notice how context.Context sits as the first parameter in every interceptor signature. That is a hard convention in Go. Functions that accept a context always take it first, conventionally named ctx. The gRPC framework creates this context for every incoming call. It carries the client's deadline, cancellation signals, and metadata like authorization tokens. If you spawn a background goroutine inside an interceptor, you must derive a new context from it. Otherwise, the background work will never cancel when the client disconnects.

Error handling follows the same explicit pattern. The interceptor returns (any, error). If authentication fails, you return nil, status.Error(codes.Unauthenticated, "missing token"). The gRPC framework converts that error into an HTTP/2 status code and sends it back to the client. You do not panic. You do not log and swallow the error. You return it. The if err != nil pattern is verbose by design. It forces you to acknowledge the failure path instead of hiding it behind a silent return.

Context propagation and deadline safety

Interceptors sit between the network layer and your business logic. That position makes them the perfect place to enforce deadlines. Clients can attach deadlines to requests. The framework translates those deadlines into a context.Context with a timeout. Your interceptor receives that context. If you ignore it and spawn a long-running operation, you break the contract.

Here is how you safely derive a context for internal work.

func timeoutInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
    // Create a child context with a strict server-side deadline
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    
    // Ensure resources are cleaned up when the context expires
    defer cancel()
    
    // Pass the derived context to the handler
    resp, err := handler(ctx, req)
    
    // Check if the deadline was exceeded before returning
    if err == context.DeadlineExceeded {
        return nil, status.Error(codes.DeadlineExceeded, "request took too long")
    }
    
    return resp, err
}

The defer cancel() call is mandatory. Contexts hold timers and cancellation channels. If you drop the context without calling cancel(), the timer keeps running until it fires. That leaks memory and CPU cycles. The framework will not clean it up for you.

You also need to handle metadata carefully. gRPC attaches client metadata to the context. You can read it with metadata.FromIncomingContext(ctx). If you modify the context and pass a new one to the handler, you must copy the metadata forward. Otherwise, downstream code loses the request headers. The _ (underscore) discard operator helps here when you intentionally drop a value. md, _ := metadata.FromIncomingContext(ctx) says you considered the second return value and chose to drop it. Use it sparingly with errors, but freely with metadata unpacking.

Testing interceptors without a running server

Running a full gRPC server just to test an interceptor is slow and fragile. You can test interceptors in isolation by providing a mock handler. The interceptor signature expects a grpc.UnaryHandler. You can write a tiny function that matches that signature and returns a predictable value.

func TestLoggingInterceptor(t *testing.T) {
    // Create a mock handler that returns a fixed response
    mockHandler := func(ctx context.Context, req any) (any, error) {
        return &pb.UserResponse{Name: "test"}, nil
    }
    
    // Call the interceptor directly with the mock handler
    resp, err := loggingInterceptor(context.Background(), &pb.UserRequest{}, &grpc.UnaryServerInfo{FullMethod: "/user.Get"}, mockHandler)
    
    // Verify the response matches the mock
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    
    // Assert the response type and value
    if resp.(*pb.UserResponse).Name != "test" {
        t.Fatal("response mismatch")
    }
}

This approach isolates the interceptor logic. You verify that it calls the handler, that it returns the correct response, and that it handles errors properly. You do not need network sockets, proto registration, or server lifecycles. The test runs in milliseconds.

Streaming interceptors and memory leaks

Unary interceptors handle request/response pairs. Streaming interceptors handle long-lived connections where messages flow in both directions. The signature changes because you cannot wrap a single request. You wrap the entire stream.

func streamLoggingInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
    // Log the stream method before processing begins
    log.Printf("stream started: %s", info.FullMethod)
    
    // Invoke the handler to manage the stream lifecycle
    err := handler(ss)
    
    // Log completion or failure after the stream closes
    log.Printf("stream finished: %s error=%v", info.FullMethod, err)
    
    return err
}

The handler function here takes no arguments and returns an error. You call it once, and it manages the entire stream lifecycle. The framework calls ss.RecvMsg() and ss.SendMsg() inside that handler. Your interceptor cannot intercept individual messages without wrapping the ServerStream interface. That requires implementing a proxy struct that delegates to the original stream. Most teams skip per-message streaming interceptors unless they need deep packet inspection. The complexity rarely pays off.

Forgetting to call handler(ss) leaves the stream open and leaks memory. The worst goroutine bug is the one that never logs. Always verify that the handler executes exactly once. If you wrap the stream, ensure your proxy forwards cancellation signals. Streams carry their own context. You can retrieve it with ss.Context(). If the client disconnects, that context cancels. Your proxy must respect it.

Performance characteristics and allocation

Interceptors add a function call and a closure allocation per request. That cost is small but measurable under heavy load. The handler parameter is a closure that captures the original method, the server instance, and the request metadata. Go's compiler optimizes simple closures well, but complex interceptors that allocate maps or strings on every call will show up in pprof profiles.

Keep interceptors lean. Do database lookups in the handler, not the interceptor. Do not deserialize large payloads twice. Do not create new slices or maps unless you must. If you need to attach data to the request, use context values with typed keys. Avoid string keys. String keys cause collisions and prevent compiler checks. Define a private type like type contextKey struct{} and use contextKey{} as the key. The compiler will reject accidental collisions.

Formatting matters too. gofmt is mandatory. Do not argue about indentation or brace placement. Let the tool decide. Most editors run it on save. Interceptor code follows the same rules as the rest of your codebase. Consistent formatting reduces cognitive load when you are debugging a chain of five wrappers.

Picking the right approach

Use a unary interceptor when you need to inspect or modify standard request/response RPCs like authentication, logging, or basic metrics. Use a stream interceptor when your service handles bidirectional or server-streaming calls that require per-message processing or stream-level timeouts. Use a third-party middleware library like grpc-middleware when you need to chain dozens of interceptors and want helper functions for chaining and recovery. Use plain sequential code inside your RPC methods when the logic only applies to a single endpoint and does not justify the abstraction overhead.

Where to go next