Repository: envoyproxy/gateway
Pull request #5319 (2025-02-19 14:00:02)

feat: distinct match support for local ratelimit
This PR adds distinct header match and distinct source CIDR match to local ratelimit. Both are verified with e2e tests.

implement: #5315 

Release Notes: Yes


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/envoyproxy/gateway/pull/5319?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) Report
Attention: Patch coverage is `95.00000%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 65.05%. Comparing base [(`3e08722`)](https://app.codecov.io/gh/envoyproxy/gateway/commit/3e0872290dd9f76e6df598e899cac45ee9b19c12?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) to head [(`c7964a2`)](https://app.codecov.io/gh/envoyproxy/gateway/commit/c7964a2eb99ee2e9bfa780df2e7be4bf80922c79?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy).
> Report is 1 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/envoyproxy/gateway/pull/5319?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) | Patch % | Lines |
|---|---|---|
| [internal/xds/translator/local\_ratelimit.go](https://app.codecov.io/gh/envoyproxy/gateway/pull/5319?src=pr&el=tree&filepath=internal%2Fxds%2Ftranslator%2Flocal_ratelimit.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy#diff-aW50ZXJuYWwveGRzL3RyYW5zbGF0b3IvbG9jYWxfcmF0ZWxpbWl0Lmdv) | 95.00% | [2 Missing and 1 partial :warning: ](https://app.codecov.io/gh/envoyproxy/gateway/pull/5319?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #5319      +/-   ##
==========================================
+ Coverage   64.94%   65.05%   +0.11%     
==========================================
  Files         214      214              
  Lines       33532    33546      +14     
==========================================
+ Hits        21776    21824      +48     
+ Misses      10412    10387      -25     
+ Partials     1344     1335       -9     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/envoyproxy/gateway/pull/5319?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy).

arkodg: thanks @zhaohuabing ! this looks good
we'll also need to edit max_dynamic_descriptors, the default of 20 is too low, and later we can could consider a API for it
https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/local_ratelimit/v3/local_rate_limit.proto
zhaohuabing: > thanks @zhaohuabing ! this looks good
> we'll also need to edit max_dynamic_descriptors, the default of 20 is too low, and later we can could consider a API for it
> https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/local_ratelimit/v3/local_rate_limit.proto

Setting a default value is tricky because "Actual number of dynamic descriptors will depend on the cardinality of unique values received from the http request for the omitted values", which is unpredictable. 

Would 100 be reasonable default for a listener?
arkodg: > > thanks @zhaohuabing ! this looks good
> > we'll also need to edit max_dynamic_descriptors, the default of 20 is too low, and later we can could consider a API for it
> > https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/local_ratelimit/v3/local_rate_limit.proto
> 
> Setting a default value is tricky because "Actual number of dynamic descriptors will depend on the cardinality of unique values received from the http request for the omitted values", which is unpredictable.
> 
> Would 100 be reasonable default for a listener?

I was thinking 10K, assuming at any given time, the route had 10K unique active users which need to be ratelimited
wdyt @wbpcode @vikaschoudhary16 
zhaohuabing: What happens if the cache limit is reached?  The oldest value will be removed and the limit for that value may not be accurate?
vikaschoudhary16: Yeah, let's say the one which got removed, LRU algo, had its token 50% consumed. Now if again a request comes, new token bucket will be created and thus tokens will get refreshed unexpectedly

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)
+		}
+	}
+}