38.6 gRPC Interceptors: Authentication, Logging, and Tracing
Right, interceptors. This is where we stop treating gRPC like a fancy HTTP bus and start making it do our actual bidding. Think of them as the bouncers, the scribes, and the private investigators for your service’s door. Every single request and response passes through them, giving you a single, elegant choke point to implement all the cross-cutting nonsense you’d otherwise have to copy-paste into every handler. Authentication, logging, tracing, rate limiting—you name it. They are, without a doubt, the most important part of your gRPC setup after the protobuf definitions themselves.
gRPC is kind enough to give us two main types of interceptors, and the distinction is crucial: unary and streaming. Unary is the simple request-response you know and love. Streaming involves, well, streams of data, which are inherently stateful and more complex. The Go library reflects this by offering two different function signatures. If you try to shove a unary interceptor into a streaming slot, it will complain, and rightly so. Let’s break them down.
The Anatomy of a Unary Interceptor
A unary interceptor is essentially a middleware function. Its signature looks a bit intimidating until you peel back the layers.
type UnaryServerInterceptor func(ctx context.Context, req interface{}, info *UnaryServerInfo, handler UnaryHandler) (interface{}, error)
Let’s translate that from Gopher to English:
ctx context.Context: The request’s context, your lifeline for deadlines, cancellation, and storing request-scoped values.req interface{}: The actual request payload, already unmarshaled from its Protocol Buffer format into the Go struct you defined in your.protofile.info *UnaryServerInfo: Contains metadata about the method being called, most importantly its full method name (e.g.,/package.Service/Method).handler UnaryHandler: The next function in the chain. Your interceptor must call this eventually, or the request will just die with you. This is what actually invokes your business logic.
Here’s a dead-simple example: a logging interceptor.
func loggingUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
start := time.Now()
// Call the handler to execute the actual RPC logic.
resp, err := handler(ctx, req)
duration := time.Since(start)
log.Printf("Method: %s, Duration: %v, Error: %v", info.FullMethod, duration, err)
return resp, err
}
The magic is in the handler(ctx, req) call. Everything before it is pre-processing, everything after is post-processing. Simple, right?
Wrestling with Streaming Interceptors
Streaming interceptors are where the real fun begins because you’re not just wrapping a single function call; you’re wrapping an entire connection. The signature is more complex because it returns an interface (grpc.ServerStream) that you’ll often need to wrap yourself to intercept the sent and received messages.
type StreamServerInterceptor func(srv interface{}, ss grpc.ServerStream, info *StreamServerInfo, handler StreamHandler) error
The key here is to create a wrapper around the original grpc.ServerStream. This lets you intercept every single SendMsg and RecvMsg call on the stream.
type wrappedStream struct {
grpc.ServerStream
}
func (w *wrappedStream) RecvMsg(m interface{}) error {
log.Printf("Stream received message: %T", m)
return w.ServerStream.RecvMsg(m)
}
func (w *wrappedStream) SendMsg(m interface{}) error {
log.Printf("Stream sending message: %T", m)
return w.ServerStream.SendMsg(m)
}
func loggingStreamInterceptor(srv interface{}, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
wrapped := &wrappedStream{ss}
err := handler(srv, wrapped)
log.Printf("Stream ended with error: %v", err)
return err
}
This pattern is the golden ticket. You use it for everything from logging individual stream messages to implementing complex authentication that needs to validate the first message (often a token) sent over the stream.
The Critical Order of Operations
Listen closely, because this is the number one thing people get wrong: interceptor execution order is the reverse of registration order. The gRPC server treats interceptors like an onion. The first interceptor you register is the outermost layer of the onion. It runs first before the RPC, and last after the RPC.
server := grpc.NewServer(
grpc.ChainUnaryInterceptor(
loggingInterceptor, // Outer layer: Runs first on way in, last on way out.
authInterceptor, // Middle layer
metricsInterceptor, // Inner layer: Runs last on way in, first on way out.
),
)
So for an incoming request, the order is: loggingInterceptor -> authInterceptor -> metricsInterceptor -> [Your RPC Handler]. Then, on the response path, it’s the reverse: metricsInterceptor -> authInterceptor -> loggingInterceptor. If your auth interceptor depends on something the logging interceptor adds to the context, you’re in for a world of pain. Plan your layers carefully.
A Real-World Auth Interceptor Example
Let’s build something useful. An authentication interceptor that plucks a JWT from the incoming context’s metadata (which is gRPC’s fancy name for headers).
func authUnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
// Let's skip auth for the login method itself, obviously.
if info.FullMethod == "/proto.AuthService/Login" {
return handler(ctx, req)
}
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Errorf(codes.Unauthenticated, "missing metadata")
}
authHeaders := md.Get("authorization")
if len(authHeaders) == 0 {
return nil, status.Errorf(codes.Unauthenticated, "missing authorization token")
}
token := strings.TrimPrefix(authHeaders[0], "Bearer ")
userID, err := validateToken(token) // Your custom logic here
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
}
// The best part: add the user info to the context for your actual handler to use.
newCtx := context.WithValue(ctx, userIDKey{}, userID)
// Proceed with the now-authenticated request.
return handler(newCtx, req)
}
The beauty here is that your actual business handlers never have to think about tokens or metadata. They just check the context for a userID and get on with their jobs. Clean, separated, and professional.
The pitfall? Always, always remember to call the handler. Forgetting that is like inviting a guest in but then locking them in the hallway. Also, be ruthless about setting appropriate gRPC status codes (codes.Unauthenticated, codes.InvalidArgument) instead of just returning vanilla errors. It’s what allows your clients to actually understand what went wrong. Now go forth and intercept. Just don’t be creepy about it.