gRPC interceptors let you inject custom logic into the request/response lifecycle without altering your core service code.

Let’s see this in action. Imagine a simple gRPC service:

syntax = "proto3";

package myservice;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

And its server implementation:

package main

import (
	"context"
	"log"
	"net"

	"google.golang.org/grpc"
	pb "path/to/your/myservice" // Replace with your actual import path
)

type server struct {
	pb.UnimplementedGreeterServer
}

func (s *server) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) {
	log.Printf("Received: %v", req.GetName())
	return &pb.HelloReply{Message: "Hello " + req.GetName()}, nil
}

func main() {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	s := grpc.NewServer()
	pb.RegisterGreeterServer(s, &server{})
	log.Printf("server listening at %v", lis.Addr())
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

Now, let’s add an interceptor for authentication. This interceptor will run before SayHello is executed.

package main

import (
	"context"
	"log"
	"net"
	"strings"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"
	pb "path/to/your/myservice" // Replace with your actual import path
)

// authInterceptor checks for a specific API key in the request metadata.
func authInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInterceptorFunc, handler grpc.UnaryHandler) (interface{}, error) {
	// Extract metadata from the context
	md, ok := metadata.FromIncomingContext(ctx)
	if !ok {
		return nil, status.Errorf(codes.Unauthenticated, "missing metadata")
	}

	// Check for the 'authorization' header
	authHeader, ok := md["authorization"]
	if !ok || len(authHeader) == 0 {
		return nil, status.Errorf(codes.Unauthenticated, "authorization header not provided")
	}

	// Simple check: expecting "Bearer YOUR_API_KEY"
	if !strings.HasPrefix(authHeader[0], "Bearer ") {
		return nil, status.Errorf(codes.Unauthenticated, "invalid authorization format")
	}

	apiKey := strings.TrimPrefix(authHeader[0], "Bearer ")
	if apiKey != "my-secret-api-key" { // Replace with your actual secret key
		return nil, status.Errorf(codes.Unauthenticated, "invalid API key")
	}

	log.Println("Authentication successful")
	// Call the actual RPC handler
	return handler(ctx, req)
}

// loggingInterceptor logs incoming requests and outgoing responses.
func loggingInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInterceptorFunc, handler grpc.UnaryHandler) (interface{}, error) {
	log.Printf("Received request: %T, %+v", req, req)

	// Call the actual RPC handler
	resp, err := handler(ctx, req)

	if err != nil {
		log.Printf("Error processing request: %v", err)
		return nil, err
	}

	log.Printf("Sending response: %T, %+v", resp, resp)
	return resp, nil
}

type server struct {
	pb.UnimplementedGreeterServer
}

func (s *server) SayHello(ctx context.Context, req *pb.HelloRequest) (*pb.HelloReply, error) {
	log.Printf("Executing SayHello for: %v", req.GetName())
	return &pb.HelloReply{Message: "Hello " + req.GetName()}, nil
}

func main() {
	lis, err := net.Listen("tcp", ":50051")
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	// Create a new gRPC server and add the interceptors
	s := grpc.NewServer(
		grpc.UnaryInterceptor(authInterceptor),
		grpc.UnaryInterceptor(loggingInterceptor), // Interceptors are chained
	)

	pb.RegisterGreeterServer(s, &server{})
	log.Printf("server listening at %v", lis.Addr())
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

When a client makes a request, gRPC first checks the authInterceptor. If authentication fails, the request is immediately rejected with an Unauthenticated error. If it passes, the loggingInterceptor logs the request details, and then the actual SayHello method is called. After SayHello completes, the loggingInterceptor logs the response before it’s sent back to the client.

The grpc.UnaryInterceptor option takes a function that conforms to the grpc.UnaryServerInterceptor interface. This function receives the context, the request payload, a ServerInfo struct (containing method name), and a UnaryHandler. The UnaryHandler is a function that, when called, will proceed to the next interceptor in the chain or execute the actual service method. You must call handler(ctx, req) to continue the request processing.

To make this work on the client side, you’d use grpc.WithUnaryClientInterceptor:

package main

import (
	"context"
	"log"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/metadata"
	pb "path/to/your/myservice" // Replace with your actual import path
)

// addAuthHeaderClientInterceptor adds the authorization header to outgoing requests.
func addAuthHeaderClientInterceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
	// Add metadata to the context
	md := metadata.Pairs("authorization", "Bearer my-secret-api-key") // Must match server's secret
	ctx = metadata.NewOutgoingContext(ctx, md)

	// Call the actual RPC method
	return invoker(ctx, method, req, reply, cc, opts...)
}

func main() {
	conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(credentials.NewClientTLSFromCert(nil, "")), // Use insecure for testing, or proper TLS
		grpc.WithUnaryClientInterceptor(addAuthHeaderClientInterceptor))
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()
	c := pb.NewGreeterClient(conn)

	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	r, err := c.SayHello(ctx, &pb.HelloRequest{Name: "World"})
	if err != nil {
		log.Fatalf("could not greet: %v", err)
	}
	log.Printf("Greeting: %s", r.GetMessage())
}

The chaining of interceptors is determined by the order in which you pass them to grpc.NewServer. The first interceptor passed will be the first one executed on the server side. For client interceptors, the order is also significant.

The most surprising thing about interceptors is how seamlessly they integrate into the gRPC lifecycle, allowing for cross-cutting concerns like authentication, authorization, logging, metrics, and retries to be handled in a modular fashion without cluttering your core business logic. Each interceptor acts as a middleware, deciding whether to pass the request along, modify it, or short-circuit the process entirely. This separation of concerns is fundamental to building robust and maintainable distributed systems.

The key to implementing custom interceptors lies in understanding the context.Context and the grpc.UnaryHandler (or grpc.StreamHandler for streaming RPCs). The context is where you can pass information down the chain, like user IDs after authentication, or attach request-scoped data. The handler is the gateway to the next step in the processing pipeline.

When you need to implement features like rate limiting based on request frequency, you can leverage client-side interceptors to track outgoing requests. By inspecting grpc.ClientConn and using a mechanism like a token bucket or leaky bucket, you can enforce limits before requests even leave the client. This is particularly useful when interacting with third-party APIs or managing resource consumption within your own microservices.

The next logical step is to explore stream interceptors for handling streaming RPCs, which have a different signature but serve a similar purpose of injecting logic into the data flow.

Want structured learning?

Take the full Grpc course →