Repository: envoyproxy/gateway

Issue:
Support distinct match for local ratelimit
EG can now add distinct match support for local ratelimit, as it has been implemented in Envoy.

https://github.com/envoyproxy/envoy/pull/36623

Discussion:

Solution patch:
diff --git a/api/v1alpha1/ratelimit_types.go b/api/v1alpha1/ratelimit_types.go
index 5c52d5373..815aca794 100644
--- a/api/v1alpha1/ratelimit_types.go
+++ b/api/v1alpha1/ratelimit_types.go
@@ -202,7 +202,6 @@ const (
 	SourceMatchExact SourceMatchType = "Exact"
 	// SourceMatchDistinct Each IP Address within the specified Source IP CIDR is treated as a distinct client selector
 	// and uses a separate rate limit bucket/counter.
-	// Note: This is only supported for Global Rate Limits.
 	SourceMatchDistinct SourceMatchType = "Distinct"
 )
 
@@ -270,7 +269,6 @@ const (
 	// HeaderMatchDistinct matches any and all possible unique values encountered in the
 	// specified HTTP Header. Note that each unique value will receive its own rate limit
 	// bucket.
-	// Note: This is only supported for Global Rate Limits.
 	HeaderMatchDistinct HeaderMatchType = "Distinct"
 )
 
diff --git a/examples/extension-server/go.mod b/examples/extension-server/go.mod
index ed38b67c3..52d92e8b6 100644
--- a/examples/extension-server/go.mod
+++ b/examples/extension-server/go.mod
@@ -4,8 +4,8 @@ go 1.24.0
 
 require (
 	github.com/envoyproxy/gateway v1.3.0
-	github.com/envoyproxy/go-control-plane v0.13.4
-	github.com/envoyproxy/go-control-plane/envoy v1.32.4
+	github.com/envoyproxy/go-control-plane v0.13.5-0.20250303232259-0c5cb90447bb
+	github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250303232259-0c5cb90447bb
 	github.com/urfave/cli/v2 v2.27.5
 	google.golang.org/grpc v1.70.0
 	google.golang.org/protobuf v1.36.5
diff --git a/examples/extension-server/go.sum b/examples/extension-server/go.sum
index 73f1c30c8..72842d102 100644
--- a/examples/extension-server/go.sum
+++ b/examples/extension-server/go.sum
@@ -8,10 +8,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
-github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
-github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
-github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
+github.com/envoyproxy/go-control-plane v0.13.5-0.20250303232259-0c5cb90447bb h1:6PnPVZ2oLLX6cV28/823mJA1djE4X3kzJXkigkHNNP0=
+github.com/envoyproxy/go-control-plane v0.13.5-0.20250303232259-0c5cb90447bb/go.mod h1:UbsdUt4yQprC2gTbH9AaLhB/RAAajyoD55sjUAfzKUA=
+github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250303232259-0c5cb90447bb h1:4oJf7zkbbNoBRGkeA0O8jB48FjM78qfYwGL6Q68AaH4=
+github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250303232259-0c5cb90447bb/go.mod h1:vggMdJSZaAGnQ5sf1pBl1OV7w3IFqt8qo3id+asttJw=
 github.com/envoyproxy/protoc-gen-validate v1.2.1 h1:DEo3O99U8j4hBFwbJfrz9VtgcDfUKS7KJ7spH3d86P8=
 github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
 github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
diff --git a/go.mod b/go.mod
index 53818831a..b4f176579 100644
--- a/go.mod
+++ b/go.mod
@@ -13,9 +13,9 @@ require (
 	github.com/docker/cli v27.5.1+incompatible
 	github.com/docker/docker v27.5.1+incompatible
 	github.com/dominikbraun/graph v0.23.0
-	github.com/envoyproxy/go-control-plane v0.13.4
-	github.com/envoyproxy/go-control-plane/contrib v1.32.4
-	github.com/envoyproxy/go-control-plane/envoy v1.32.4
+	github.com/envoyproxy/go-control-plane v0.13.5-0.20250303232259-0c5cb90447bb
+	github.com/envoyproxy/go-control-plane/contrib v1.32.5-0.20250303232259-0c5cb90447bb
+	github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250303232259-0c5cb90447bb
 	github.com/envoyproxy/go-control-plane/ratelimit v0.1.0
 	github.com/envoyproxy/ratelimit v1.4.1-0.20230427142404-e2a87f41d3a7
 	github.com/evanphx/json-patch v5.9.11+incompatible
diff --git a/go.sum b/go.sum
index 315793206..dc4f2de03 100644
--- a/go.sum
+++ b/go.sum
@@ -352,12 +352,12 @@ github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRr
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/go-control-plane v0.13.4 h1:zEqyPVyku6IvWCFwux4x9RxkLOMUL+1vC9xUFv5l2/M=
-github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
-github.com/envoyproxy/go-control-plane/contrib v1.32.4 h1:/udV6s9xkDGe13WfrT2MHAxXTNDMBYBPxI1GkleCrmM=
-github.com/envoyproxy/go-control-plane/contrib v1.32.4/go.mod h1:gkGYoY7plfQg7FPBDhyKtP1cDA9frFR/3YsCx8taRvI=
-github.com/envoyproxy/go-control-plane/envoy v1.32.4 h1:jb83lalDRZSpPWW2Z7Mck/8kXZ5CQAFYVjQcdVIr83A=
-github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
+github.com/envoyproxy/go-control-plane v0.13.5-0.20250303232259-0c5cb90447bb h1:6PnPVZ2oLLX6cV28/823mJA1djE4X3kzJXkigkHNNP0=
+github.com/envoyproxy/go-control-plane v0.13.5-0.20250303232259-0c5cb90447bb/go.mod h1:UbsdUt4yQprC2gTbH9AaLhB/RAAajyoD55sjUAfzKUA=
+github.com/envoyproxy/go-control-plane/contrib v1.32.5-0.20250303232259-0c5cb90447bb h1:rQwW655S4Bvkp/jdkoPhG6kWPfsSaK3xiDLgmSJMVio=
+github.com/envoyproxy/go-control-plane/contrib v1.32.5-0.20250303232259-0c5cb90447bb/go.mod h1:dnsjEXShSTkYKebBHJng2tMCYQJhJhlP0CDtMpLRQqQ=
+github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250303232259-0c5cb90447bb h1:4oJf7zkbbNoBRGkeA0O8jB48FjM78qfYwGL6Q68AaH4=
+github.com/envoyproxy/go-control-plane/envoy v1.32.5-0.20250303232259-0c5cb90447bb/go.mod h1:vggMdJSZaAGnQ5sf1pBl1OV7w3IFqt8qo3id+asttJw=
 github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
 github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
diff --git a/internal/gatewayapi/backendtrafficpolicy.go b/internal/gatewayapi/backendtrafficpolicy.go
index 16d00920c..4d3c95c49 100644
--- a/internal/gatewayapi/backendtrafficpolicy.go
+++ b/internal/gatewayapi/backendtrafficpolicy.go
@@ -676,16 +676,6 @@ func (t *Translator) buildLocalRateLimit(policy *egv1a1.BackendTrafficPolicy) (*
 		if err != nil {
 			return nil, err
 		}
-
-		if irRule.CIDRMatch != nil && irRule.CIDRMatch.Distinct {
-			return nil, fmt.Errorf("local rateLimit does not support distinct CIDRMatch")
-		}
-
-		for _, match := range irRule.HeaderMatches {
-			if match.Distinct {
-				return nil, fmt.Errorf("local rateLimit does not support distinct HeaderMatch")
-			}
-		}
 		irRules = append(irRules, irRule)
 	}
 
diff --git a/internal/xds/extensions/extensions.gen.go b/internal/xds/extensions/extensions.gen.go
index 052eb2216..5df9ca1bb 100644
--- a/internal/xds/extensions/extensions.gen.go
+++ b/internal/xds/extensions/extensions.gen.go
@@ -204,6 +204,7 @@ import (
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/custom_response/local_response_policy/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/custom_response/redirect_policy/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/early_header_mutation/header_mutation/v3"
+	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/ext_proc/response_processors/save_processing_response/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/header_formatters/preserve_case/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/header_validators/envoy_default/v3"
 	_ "github.com/envoyproxy/go-control-plane/envoy/extensions/http/injected_credentials/generic/v3"
diff --git a/internal/xds/translator/local_ratelimit.go b/internal/xds/translator/local_ratelimit.go
index ea3c43516..1fb7272f7 100644
--- a/internal/xds/translator/local_ratelimit.go
+++ b/internal/xds/translator/local_ratelimit.go
@@ -27,6 +27,7 @@ import (
 const (
 	localRateLimitFilterStatPrefix = "http_local_rate_limiter"
 	descriptorMaskedRemoteAddress  = "masked_remote_address"
+	descriptorRemoteAddress        = "remote_address"
 )
 
 func init() {
@@ -59,6 +60,11 @@ func (*localRateLimit) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir
 
 	localRl := &localrlv3.LocalRateLimit{
 		StatPrefix: localRateLimitFilterStatPrefix,
+		MaxDynamicDescriptors: &wrapperspb.UInt32Value{
+			Value: 10000,
+			// Default to 10k, assuming a listener has 10k unique active users to be rate limited.
+			// We can make this configurable in the API if needed.
+		},
 	}
 
 	localRlAny, err := anypb.New(localRl)
@@ -129,10 +135,7 @@ func (*localRateLimit) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute) e
 
 	local := irRoute.Traffic.RateLimit.Local
 
-	rateLimits, descriptors, err := buildRouteLocalRateLimits(local)
-	if err != nil {
-		return err
-	}
+	rateLimits, descriptors := buildRouteLocalRateLimits(local)
 	routeAction.RateLimits = rateLimits
 
 	filterCfg := route.GetTypedPerFilterConfig()
@@ -189,7 +192,7 @@ func (*localRateLimit) patchRoute(route *routev3.Route, irRoute *ir.HTTPRoute) e
 }
 
 func buildRouteLocalRateLimits(local *ir.LocalRateLimit) (
-	[]*routev3.RateLimit, []*rlv3.LocalRateLimitDescriptor, error,
+	[]*routev3.RateLimit, []*rlv3.LocalRateLimitDescriptor,
 ) {
 	var rateLimits []*routev3.RateLimit
 	var descriptors []*rlv3.LocalRateLimitDescriptor
@@ -201,42 +204,57 @@ func buildRouteLocalRateLimits(local *ir.LocalRateLimit) (
 
 		// HeaderMatches
 		for mIdx, match := range rule.HeaderMatches {
+			var action *routev3.RateLimit_Action
+			var entry *rlv3.RateLimitDescriptor_Entry
+
 			if match.Distinct {
-				// This is a sanity check. This should never happen because Gateway
-				// API translator should have already validated this.
-				if rule.CIDRMatch.Distinct {
-					return nil, nil, errors.New("local rateLimit does not support distinct HeaderMatch")
+				// For distinct matches, we only check if the header exists using the RequestHeaders action.
+				descriptorKey := getRouteRuleDescriptor(rIdx, mIdx)
+				action = &routev3.RateLimit_Action{
+					ActionSpecifier: &routev3.RateLimit_Action_RequestHeaders_{
+						RequestHeaders: &routev3.RateLimit_Action_RequestHeaders{
+							HeaderName:    match.Name,
+							DescriptorKey: descriptorKey,
+						},
+					},
 				}
-			}
-
-			// Setup HeaderValueMatch actions
-			descriptorKey := getRouteRuleDescriptor(rIdx, mIdx)
-			descriptorVal := getRouteRuleDescriptor(rIdx, mIdx)
-			headerMatcher := &routev3.HeaderMatcher{
-				Name: match.Name,
-				HeaderMatchSpecifier: &routev3.HeaderMatcher_StringMatch{
-					StringMatch: buildXdsStringMatcher(match),
-				},
-			}
-			expectMatch := true
-			if match.Invert != nil && *match.Invert {
-				expectMatch = false
-			}
-			action := &routev3.RateLimit_Action{
-				ActionSpecifier: &routev3.RateLimit_Action_HeaderValueMatch_{
-					HeaderValueMatch: &routev3.RateLimit_Action_HeaderValueMatch{
-						DescriptorKey:   descriptorKey,
-						DescriptorValue: descriptorVal,
-						ExpectMatch: &wrapperspb.BoolValue{
-							Value: expectMatch,
+				// The descriptor entry value is not set for distinct matches, which means that each distinct
+				// value of the matched header will be counted separately.
+				entry = &rlv3.RateLimitDescriptor_Entry{
+					Key: descriptorKey,
+				}
+			} else {
+				// For exact matches, we check if there is an existing header with the matching value using the
+				// HeaderValueMatch action.
+				descriptorKey := getRouteRuleDescriptor(rIdx, mIdx)
+				descriptorVal := getRouteRuleDescriptor(rIdx, mIdx)
+				headerMatcher := &routev3.HeaderMatcher{
+					Name: match.Name,
+					HeaderMatchSpecifier: &routev3.HeaderMatcher_StringMatch{
+						StringMatch: buildXdsStringMatcher(match),
+					},
+				}
+				expectMatch := true
+				if match.Invert != nil && *match.Invert {
+					expectMatch = false
+				}
+				action = &routev3.RateLimit_Action{
+					ActionSpecifier: &routev3.RateLimit_Action_HeaderValueMatch_{
+						HeaderValueMatch: &routev3.RateLimit_Action_HeaderValueMatch{
+							DescriptorKey:   descriptorKey,
+							DescriptorValue: descriptorVal,
+							ExpectMatch: &wrapperspb.BoolValue{
+								Value: expectMatch,
+							},
+							Headers: []*routev3.HeaderMatcher{headerMatcher},
 						},
-						Headers: []*routev3.HeaderMatcher{headerMatcher},
 					},
-				},
-			}
-			entry := &rlv3.RateLimitDescriptor_Entry{
-				Key:   descriptorKey,
-				Value: descriptorVal,
+				}
+				// For exact matches, the descriptor entry value is set to the generated descriptor value.
+				entry = &rlv3.RateLimitDescriptor_Entry{
+					Key:   descriptorKey,
+					Value: descriptorVal,
+				}
 			}
 			rlActions = append(rlActions, action)
 			descriptorEntries = append(descriptorEntries, entry)
@@ -244,13 +262,8 @@ func buildRouteLocalRateLimits(local *ir.LocalRateLimit) (
 
 		// Source IP CIDRMatch
 		if rule.CIDRMatch != nil {
-			// This is a sanity check. This should never happen because Gateway
-			// API translator should have already validated this.
-			if rule.CIDRMatch.Distinct {
-				return nil, nil, errors.New("local rateLimit does not support distinct CIDRMatch")
-			}
-
-			// Setup MaskedRemoteAddress action
+			// For CIDR matches, we first need to check if the source IP matches the CIDR range using
+			// the MaskedRemoteAddress action.
 			mra := &routev3.RateLimit_Action_MaskedRemoteAddress{}
 			maskLen := &wrapperspb.UInt32Value{Value: rule.CIDRMatch.MaskLen}
 			if rule.CIDRMatch.IsIPv6 {
@@ -269,6 +282,23 @@ func buildRouteLocalRateLimits(local *ir.LocalRateLimit) (
 			}
 			descriptorEntries = append(descriptorEntries, entry)
 			rlActions = append(rlActions, action)
+
+			if rule.CIDRMatch.Distinct {
+				// If the CIDRMatch is distinct, we also need to use the RemoteAddress action to get the client IP.
+				action = &routev3.RateLimit_Action{
+					ActionSpecifier: &routev3.RateLimit_Action_RemoteAddress_{
+						RemoteAddress: &routev3.RateLimit_Action_RemoteAddress{},
+					},
+				}
+
+				// If the CIDRMatch is distinct, we use the built-in remote address descriptor key without a value.
+				// This means that each distinct client IP will be counted separately.
+				entry = &rlv3.RateLimitDescriptor_Entry{
+					Key: descriptorRemoteAddress,
+				}
+				descriptorEntries = append(descriptorEntries, entry)
+				rlActions = append(rlActions, action)
+			}
 		}
 
 		rateLimit := &routev3.RateLimit{Actions: rlActions}
@@ -287,5 +317,5 @@ func buildRouteLocalRateLimits(local *ir.LocalRateLimit) (
 		descriptors = append(descriptors, descriptor)
 	}
 
-	return rateLimits, descriptors, nil
+	return rateLimits, descriptors
 }
diff --git a/release-notes/current.yaml b/release-notes/current.yaml
index 41078a15f..4cb54a558 100644
--- a/release-notes/current.yaml
+++ b/release-notes/current.yaml
@@ -18,6 +18,7 @@ new features: |
   Added support for defining Lua EnvoyExtensionPolicies
   Added RequestID field in ClientTrafficPolicy.HeaderSettings to configure Envoy X-Request-ID behavior.
   Added support for HorizontalPodAutoscaler to helm chart
+  Added support for distinct header and distinct source CIDR based local rate limiting
   Added support for forwarding the authenticated username to the backend via a configurable header in BasicAuth
 
 bug fixes: |
@@ -36,4 +37,3 @@ deprecations: |
 
 # Other notable changes not covered by the above sections.
 Other changes: |
-



Test patch:
diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-local-ratelimit-invalid-match-type.in.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-local-ratelimit-distinct-match-type.in.yaml
similarity index 100%
rename from internal/gatewayapi/testdata/backendtrafficpolicy-with-local-ratelimit-invalid-match-type.in.yaml
rename to internal/gatewayapi/testdata/backendtrafficpolicy-with-local-ratelimit-distinct-match-type.in.yaml
diff --git a/internal/gatewayapi/testdata/backendtrafficpolicy-with-local-ratelimit-invalid-match-type.out.yaml b/internal/gatewayapi/testdata/backendtrafficpolicy-with-local-ratelimit-distinct-match-type.out.yaml
similarity index 82%
rename from internal/gatewayapi/testdata/backendtrafficpolicy-with-local-ratelimit-invalid-match-type.out.yaml
rename to internal/gatewayapi/testdata/backendtrafficpolicy-with-local-ratelimit-distinct-match-type.out.yaml
index c4952e3cb..2ada61ce5 100644
--- a/internal/gatewayapi/testdata/backendtrafficpolicy-with-local-ratelimit-invalid-match-type.out.yaml
+++ b/internal/gatewayapi/testdata/backendtrafficpolicy-with-local-ratelimit-distinct-match-type.out.yaml
@@ -43,9 +43,9 @@ backendTrafficPolicies:
         namespace: envoy-gateway
       conditions:
       - lastTransitionTime: null
-        message: 'RateLimit: local rateLimit does not support distinct HeaderMatch.'
-        reason: Invalid
-        status: "False"
+        message: Policy has been accepted.
+        reason: Accepted
+        status: "True"
         type: Accepted
       controllerName: gateway.envoyproxy.io/gatewayclass-controller
 gateways:
@@ -175,8 +175,6 @@ xdsIR:
             name: httproute/default/httproute-1/rule/0/backend/0
             protocol: HTTP
             weight: 1
-        directResponse:
-          statusCode: 500
         hostname: gateway.envoyproxy.io
         isHTTP2: false
         metadata:
@@ -188,6 +186,38 @@ xdsIR:
           distinct: false
           name: ""
           prefix: /
+        traffic:
+          rateLimit:
+            local:
+              default:
+                requests: 4294967295
+                unit: Second
+              rules:
+              - headerMatches:
+                - distinct: false
+                  exact: one
+                  name: x-user-id
+                - distinct: true
+                  name: x-org-id
+                limit:
+                  requests: 10
+                  unit: Hour
+              - cidrMatch:
+                  cidr: 192.168.0.0/16
+                  distinct: false
+                  ip: 192.168.0.0
+                  isIPv6: false
+                  maskLen: 16
+                headerMatches:
+                - distinct: false
+                  exact: two
+                  name: x-user-id
+                - distinct: false
+                  exact: bar
+                  name: x-org-id
+                limit:
+                  requests: 10
+                  unit: Minute
     readyListener:
       address: 0.0.0.0
       ipFamily: IPv4
diff --git a/internal/xds/translator/testdata/in/xds-ir/local-ratelimit-distinct.yaml b/internal/xds/translator/testdata/in/xds-ir/local-ratelimit-distinct.yaml
new file mode 100644
index 000000000..aece51968
--- /dev/null
+++ b/internal/xds/translator/testdata/in/xds-ir/local-ratelimit-distinct.yaml
@@ -0,0 +1,70 @@
+http:
+- name: "first-listener"
+  address: "::"
+  port: 10080
+  hostnames:
+  - "*"
+  path:
+    mergeSlashes: true
+    escapedSlashesAction: UnescapeAndRedirect
+  routes:
+  - name: "first-route-ratelimit-distinct-ip"
+    hostname: "*"
+    traffic:
+      rateLimit:
+        local:
+          default:
+            requests: 10
+            unit: Minute
+          rules:
+          - headerMatches:
+            - name: "x-user-id"
+              distinct: true
+            limit:
+              requests: 5
+              unit: second
+    pathMatch:
+      exact: "foo/bar"
+    destination:
+      name: "first-route-dest"
+      settings:
+      - endpoints:
+        - host: "1.2.3.4"
+          port: 50000
+  - name: "second-route-ratelimit-multiple-rules"
+    hostname: "*"
+    traffic:
+      rateLimit:
+        local:
+          default:
+            requests: 10
+            unit: Minute
+          rules:
+          - headerMatches:
+            - name: x-user-id
+              exact: one
+            - name: x-org-id
+              exact: foo
+            limit:
+              requests: 10
+              unit: Hour
+          - cidrMatch:
+              cidr: 192.168.0.0/16
+              maskLen: 16
+              distinct: true
+            headerMatches:
+            - name: x-user-id
+              exact: two
+            - name: x-org-id
+              exact: bar
+            limit:
+              requests: 10
+              unit: Minute
+    pathMatch:
+      exact: "example"
+    destination:
+      name: "second-route-dest"
+      settings:
+      - endpoints:
+        - host: "1.2.3.4"
+          port: 50000
diff --git a/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.clusters.yaml
new file mode 100644
index 000000000..ba595752d
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.clusters.yaml
@@ -0,0 +1,34 @@
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig:
+    localityWeightedLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_PREFERRED
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: first-route-dest
+  ignoreHealthOnHostRemoval: true
+  lbPolicy: LEAST_REQUEST
+  name: first-route-dest
+  perConnectionBufferLimitBytes: 32768
+  type: EDS
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig:
+    localityWeightedLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_PREFERRED
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: second-route-dest
+  ignoreHealthOnHostRemoval: true
+  lbPolicy: LEAST_REQUEST
+  name: second-route-dest
+  perConnectionBufferLimitBytes: 32768
+  type: EDS
diff --git a/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.endpoints.yaml
new file mode 100644
index 000000000..997ca24ad
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.endpoints.yaml
@@ -0,0 +1,22 @@
+- clusterName: first-route-dest
+  endpoints:
+  - lbEndpoints:
+    - endpoint:
+        address:
+          socketAddress:
+            address: 1.2.3.4
+            portValue: 50000
+      loadBalancingWeight: 1
+    loadBalancingWeight: 1
+    locality: {}
+- clusterName: second-route-dest
+  endpoints:
+  - lbEndpoints:
+    - endpoint:
+        address:
+          socketAddress:
+            address: 1.2.3.4
+            portValue: 50000
+      loadBalancingWeight: 1
+    loadBalancingWeight: 1
+    locality: {}
diff --git a/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.listeners.yaml
new file mode 100644
index 000000000..4f2355a56
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.listeners.yaml
@@ -0,0 +1,39 @@
+- address:
+    socketAddress:
+      address: '::'
+      portValue: 10080
+  defaultFilterChain:
+    filters:
+    - name: envoy.filters.network.http_connection_manager
+      typedConfig:
+        '@type': type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
+        commonHttpProtocolOptions:
+          headersWithUnderscoresAction: REJECT_REQUEST
+        http2ProtocolOptions:
+          initialConnectionWindowSize: 1048576
+          initialStreamWindowSize: 65536
+          maxConcurrentStreams: 100
+        httpFilters:
+        - name: envoy.filters.http.local_ratelimit
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
+            maxDynamicDescriptors: 10000
+            statPrefix: http_local_rate_limiter
+        - name: envoy.filters.http.router
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
+            suppressEnvoyHeaders: true
+        mergeSlashes: true
+        normalizePath: true
+        pathWithEscapedSlashesAction: UNESCAPE_AND_REDIRECT
+        rds:
+          configSource:
+            ads: {}
+            resourceApiVersion: V3
+          routeConfigName: first-listener
+        serverHeaderTransformation: PASS_THROUGH
+        statPrefix: http-10080
+        useRemoteAddress: true
+    name: first-listener
+  name: first-listener
+  perConnectionBufferLimitBytes: 32768
diff --git a/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.routes.yaml
new file mode 100644
index 000000000..02f71fc1d
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit-distinct.routes.yaml
@@ -0,0 +1,123 @@
+- ignorePortInHostMatching: true
+  name: first-listener
+  virtualHosts:
+  - domains:
+    - '*'
+    name: first-listener/*
+    routes:
+    - match:
+        path: foo/bar
+      name: first-route-ratelimit-distinct-ip
+      route:
+        cluster: first-route-dest
+        rateLimits:
+        - actions:
+          - requestHeaders:
+              descriptorKey: rule-0-match-0
+              headerName: x-user-id
+        upgradeConfigs:
+        - upgradeType: websocket
+      typedPerFilterConfig:
+        envoy.filters.http.local_ratelimit:
+          '@type': type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
+          alwaysConsumeDefaultTokenBucket: false
+          descriptors:
+          - entries:
+            - key: rule-0-match-0
+            tokenBucket:
+              fillInterval: 0s
+              maxTokens: 5
+              tokensPerFill: 5
+          filterEnabled:
+            defaultValue:
+              numerator: 100
+          filterEnforced:
+            defaultValue:
+              numerator: 100
+          statPrefix: http_local_rate_limiter
+          tokenBucket:
+            fillInterval: 60s
+            maxTokens: 10
+            tokensPerFill: 10
+    - match:
+        path: example
+      name: second-route-ratelimit-multiple-rules
+      route:
+        cluster: second-route-dest
+        rateLimits:
+        - actions:
+          - headerValueMatch:
+              descriptorKey: rule-0-match-0
+              descriptorValue: rule-0-match-0
+              expectMatch: true
+              headers:
+              - name: x-user-id
+                stringMatch:
+                  exact: one
+          - headerValueMatch:
+              descriptorKey: rule-0-match-1
+              descriptorValue: rule-0-match-1
+              expectMatch: true
+              headers:
+              - name: x-org-id
+                stringMatch:
+                  exact: foo
+        - actions:
+          - headerValueMatch:
+              descriptorKey: rule-1-match-0
+              descriptorValue: rule-1-match-0
+              expectMatch: true
+              headers:
+              - name: x-user-id
+                stringMatch:
+                  exact: two
+          - headerValueMatch:
+              descriptorKey: rule-1-match-1
+              descriptorValue: rule-1-match-1
+              expectMatch: true
+              headers:
+              - name: x-org-id
+                stringMatch:
+                  exact: bar
+          - maskedRemoteAddress:
+              v4PrefixMaskLen: 16
+          - remoteAddress: {}
+        upgradeConfigs:
+        - upgradeType: websocket
+      typedPerFilterConfig:
+        envoy.filters.http.local_ratelimit:
+          '@type': type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
+          alwaysConsumeDefaultTokenBucket: false
+          descriptors:
+          - entries:
+            - key: rule-0-match-0
+              value: rule-0-match-0
+            - key: rule-0-match-1
+              value: rule-0-match-1
+            tokenBucket:
+              fillInterval: 3600s
+              maxTokens: 10
+              tokensPerFill: 10
+          - entries:
+            - key: rule-1-match-0
+              value: rule-1-match-0
+            - key: rule-1-match-1
+              value: rule-1-match-1
+            - key: masked_remote_address
+              value: 192.168.0.0/16
+            - key: remote_address
+            tokenBucket:
+              fillInterval: 60s
+              maxTokens: 10
+              tokensPerFill: 10
+          filterEnabled:
+            defaultValue:
+              numerator: 100
+          filterEnforced:
+            defaultValue:
+              numerator: 100
+          statPrefix: http_local_rate_limiter
+          tokenBucket:
+            fillInterval: 60s
+            maxTokens: 10
+            tokensPerFill: 10
diff --git a/internal/xds/translator/testdata/out/xds-ir/local-ratelimit.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit.listeners.yaml
index aff3d89b5..4f2355a56 100644
--- a/internal/xds/translator/testdata/out/xds-ir/local-ratelimit.listeners.yaml
+++ b/internal/xds/translator/testdata/out/xds-ir/local-ratelimit.listeners.yaml
@@ -17,6 +17,7 @@
         - name: envoy.filters.http.local_ratelimit
           typedConfig:
             '@type': type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
+            maxDynamicDescriptors: 10000
             statPrefix: http_local_rate_limiter
         - name: envoy.filters.http.router
           typedConfig:
diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md
index 729a011ee..1134cf454 100644
--- a/site/content/en/latest/api/extension_types.md
+++ b/site/content/en/latest/api/extension_types.md
@@ -2251,7 +2251,7 @@ _Appears in:_
 | ----- | ----------- |
 | `Exact` | HeaderMatchExact matches the exact value of the Value field against the value of<br />the specified HTTP Header.<br /> | 
 | `RegularExpression` | HeaderMatchRegularExpression matches a regular expression against the value of the<br />specified HTTP Header. The regex string must adhere to the syntax documented in<br />https://github.com/google/re2/wiki/Syntax.<br /> | 
-| `Distinct` | HeaderMatchDistinct matches any and all possible unique values encountered in the<br />specified HTTP Header. Note that each unique value will receive its own rate limit<br />bucket.<br />Note: This is only supported for Global Rate Limits.<br /> | 
+| `Distinct` | HeaderMatchDistinct matches any and all possible unique values encountered in the<br />specified HTTP Header. Note that each unique value will receive its own rate limit<br />bucket.<br /> | 
 
 
 #### HeaderSettings
@@ -4151,7 +4151,7 @@ _Appears in:_
 | Value | Description |
 | ----- | ----------- |
 | `Exact` | SourceMatchExact All IP Addresses within the specified Source IP CIDR are treated as a single client selector<br />and share the same rate limit bucket.<br /> | 
-| `Distinct` | SourceMatchDistinct Each IP Address within the specified Source IP CIDR is treated as a distinct client selector<br />and uses a separate rate limit bucket/counter.<br />Note: This is only supported for Global Rate Limits.<br /> | 
+| `Distinct` | SourceMatchDistinct Each IP Address within the specified Source IP CIDR is treated as a distinct client selector<br />and uses a separate rate limit bucket/counter.<br /> | 
 
 
 #### StatefulTLSSessionResumption
diff --git a/test/e2e/testdata/local-ratelimit-distinct-cidr.yaml b/test/e2e/testdata/local-ratelimit-distinct-cidr.yaml
new file mode 100644
index 000000000..1469f0f23
--- /dev/null
+++ b/test/e2e/testdata/local-ratelimit-distinct-cidr.yaml
@@ -0,0 +1,102 @@
+# This is a client traffic policy that enables client IP detection using the XFF header.
+# So, the client IP can be detected from the XFF header and used for rate limiting.
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: ClientTrafficPolicy
+metadata:
+  name: enable-client-ip-detection-trusted-cidr
+  namespace: gateway-conformance-infra
+spec:
+  clientIPDetection:
+    xForwardedFor:
+      numTrustedHops: 1
+  targetRefs:
+    - group: gateway.networking.k8s.io
+      kind: Gateway
+      name: same-namespace
+---
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: BackendTrafficPolicy
+metadata:
+  name: ratelimit-distinct-cidr
+  namespace: gateway-conformance-infra
+spec:
+  targetRefs:
+    - group: gateway.networking.k8s.io
+      kind: HTTPRoute
+      name: http-ratelimit-distinct-cidr
+  rateLimit:
+    type: Local
+    local:
+      rules:
+        - limit:
+            requests: 10
+            unit: Hour
+        - clientSelectors:
+            - sourceCIDR:
+                value: 0.0.0.0/0
+                type: Distinct
+          limit:
+            requests: 3
+            unit: Hour
+---
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: BackendTrafficPolicy
+metadata:
+  name: ratelimit-distinct-cidr-and-exact-header
+  namespace: gateway-conformance-infra
+spec:
+  targetRefs:
+    - group: gateway.networking.k8s.io
+      kind: HTTPRoute
+      name: http-ratelimit-distinct-cidr-and-exact-header
+  rateLimit:
+    type: Local
+    local:
+      rules:
+        - limit:
+            requests: 10
+            unit: Hour
+        - clientSelectors:
+            - sourceCIDR:
+                value: 0.0.0.0/0
+                type: Distinct
+            - headers:
+                - name: x-org-id
+                  value: foo
+          limit:
+            requests: 3
+            unit: Hour
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: http-ratelimit-distinct-cidr
+  namespace: gateway-conformance-infra
+spec:
+  parentRefs:
+    - name: same-namespace
+  rules:
+    - backendRefs:
+        - name: infra-backend-v1
+          port: 8080
+      matches:
+        - path:
+            type: Exact
+            value: /ratelimit-distinct-cidr
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: http-ratelimit-distinct-cidr-and-exact-header
+  namespace: gateway-conformance-infra
+spec:
+  parentRefs:
+    - name: same-namespace
+  rules:
+    - backendRefs:
+        - name: infra-backend-v1
+          port: 8080
+      matches:
+        - path:
+            type: Exact
+            value: /ratelimit-distinct-cidr-and-exact-header
diff --git a/test/e2e/testdata/local-ratelimit-distinct-header.yaml b/test/e2e/testdata/local-ratelimit-distinct-header.yaml
new file mode 100644
index 000000000..47ffee00a
--- /dev/null
+++ b/test/e2e/testdata/local-ratelimit-distinct-header.yaml
@@ -0,0 +1,85 @@
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: BackendTrafficPolicy
+metadata:
+  name: ratelimit-distinct-header
+  namespace: gateway-conformance-infra
+spec:
+  targetRefs:
+  - group: gateway.networking.k8s.io
+    kind: HTTPRoute
+    name: http-ratelimit-distinct-header
+  rateLimit:
+    type: Local
+    local:
+      rules:
+      - limit:
+          requests: 10
+          unit: Hour
+      - clientSelectors:
+        - headers:
+          - name: x-user-id
+            type: Distinct
+        limit:
+          requests: 3
+          unit: Hour
+---
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: BackendTrafficPolicy
+metadata:
+  name: ratelimit-distinct-header-and-exact-header
+  namespace: gateway-conformance-infra
+spec:
+  targetRefs:
+  - group: gateway.networking.k8s.io
+    kind: HTTPRoute
+    name: http-ratelimit-distinct-header-and-exact-header
+  rateLimit:
+    type: Local
+    local:
+      rules:
+      - limit:
+          requests: 10
+          unit: Hour
+      - clientSelectors:
+        - headers:
+          - name: x-user-id
+            type: Distinct
+          - name: x-org-id
+            value: foo
+        limit:
+          requests: 3
+          unit: Hour
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: http-ratelimit-distinct-header
+  namespace: gateway-conformance-infra
+spec:
+  parentRefs:
+  - name: same-namespace
+  rules:
+  - backendRefs:
+    - name: infra-backend-v1
+      port: 8080
+    matches:
+    - path:
+        type: Exact
+        value: /ratelimit-distinct-header
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: http-ratelimit-distinct-header-and-exact-header
+  namespace: gateway-conformance-infra
+spec:
+  parentRefs:
+  - name: same-namespace
+  rules:
+  - backendRefs:
+    - name: infra-backend-v1
+      port: 8080
+    matches:
+    - path:
+        type: Exact
+        value: /ratelimit-distinct-header-and-exact-header
diff --git a/test/e2e/tests/local_ratelimit_distinct_cidr.go b/test/e2e/tests/local_ratelimit_distinct_cidr.go
new file mode 100644
index 000000000..b82650fd6
--- /dev/null
+++ b/test/e2e/tests/local_ratelimit_distinct_cidr.go
@@ -0,0 +1,136 @@
+// Copyright Envoy Gateway Authors
+// SPDX-License-Identifier: Apache-2.0
+// The full text of the Apache license is available in the LICENSE file at
+// the root of the repo.
+
+//go:build e2e
+
+package tests
+
+import (
+	"testing"
+
+	"k8s.io/apimachinery/pkg/types"
+	gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
+	gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
+	"sigs.k8s.io/gateway-api/conformance/utils/http"
+	"sigs.k8s.io/gateway-api/conformance/utils/kubernetes"
+	"sigs.k8s.io/gateway-api/conformance/utils/suite"
+
+	"github.com/envoyproxy/gateway/internal/gatewayapi"
+	"github.com/envoyproxy/gateway/internal/gatewayapi/resource"
+)
+
+func init() {
+	ConformanceTests = append(ConformanceTests, LocalRateLimitDistinctCIDRTest)
+}
+
+var LocalRateLimitDistinctCIDRTest = suite.ConformanceTest{
+	ShortName:   "LocalRateLimitDistinctCIDR",
+	Description: "Test that local rate limit filter works with distinct cidr",
+	Manifests:   []string{"testdata/local-ratelimit-distinct-cidr.yaml"},
+	Test: func(t *testing.T, suite *suite.ConformanceTestSuite) {
+		ns := "gateway-conformance-infra"
+		routeNN := types.NamespacedName{Name: "http-ratelimit-distinct-cidr", Namespace: ns}
+		gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns}
+		gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN)
+
+		t.Run("requests with x-forwarded-for header should be limited per IP", func(t *testing.T) {
+			ancestorRef := gwapiv1a2.ParentReference{
+				Group:     gatewayapi.GroupPtr(gwapiv1.GroupName),
+				Kind:      gatewayapi.KindPtr(resource.KindGateway),
+				Namespace: gatewayapi.NamespacePtr(gwNN.Namespace),
+				Name:      gwapiv1.ObjectName(gwNN.Name),
+			}
+			BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "ratelimit-distinct-cidr", Namespace: ns}, suite.ControllerName, ancestorRef)
+			path := "/ratelimit-distinct-cidr"
+			testDistinctCIDRRatelimit(t, "192.168.1.1", "", ns, gwAddr, path, true, suite)
+			testDistinctCIDRRatelimit(t, "192.168.1.2", "", ns, gwAddr, path, true, suite)
+		})
+
+		t.Run("requests with x-forwarded-for header and matching x-org-id header should be limited per IP", func(t *testing.T) {
+			ancestorRef := gwapiv1a2.ParentReference{
+				Group:     gatewayapi.GroupPtr(gwapiv1.GroupName),
+				Kind:      gatewayapi.KindPtr(resource.KindGateway),
+				Namespace: gatewayapi.NamespacePtr(gwNN.Namespace),
+				Name:      gwapiv1.ObjectName(gwNN.Name),
+			}
+			BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "ratelimit-distinct-cidr-and-exact-header", Namespace: ns}, suite.ControllerName, ancestorRef)
+			path := "/ratelimit-distinct-cidr-and-exact-header"
+			testDistinctCIDRRatelimit(t, "192.168.1.1", "foo", ns, gwAddr, path, true, suite)
+			testDistinctCIDRRatelimit(t, "192.168.1.2", "foo", ns, gwAddr, path, true, suite)
+		})
+
+		t.Run("requests with with x-forwarded-for header but no matching x-org-id header should not be limited", func(t *testing.T) {
+			ancestorRef := gwapiv1a2.ParentReference{
+				Group:     gatewayapi.GroupPtr(gwapiv1.GroupName),
+				Kind:      gatewayapi.KindPtr(resource.KindGateway),
+				Namespace: gatewayapi.NamespacePtr(gwNN.Namespace),
+				Name:      gwapiv1.ObjectName(gwNN.Name),
+			}
+			BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "ratelimit-distinct-cidr-and-exact-header", Namespace: ns}, suite.ControllerName, ancestorRef)
+			path := "/ratelimit-distinct-cidr-and-exact-header"
+			testDistinctCIDRRatelimit(t, "192.168.1.1", "bar", ns, gwAddr, path, false, suite)
+		})
+	},
+}
+
+func testDistinctCIDRRatelimit(t *testing.T, clientIP, org, ns, gwAddr, path string, limited bool, suite *suite.ConformanceTestSuite) {
+	expectOkResp := http.ExpectedResponse{
+		Request: http.Request{
+			Path: path,
+			Headers: map[string]string{
+				"X-Forwarded-For": clientIP,
+				"x-org-id":        org,
+			},
+		},
+		ExpectedRequest: &http.ExpectedRequest{
+			Request: http.Request{
+				Path:    path,
+				Headers: nil, // don't check headers since Envoy will append the client IP to the X-Forwarded-For header
+			},
+		},
+		Response: http.Response{
+			StatusCode: 200,
+		},
+		Namespace: ns,
+	}
+
+	expectOkReq := http.MakeRequest(t, &expectOkResp, gwAddr, "HTTP", "http")
+
+	expectLimitResp := http.ExpectedResponse{
+		Request: http.Request{
+			Path: path,
+			Headers: map[string]string{
+				"X-Forwarded-For": clientIP,
+				"x-org-id":        org,
+			},
+		},
+		Response: http.Response{
+			StatusCode: 429,
+		},
+		Namespace: ns,
+	}
+	expectLimitReq := http.MakeRequest(t, &expectLimitResp, gwAddr, "HTTP", "http")
+
+	// should just send exactly 4 requests, and expect 429
+
+	// keep sending requests till get 200 first, that will cost one 200
+	http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expectOkResp)
+
+	// fire the rest request
+	if err := GotExactExpectedResponse(t, 2, suite.RoundTripper, expectOkReq, expectOkResp); err != nil {
+		t.Errorf("fail to get expected response at first three request: %v", err)
+	}
+
+	if limited {
+		// this request should be limited because the limit is 3
+		if err := GotExactExpectedResponse(t, 1, suite.RoundTripper, expectLimitReq, expectLimitResp); err != nil {
+			t.Errorf("fail to get expected response at the fourth request: %v", err)
+		}
+	} else {
+		if err := GotExactExpectedResponse(t, 1, suite.RoundTripper, expectLimitReq, expectOkResp); err != nil {
+			t.Errorf("fail to get expected response at the fourth request: %v", err)
+		}
+	}
+}
diff --git a/test/e2e/tests/local_ratelimit_distinct_header.go b/test/e2e/tests/local_ratelimit_distinct_header.go
new file mode 100644
index 000000000..2c032c655
--- /dev/null
+++ b/test/e2e/tests/local_ratelimit_distinct_header.go
@@ -0,0 +1,130 @@
+// Copyright Envoy Gateway Authors
+// SPDX-License-Identifier: Apache-2.0
+// The full text of the Apache license is available in the LICENSE file at
+// the root of the repo.
+
+//go:build e2e
+
+package tests
+
+import (
+	"testing"
+
+	"k8s.io/apimachinery/pkg/types"
+	gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
+	gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
+	"sigs.k8s.io/gateway-api/conformance/utils/http"
+	"sigs.k8s.io/gateway-api/conformance/utils/kubernetes"
+	"sigs.k8s.io/gateway-api/conformance/utils/suite"
+
+	"github.com/envoyproxy/gateway/internal/gatewayapi"
+	"github.com/envoyproxy/gateway/internal/gatewayapi/resource"
+)
+
+func init() {
+	ConformanceTests = append(ConformanceTests, LocalRateLimitDistinctHeaderTest)
+}
+
+var LocalRateLimitDistinctHeaderTest = suite.ConformanceTest{
+	ShortName:   "LocalRateLimitDistinctHeader",
+	Description: "Test that local rate limit filter works with distinct header",
+	Manifests:   []string{"testdata/local-ratelimit-distinct-header.yaml"},
+	Test: func(t *testing.T, suite *suite.ConformanceTestSuite) {
+		ns := "gateway-conformance-infra"
+		routeNN := types.NamespacedName{Name: "http-ratelimit-distinct-header", Namespace: ns}
+		gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns}
+		gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN)
+
+		t.Run("requests with x-user-id header should be limited per user", func(t *testing.T) {
+			ancestorRef := gwapiv1a2.ParentReference{
+				Group:     gatewayapi.GroupPtr(gwapiv1.GroupName),
+				Kind:      gatewayapi.KindPtr(resource.KindGateway),
+				Namespace: gatewayapi.NamespacePtr(gwNN.Namespace),
+				Name:      gwapiv1.ObjectName(gwNN.Name),
+			}
+			BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "ratelimit-distinct-header", Namespace: ns}, suite.ControllerName, ancestorRef)
+			path := "/ratelimit-distinct-header"
+			testDistinctHeaderRatelimit(t, "john", "", ns, gwAddr, path, true, suite)
+			testDistinctHeaderRatelimit(t, "alice", "", ns, gwAddr, path, true, suite)
+		})
+
+		t.Run("requests with x-user-id header and matching x-org-id header should be limited per user", func(t *testing.T) {
+			ancestorRef := gwapiv1a2.ParentReference{
+				Group:     gatewayapi.GroupPtr(gwapiv1.GroupName),
+				Kind:      gatewayapi.KindPtr(resource.KindGateway),
+				Namespace: gatewayapi.NamespacePtr(gwNN.Namespace),
+				Name:      gwapiv1.ObjectName(gwNN.Name),
+			}
+			BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "ratelimit-distinct-header-and-exact-header", Namespace: ns}, suite.ControllerName, ancestorRef)
+			path := "/ratelimit-distinct-header-and-exact-header"
+			testDistinctHeaderRatelimit(t, "john", "foo", ns, gwAddr, path, true, suite)
+			testDistinctHeaderRatelimit(t, "alice", "foo", ns, gwAddr, path, true, suite)
+		})
+
+		t.Run("requests with x-user-id header but no matching x-org-id header should not be limited", func(t *testing.T) {
+			ancestorRef := gwapiv1a2.ParentReference{
+				Group:     gatewayapi.GroupPtr(gwapiv1.GroupName),
+				Kind:      gatewayapi.KindPtr(resource.KindGateway),
+				Namespace: gatewayapi.NamespacePtr(gwNN.Namespace),
+				Name:      gwapiv1.ObjectName(gwNN.Name),
+			}
+			BackendTrafficPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "ratelimit-distinct-header-and-exact-header", Namespace: ns}, suite.ControllerName, ancestorRef)
+			path := "/ratelimit-distinct-header-and-exact-header"
+			testDistinctHeaderRatelimit(t, "john", "bar", ns, gwAddr, path, false, suite)
+		})
+	},
+}
+
+func testDistinctHeaderRatelimit(t *testing.T, user, org, ns, gwAddr, path string, limited bool, suite *suite.ConformanceTestSuite) {
+	expectOkResp := http.ExpectedResponse{
+		Request: http.Request{
+			Path: path,
+			Headers: map[string]string{
+				"x-user-id": user,
+				"x-org-id":  org,
+			},
+		},
+		Response: http.Response{
+			StatusCode: 200,
+		},
+		Namespace: ns,
+	}
+
+	expectOkReq := http.MakeRequest(t, &expectOkResp, gwAddr, "HTTP", "http")
+
+	expectLimitResp := http.ExpectedResponse{
+		Request: http.Request{
+			Path: path,
+			Headers: map[string]string{
+				"x-user-id": user,
+				"x-org-id":  org,
+			},
+		},
+		Response: http.Response{
+			StatusCode: 429,
+		},
+		Namespace: ns,
+	}
+	expectLimitReq := http.MakeRequest(t, &expectLimitResp, gwAddr, "HTTP", "http")
+
+	// should just send exactly 4 requests, and expect 429
+
+	// keep sending requests till get 200 first, that will cost one 200
+	http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expectOkResp)
+
+	// fire the rest request
+	if err := GotExactExpectedResponse(t, 2, suite.RoundTripper, expectOkReq, expectOkResp); err != nil {
+		t.Errorf("fail to get expected response at first three request: %v", err)
+	}
+
+	if limited {
+		// this request should be limited because the limit is 3
+		if err := GotExactExpectedResponse(t, 1, suite.RoundTripper, expectLimitReq, expectLimitResp); err != nil {
+			t.Errorf("fail to get expected response at the fourth request: %v", err)
+		}
+	} else {
+		if err := GotExactExpectedResponse(t, 1, suite.RoundTripper, expectLimitReq, expectOkResp); err != nil {
+			t.Errorf("fail to get expected response at the fourth request: %v", err)
+		}
+	}
+}