---
title: Interceptors
description: Interceptors are middleware functions that can modify HTTP requests and responses on every call to Elasticsearch. They are useful for injecting authentication...
url: https://www.elastic.co/docs/reference/elasticsearch/clients/go/advanced/interceptors
products:
  - Elasticsearch Client
  - Elasticsearch Go Client
applies_to:
  - Elastic Stack: Generally available since 8.19
---

# Interceptors
Interceptors are middleware functions that can modify HTTP requests and responses on every call to Elasticsearch. They are useful for injecting authentication credentials, adding custom headers, implementing observability, and handling challenge-response authentication protocols.

## How interceptors work

An interceptor wraps the transport's round-trip function. It receives the next function in the chain and returns a new function that can modify the request before calling `next`, and modify the response after.
The type signatures are defined in the `elastictransport` package:
```go
type RoundTripFunc func(*http.Request) (*http.Response, error)

type InterceptorFunc func(next RoundTripFunc) RoundTripFunc
```

Interceptors are configured at client creation and cannot be changed after the transport is created. The examples on this page use `elasticsearch.NewTyped`; interceptors apply identically to the [low-level client](https://www.elastic.co/docs/reference/elasticsearch/clients/go/low-level-api) built with `elasticsearch.New`.
```go
es, err := elasticsearch.NewTyped(
    elasticsearch.WithTransportOptions(
        elastictransport.WithInterceptors(
            myFirstInterceptor(),
            mySecondInterceptor(),
        ),
    ),
)
```


### Execution order

Interceptors are applied **left to right** for requests and **right to left** for responses. In the example above:
1. `myFirstInterceptor` modifies the request first
2. `mySecondInterceptor` modifies the request second, then sends it to Elasticsearch
3. `mySecondInterceptor` sees the response first
4. `myFirstInterceptor` sees the response last

```mermaid
sequenceDiagram
    participant App as Application
    participant I1 as Interceptor_1
    participant I2 as Interceptor_2
    participant ES as Elasticsearch

    App->>I1: request
    I1->>I2: request
    I2->>ES: request
    ES-->>I2: response
    I2-->>I1: response
    I1-->>App: response
```


## Dynamic credential rotation

When credentials may change at runtime (for example, during token refresh or credential rotation), an interceptor can inject the latest credentials into each request dynamically.
```go
func DynamicAuthInterceptor(provider *CredentialProvider) elastictransport.InterceptorFunc {
    return func(next elastictransport.RoundTripFunc) elastictransport.RoundTripFunc { 
        return func(req *http.Request) (*http.Response, error) {
            username, password := provider.Get() 
            req.SetBasicAuth(username, password)  
            return next(req)                      
        }
    }
}
```

The `CredentialProvider` uses a `sync.RWMutex` so credentials can be updated safely from another goroutine:
```go
type CredentialProvider struct {
    mu       sync.RWMutex
    username string
    password string
}

func (p *CredentialProvider) Get() (string, string) {
    p.mu.RLock()
    defer p.mu.RUnlock()
    return p.username, p.password
}

func (p *CredentialProvider) Update(username, password string) {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.username = username
    p.password = password
}
```

Usage:
```go
authProvider := NewCredentialProvider("user1", "password1")

es, err := elasticsearch.NewTyped(
    elasticsearch.WithAddresses("https://localhost:9200"),
    elasticsearch.WithTransportOptions(
        elastictransport.WithInterceptors(DynamicAuthInterceptor(authProvider)),
    ),
)

// Later, rotate credentials. All future requests use the new credentials.
authProvider.Update("user2", "password2")
```


## Per-request auth via context

In multi-tenant applications or impersonation scenarios, different requests may need different credentials. An interceptor can read credentials from the request's `context.Context`:
```go
type basicAuthKey struct{}

type basicAuthValue struct {
    username string
    password string
}

// WithBasicAuth attaches credentials to a context.
func WithBasicAuth(ctx context.Context, username, password string) context.Context {
    return context.WithValue(ctx, basicAuthKey{}, basicAuthValue{username, password})
}

func ContextAuthInterceptor() elastictransport.InterceptorFunc {
    return func(next elastictransport.RoundTripFunc) elastictransport.RoundTripFunc {
        return func(req *http.Request) (*http.Response, error) {
            if auth, ok := req.Context().Value(basicAuthKey{}).(basicAuthValue); ok {
                req.SetBasicAuth(auth.username, auth.password) 
            }
            return next(req) 
        }
    }
}
```

Usage:
```go
es, err := elasticsearch.NewTyped(
    elasticsearch.WithBasicAuth("default_user", "default_password"),
    elasticsearch.WithTransportOptions(
        elastictransport.WithInterceptors(ContextAuthInterceptor()),
    ),
)

// Uses default credentials
res, err := es.Info().Do(context.Background())
if err != nil {
    log.Fatal(err)
}

// Uses per-request credentials
ctx := WithBasicAuth(context.Background(), "tenant_a", "tenant_a_secret")
res, err = es.Info().Do(ctx)
if err != nil {
    log.Fatal(err)
}
```

<dropdown title="Challenge-response authentication (Kerberos/SPNEGO)">
  Interceptors can also handle challenge-response protocols. The following example implements Kerberos/SPNEGO authentication by retrying a request when a 401 challenge is received:
  ```go
  func KerberosInterceptor(tokenProvider func() (string, error)) elastictransport.InterceptorFunc {
      return func(next elastictransport.RoundTripFunc) elastictransport.RoundTripFunc {
          return func(req *http.Request) (*http.Response, error) {
              resp, err := next(req) 
              if err != nil {
                  return nil, err
              }

              if resp.StatusCode == http.StatusUnauthorized {
                  authHeader := resp.Header.Get("WWW-Authenticate")
                  if strings.HasPrefix(authHeader, "Negotiate") {
                      resp.Body.Close() 

                      token, err := tokenProvider() 
                      if err != nil {
                          return nil, fmt.Errorf("failed to obtain Kerberos token: %w", err)
                      }

                      retryReq := req.Clone(req.Context())
                      retryReq.Header.Set("Authorization", "Negotiate "+token)
                      return next(retryReq) 
                  }
              }

              return resp, nil
          }
      }
  }
  ```
</dropdown>

<dropdown title="Custom observability via interceptors">
  While the client has [built-in OpenTelemetry support](https://www.elastic.co/docs/reference/elasticsearch/clients/go/advanced/observability), interceptors can add custom observability. Here is a logging interceptor that records request and response details:
  ```go
  func LoggingInterceptor() elastictransport.InterceptorFunc {
      logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

      return func(next elastictransport.RoundTripFunc) elastictransport.RoundTripFunc {
          return func(req *http.Request) (*http.Response, error) {
              start := time.Now()

              logger.Info("elasticsearch request started",
                  slog.String("method", req.Method),
                  slog.String("url", req.URL.String()),
              )

              resp, err := next(req)
              duration := time.Since(start)

              if err != nil {
                  logger.Error("elasticsearch request failed",
                      slog.Duration("duration", duration),
                      slog.String("error", err.Error()),
                  )
                  return nil, err
              }

              logger.Info("elasticsearch request completed",
                  slog.Int("status_code", resp.StatusCode),
                  slog.Duration("duration", duration),
              )

              return resp, nil
          }
      }
  }
  ```
  You can compose multiple interceptors for logging, metrics, and tracing:
  ```go
  es, err := elasticsearch.NewTyped(
      elasticsearch.WithTransportOptions(
          elastictransport.WithInterceptors(
              LoggingInterceptor(),
              MetricsInterceptor(requestCounter, requestDuration),
              TracingInterceptor(tracer),
          ),
      ),
  )
  ```

  <note>
    Prefer using the built-in [OpenTelemetry instrumentation](https://www.elastic.co/docs/reference/elasticsearch/clients/go/advanced/observability) over custom interceptors for observability when possible. The built-in support follows OpenTelemetry semantic conventions and provides richer span attributes.
  </note>
</dropdown>

<dropdown title="Counting request retries">
  Interceptors can be used to monitor internal HTTP behavior, such as counting retry attempts made for a request. An interceptor can track each round-trip attempt by storing a counter in the request context:
  ```go
  type attemptCounterKey struct{}

  // AttemptCounter tracks the number of round-trip attempts for a single
  // Perform call. Attach it to the request context before calling Perform,
  // and read it back afterwards.
  type AttemptCounter struct {
  	value atomic.Int64
  }

  func (c *AttemptCounter) Attempts() int  { return int(c.value.Load()) }
  func (c *AttemptCounter) Retries() int   { return max(c.Attempts()-1, 0) }

  // NewAttemptContext returns a context carrying a fresh AttemptCounter,
  // along with the counter itself so the caller can inspect it after Perform.
  func NewAttemptContext(ctx context.Context) (context.Context, *AttemptCounter) {
  	counter := &AttemptCounter{}
  	return context.WithValue(ctx, attemptCounterKey{}, counter), counter
  }

  // CountRetriesInterceptor returns an interceptor that increments the
  // AttemptCounter stored in the request context on every round-trip.
  func CountRetriesInterceptor() elastictransport.InterceptorFunc {
  	return func(next elastictransport.RoundTripFunc) elastictransport.RoundTripFunc {
  		return func(req *http.Request) (*http.Response, error) {
  			if counter, ok := req.Context().Value(attemptCounterKey{}).(*AttemptCounter); ok {
  				counter.value.Add(1)
  			}
  			return next(req)
  		}
  	}
  }
  ```
  Usage:
  ```go
  es, err := elasticsearch.NewTyped(
      elasticsearch.WithAddresses("https://localhost:9200"),
      elasticsearch.WithTransportOptions(
          elastictransport.WithInterceptors(CountRetriesInterceptor()),
      ),
  )

  // Create a context with an attempt counter
  ctx, counter := NewAttemptContext(context.Background())

  // Perform a request that might be retried
  res, err := es.Info().Do(ctx)
  if err != nil {
      log.Fatal(err)
  }
  _ = res

  // Check how many attempts were made
  fmt.Printf("Request completed after %d attempts (%d retries)\n",
      counter.Attempts(), counter.Retries())
  ```
</dropdown>


## Best practices

- **Keep interceptors lightweight.** Interceptors run on every request. Avoid expensive operations like disk I/O or network calls inside the hot path.
- **Ordering matters.** Place authentication interceptors before observability interceptors so that traces capture the final request state.
- **Always call `next`.** Failing to call `next(req)` will prevent the request from reaching Elasticsearch. Only skip `next` if you intentionally want to short-circuit (e.g., returning a cached response).
- **Close response bodies on retry.** If your interceptor retries a request (like the Kerberos example), always close the original response body to avoid resource leaks.
- **Use `req.Clone()` for retries.** When retrying, clone the request to avoid mutating the original.
- **Handle errors from `next`.** If `next` returns an error, return it to the caller rather than swallowing it.