Skip to content
gohacks.dev
APIs

gRPC Interceptors in Go for Logging Auth and Recovery

Write gRPC interceptors in Go for logging, auth and panic recovery, chain them in the right order, wrap streams and avoid unguarded stream methods.

15 min read
Summarize in AI
Flat illustration of a request passing through concentric middleware layers to a handler.

Every service ends up with the same list of cross-cutting concerns: log each call, check who is calling, recover from panics, record metrics, add trace context, validate inputs, enforce limits. Putting these in each handler duplicates code and guarantees that one handler will forget one of them. gRPC's answer is the interceptor: a function that wraps every call and can run code before and after the handler, change the context, reject the call or alter the result. It is the equivalent of HTTP middleware.

Tested with Go 1.25.6, grpc-go v1.75.0, protobuf-go v1.36.8, protoc 29.3, macOS on Apple Silicon (arm64)

Interceptors are small to write and have a few sharp edges, mostly about ordering and about streams. This article builds the three most common (logging, authentication, recovery) plus a stream wrapper and a client interceptor, runs them against a service, and uses the output to explain the rules. It follows unary RPCs and pairs with gRPC authentication and OpenTelemetry for gRPC. It ran on Go 1.25.6.

The shapes

There are four interceptor types, two for servers and two for clients, each with a unary and a stream form.

A unary server interceptor has this signature:

Go
func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error)

It receives the context, the decoded request, information about the method (info.FullMethod is /calc.v1.Calc/Add) and the next handler. It must call handler(ctx, req) to continue the chain, or return without calling it to reject the call. Whatever it returns is what the client gets.

A stream server interceptor receives the server, a grpc.ServerStream, info and the next handler. It cannot see individual messages unless it wraps the stream and overrides SendMsg or RecvMsg.

The client forms are UnaryClientInterceptor (with the method, request, reply, connection and an invoker) and StreamClientInterceptor.

The service and the chain

The lab service has a method Add, a method Explode that always panics and a server stream Count. Three unary interceptors guard the unary methods and one wraps streams.

proto/calc.protoprotobuf
syntax = "proto3";
package calc.v1;
option go_package = "example.test/lab/calcpb";

service Calc {
  rpc Add(AddRequest) returns (AddReply);
  rpc Explode(AddRequest) returns (AddReply);        // always panics, to exercise recovery
  rpc Count(CountRequest) returns (stream CountReply); // a server stream, to exercise stream interceptors
}

message AddRequest { int64 a = 1; int64 b = 2; }
message AddReply { int64 sum = 1; }
message CountRequest { int32 to = 1; }
message CountReply { int32 n = 1; }
tools.goGo
//go:build tools

package main

import (
	_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
	_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)
main.goGo
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"net"
	"runtime/debug"
	"strings"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials/insecure"
	"google.golang.org/grpc/metadata"
	"google.golang.org/grpc/status"

	pb "example.test/lab/calcpb"
)

type ctxKey string

var trace []string // what the interceptors saw, in order, so the output shows the chain

func note(format string, a ...any) { trace = append(trace, fmt.Sprintf(format, a...)) }

// loggingUnary is the outermost layer: it sees every call and its final status, including the ones other layers reject.
func loggingUnary(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) {
	note("log: -> %s", info.FullMethod)
	resp, err := next(ctx, req)
	note("log: <- %s code=%s", info.FullMethod, status.Code(err))
	return resp, err
}

// recoveryUnary turns a panic in a handler into an Internal status instead of crashing the process.
func recoveryUnary(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (resp any, err error) {
	defer func() {
		if r := recover(); r != nil {
			note("recovery: caught %q, stack trace goes to the server log", fmt.Sprint(r))
			_ = debug.Stack()                                    // a real server logs this
			err = status.Error(codes.Internal, "internal error") // the client never sees the panic text
		}
	}()
	return next(ctx, req)
}

// authUnary checks a bearer token in the metadata and puts the caller in the context.
func authUnary(ctx context.Context, req any, info *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) {
	md, _ := metadata.FromIncomingContext(ctx)
	tokens := md.Get("authorization")
	if len(tokens) == 0 || !strings.HasPrefix(tokens[0], "Bearer ") {
		note("auth: rejected, no token")
		return nil, status.Error(codes.Unauthenticated, "missing bearer token")
	}
	user := strings.TrimPrefix(tokens[0], "Bearer ")
	if user != "ada" && user != "bob" {
		note("auth: rejected, bad token")
		return nil, status.Error(codes.Unauthenticated, "invalid token")
	}
	note("auth: caller is %s", user)
	return next(context.WithValue(ctx, ctxKey("user"), user), req)
}

// A wrapped stream lets a stream interceptor see (and change) each message.
type countingStream struct {
	grpc.ServerStream
	sent int
}

func (s *countingStream) SendMsg(m any) error {
	s.sent++
	return s.ServerStream.SendMsg(m)
}

func loggingStream(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, next grpc.StreamHandler) error {
	cs := &countingStream{ServerStream: ss}
	err := next(srv, cs)
	note("log: stream %s sent %d messages, code=%s", info.FullMethod, cs.sent, status.Code(err))
	return err
}

type calc struct{ pb.UnimplementedCalcServer }

func (calc) Add(ctx context.Context, r *pb.AddRequest) (*pb.AddReply, error) {
	note("handler: Add called by %v", ctx.Value(ctxKey("user")))
	return &pb.AddReply{Sum: r.GetA() + r.GetB()}, nil
}
func (calc) Explode(context.Context, *pb.AddRequest) (*pb.AddReply, error) {
	panic("index out of range")
}
func (calc) Count(r *pb.CountRequest, s grpc.ServerStreamingServer[pb.CountReply]) error {
	for i := int32(1); i <= r.GetTo(); i++ {
		if err := s.Send(&pb.CountReply{N: i}); err != nil {
			return err
		}
	}
	return nil
}

// withToken is a client interceptor: every outgoing call gets the token without each call site repeating it.
func withToken(token string) grpc.UnaryClientInterceptor {
	return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
		if token != "" {
			ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token)
		}
		return invoker(ctx, method, req, reply, cc, opts...)
	}
}

func main() {
	lis, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		log.Fatal(err)
	}
	srv := grpc.NewServer(
		// The first interceptor is the outermost: logging wraps recovery, which wraps auth, which wraps the handler.
		grpc.ChainUnaryInterceptor(loggingUnary, recoveryUnary, authUnary),
		grpc.ChainStreamInterceptor(loggingStream),
	)
	pb.RegisterCalcServer(srv, calc{})
	go srv.Serve(lis)
	defer srv.Stop()

	dial := func(token string) pb.CalcClient {
		conn, err := grpc.NewClient(lis.Addr().String(),
			grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithChainUnaryInterceptor(withToken(token)))
		if err != nil {
			log.Fatal(err)
		}
		return pb.NewCalcClient(conn)
	}
	flush := func(label string, err error) {
		fmt.Printf("%s -> %s\n", label, status.Code(err))
		for _, t := range trace {
			fmt.Println("    ", t)
		}
		trace = nil
	}

	ctx := context.Background()
	_, err = dial("ada").Add(ctx, &pb.AddRequest{A: 2, B: 3})
	flush("Add with a good token", err)
	_, err = dial("").Add(ctx, &pb.AddRequest{A: 2, B: 3})
	flush("Add with no token", err)
	_, err = dial("mallory").Add(ctx, &pb.AddRequest{A: 2, B: 3})
	flush("Add with a bad token", err)
	_, err = dial("bob").Explode(ctx, &pb.AddRequest{})
	flush("Explode (the handler panics)", err)

	for _, token := range []string{"ada", ""} {
		stream, _ := dial(token).Count(ctx, &pb.CountRequest{To: 4})
		var last error
		for {
			if _, last = stream.Recv(); last != nil {
				break
			}
		}
		if last == io.EOF {
			last = nil
		}
		flush(fmt.Sprintf("Count to 4 (a stream) with token %q", token), last)
	}
}
Shell
mkdir -p bin
go build -o bin/protoc-gen-go google.golang.org/protobuf/cmd/protoc-gen-go
go build -o bin/protoc-gen-go-grpc google.golang.org/grpc/cmd/protoc-gen-go-grpc
PATH="$PWD/bin:$PATH" protoc --go_out=. --go_opt=module=example.test/lab --go-grpc_out=. --go-grpc_opt=module=example.test/lab proto/*.proto
go run .
Text
Add with a good token -> OK
     log: -> /calc.v1.Calc/Add
     auth: caller is ada
     handler: Add called by ada
     log: <- /calc.v1.Calc/Add code=OK
Add with no token -> Unauthenticated
     log: -> /calc.v1.Calc/Add
     auth: rejected, no token
     log: <- /calc.v1.Calc/Add code=Unauthenticated
Add with a bad token -> Unauthenticated
     log: -> /calc.v1.Calc/Add
     auth: rejected, bad token
     log: <- /calc.v1.Calc/Add code=Unauthenticated
Explode (the handler panics) -> Internal
     log: -> /calc.v1.Calc/Explode
     auth: caller is bob
     recovery: caught "index out of range", stack trace goes to the server log
     log: <- /calc.v1.Calc/Explode code=Internal
Count to 4 (a stream) with token "ada" -> OK
     log: stream /calc.v1.Calc/Count sent 4 messages, code=OK
Count to 4 (a stream) with token "" -> OK
     log: stream /calc.v1.Calc/Count sent 4 messages, code=OK

Registering a chain

grpc.ChainUnaryInterceptor(loggingUnary, recoveryUnary, authUnary) registers them as a chain. The first one is the outermost. A call passes through logging, then recovery, then auth, then the handler, and the results unwind in the opposite order. You can think of it as logging(recovery(auth(handler))).

Why does the order matter? The output shows it on the call with a bad token. The log line log: -> /calc.v1.Calc/Add appears first, auth: rejected in the middle, and log: <- ... code=Unauthenticated at the end. Logging is outermost, so it records even calls that auth rejects, which is what you want for an access log and an audit trail. If auth were outermost, rejected calls would never reach the logger and you would see none of the attacks.

Recovery sits inside logging and outside auth and the handler. That placement lets it catch a panic from auth or the handler, and lets logging record the resulting Internal status. If recovery were outermost, a panic in the logging interceptor itself would be caught too, which is another valid design; the rule is that recovery must be outside whatever you want it to protect.

Logging

loggingUnary notes the method before the call and the status code after. A real one also records the duration, the peer address, the request id and, for failures, the error message. Use the status code, not just the Go error: status.Code(err) gives you the label ("NotFound") that you can count in metrics. Do not log request or response bodies by default; they contain personal data and secrets (logging without leaking secrets).

Authentication

authUnary reads the authorization metadata, checks it and either returns Unauthenticated or continues with a context carrying the user. Three points.

First, the check happens before the handler, so handlers can assume an authenticated caller and read the user with ctx.Value. Define the key as a private type, as the lab does, so no other package collides with it.

Second, it returns the same code for a missing token and a bad one, with short messages. The distinction between "no token" and "wrong token" helps debugging and gives an attacker nothing they could not infer, so either choice is defensible; do not say why a token is invalid.

Third, token checking in the lab is a string comparison for the demonstration. A real interceptor verifies a JWT's signature, issuer, audience and expiry (API authentication), or calls an introspection endpoint with a cache. Authorisation (can this user call this method on this object?) belongs after authentication: a per-method policy table keyed by info.FullMethod is a clean way to do coarse checks, and object-level checks stay in the handlers or services (BOLA).

Recovery

A panic in a gRPC handler is not recovered by the framework, and it will terminate the process. That is a surprise to developers used to HTTP servers where net/http recovers per request. The recoveryUnary interceptor uses defer and recover() to catch the panic, writes it to the log with a stack trace, and replaces the result with status.Error(codes.Internal, "internal error"). The output for Explode shows it: the interceptor caught "index out of range", and the client received Internal. The client sees a generic message; the panic text and the stack stay on the server.

Recover only in an interceptor, not in handlers. And be aware of its limit: it catches panics in the goroutine of the call. A panic in a goroutine your handler started is not caught and will crash the process, so goroutines you spawn need their own recover.

Stream interceptors and wrapped streams

For streams, an interceptor sees the stream as a whole, not each message. To see messages, wrap the stream. The lab defines countingStream, which embeds grpc.ServerStream and overrides SendMsg to count messages before delegating. The interceptor passes the wrapper to the handler and reads the count afterwards. The output shows sent 4 messages, code=OK. The same technique with RecvMsg lets you validate or log incoming messages, and by overriding Context() you can hand the handler a context with extra values, which is how stream authentication passes the user.

The gap: unary interceptors do not guard streams

The last two calls are the most important lines in the output. The auth interceptor was registered with ChainUnaryInterceptor. Count is a streaming method. It went through only the stream chain, which contained logging and no auth, so a call with an empty token succeeded, the same as one with a valid token.

This is a real vulnerability that appears in real services: an engineer adds authentication as a unary interceptor, tests it on the unary methods and moves on, and every streaming method stays open. The fix is to register an equivalent ChainStreamInterceptor for each concern, sharing the checking logic:

Go
func authStream(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, next grpc.StreamHandler) error {
	user, err := authenticate(ss.Context()) // the same function the unary interceptor calls
	if err != nil {
		return err
	}
	return next(srv, &userStream{ServerStream: ss, ctx: context.WithValue(ss.Context(), ctxKey("user"), user)})
}

Write the shared logic once and keep two thin adapters. Then add a test that walks every method of the registered service (the server's GetServiceInfo() lists them) and asserts that a call without credentials is rejected, so a new streaming method cannot ship open.

Client interceptors

withToken is a unary client interceptor that appends the bearer token to the outgoing metadata, so call sites stay clean. It is installed with grpc.WithChainUnaryInterceptor when creating the connection. Use client interceptors for the same cross-cutting reasons: authentication headers, request ids, tracing propagation, default timeouts, metrics and retries. Remember that the stream version is separate, and install both.

A client-side default deadline is a good example:

Go
func withDefaultTimeout(d time.Duration) grpc.UnaryClientInterceptor {
	return func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
		if _, ok := ctx.Deadline(); !ok {
			var cancel context.CancelFunc
			ctx, cancel = context.WithTimeout(ctx, d)
			defer cancel()
		}
		return invoker(ctx, method, req, reply, cc, opts...)
	}
}

It adds a deadline only when the caller did not set one, so a forgotten timeout cannot hang the program (deadlines).

Recipes worth having

Beyond the three in the lab, a few interceptors earn their place in most services.

Request id. Read x-request-id from the incoming metadata (or generate one), put it in the context and attach it to every log line and every outgoing call. It is the thread that ties the logs of five services into one story (request IDs).

Validation. Check the request before the handler runs. If your messages implement a Validate() error method (generated by protoc-gen-validate or Buf's protovalidate, or written by hand), one interceptor can call it and return InvalidArgument with the field in the message, and every handler then starts from a valid request.

Per-method policy. Keep a table keyed by info.FullMethod: whether authentication is required, which scope is needed, which rate limit applies. The interceptor looks the method up and applies the policy. Defaulting to "denied" for a method that is not in the table means a new rpc is closed until someone decides, which is the safe default.

Rate limiting. A token bucket per caller, keyed by the user from the context or the peer address, returning ResourceExhausted with a retry hint in the status details (the Redis rate limiter shows the shared-state version).

Default deadline on the server. If a client forgot a deadline, the server can impose one by wrapping the context with a timeout. It protects the server from clients that wait forever and holds the server's resources to a bound.

Metrics. Count calls by method and code, and time them with a histogram. Label by info.FullMethod and the status code, never by user or request id, to keep the number of series bounded (SLOs show what to compute from them).

Ordering mistakes to avoid

Ordering problems do not fail loudly. They look like missing log lines or unprotected methods.

  • Recovery inside logging, not outside. The logger should record the Internal produced by the recovery, so the panic shows up as a failed call.
  • Authentication before validation and rate limiting by user. Rate limiting that needs the user must run after auth. Rate limiting by IP address can run before it, which protects the auth check itself from floods.
  • Tracing outermost. A span should cover the whole call, including the interceptors, so the time spent in auth is visible.
  • Context changes only flow inward. An interceptor that adds a value to the context passes the new context to handler; an interceptor outside it never sees the value. Information that must flow outward (the user, for the logger) needs a shared mutable holder placed in the context by the outer interceptor.

The simplest defence is a single function that builds the chain for the server, used by both the production binary and the tests, so there is one place where the order is written.

Cost

An interceptor adds a function call and whatever it does. The call overhead is nanoseconds; the work inside (JSON logging, token verification, a Redis call) dominates. Keep the work cheap and, where it is not, cache: verified tokens for their lifetime, policy lookups in a map built at start-up. For the heavy ones, measure with a benchmark that runs the call through the full chain (gRPC performance shows how).

Interceptors or stats handlers

For observability, grpc-go also offers stats handlers (grpc.StatsHandler), which receive events at the transport level: bytes in and out, headers, begin and end of a call. OpenTelemetry's gRPC instrumentation uses a stats handler rather than interceptors for exactly that reason. Use interceptors for behaviour that changes calls (auth, validation, recovery, deadlines), and stats handlers for passive measurement.

Keeping the layers honest

It is tempting to put business rules in interceptors because they run for every call. Resist it. An interceptor should be about the call (who, how long, how often, what status), and a handler about the domain. When a rule depends on the contents of a specific request, it belongs with the code that understands that request, where it can be tested without a server and read next to the logic it protects.

Libraries and testing

The go-grpc-middleware project (under grpc-ecosystem) provides ready-made interceptors for logging, recovery, retries, rate limiting and validation, with the same shapes as the ones above. Reading their source is a good way to see how details are handled; using them saves writing and maintaining the boilerplate.

Test interceptors as ordinary functions: construct a fake UnaryServerInfo, a handler that records whether it was called, and assert on the result. Then test the chain through a real in-memory server, covering the ordering questions above: that a rejected call is still logged, that a panic is recovered and logged, and that every streaming method is guarded (testing gRPC services). The reference is the grpc-go interceptor documentation and the middleware project.

Also inSecurity
Tagged""
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.