Repository: envoyproxy/gateway

Issue:
Add cacheDuration and asyncFetch support for remoteJWKS in SecurityPolicy
*Description*:
Envoy Gateway currently lacks support for configuring the JWKS cache duration (`cache_duration`) and asynchronous background refresh (`async_fetch`) as provided in the underlying Envoy JWT Authentication filter.

This limits control over JWKS caching behavior, particularly in key rollover scenarios where tokens signed with a new kid fail JWT validation due to stale JWKS cache.


## Proposed Solution
Extend the Envoy Gateway CRD for SecurityPolicy (or JWTProvider) to allow the following fields under remoteJWKS:

```yaml
jwt:
  providers:
    - name: issuer-name
      remoteJWKS:
        uri: https://issuer.example.com/.well-known/jwks.json
        cacheDuration: 60s     # new field: JWKS TTL
        asyncFetch: true         # new field: enable async background refresh
```

Hints from issue comments:
@arkodg: +1 modify the cacheDuration default of 300s
https://github.com/envoyproxy/gateway/blob/64f3576cffa892acdf3af396b51b29b7a28cd4cc/internal/xds/translator/jwt.go#L161

is this a rotation use case ( 2 `kid`s valid for a short duration) or revocation case ? @arkodg: sidenote - this doesnt really resolve https://github.com/envoyproxy/gateway/issues/4180, just reduces the downtime, but at the cost of frequent fetches@slayer321: Hey @arkodg , I did like to work on this issue. Can you please assign it to me.@arkodg: sure thanks@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.

Solution patch:
diff --git a/api/v1alpha1/jwt_types.go b/api/v1alpha1/jwt_types.go
index 2666d56d0..823192ad4 100644
--- a/api/v1alpha1/jwt_types.go
+++ b/api/v1alpha1/jwt_types.go
@@ -5,7 +5,9 @@
 
 package v1alpha1
 
-import gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
+import (
+	gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
+)
 
 // JWT defines the configuration for JSON Web Token (JWT) authentication.
 type JWT struct {
@@ -108,6 +110,11 @@ type RemoteJWKS struct {
 	// +kubebuilder:validation:MinLength=1
 	// +kubebuilder:validation:MaxLength=253
 	URI string `json:"uri"`
+	// Duration after which the cached JWKS should be expired. If not specified, default cache duration is 5 minutes.
+
+	// +kubebuilder:default="300s"
+	// +optional
+	CacheDuration *gwapiv1.Duration `json:"cacheDuration,omitempty"`
 }
 
 // LocalJWKSType defines the types of values for Local JWKS.
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 866269c9a..9884d1df7 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -6035,6 +6035,11 @@ func (in *RedisTLSSettings) DeepCopy() *RedisTLSSettings {
 func (in *RemoteJWKS) DeepCopyInto(out *RemoteJWKS) {
 	*out = *in
 	in.BackendCluster.DeepCopyInto(&out.BackendCluster)
+	if in.CacheDuration != nil {
+		in, out := &in.CacheDuration, &out.CacheDuration
+		*out = new(v1.Duration)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoteJWKS.
diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml
index 75f89017c..ed62618c3 100644
--- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml
+++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_securitypolicies.yaml
@@ -3761,6 +3761,13 @@ spec:
                                       type: object
                                   type: object
                               type: object
+                            cacheDuration:
+                              default: 300s
+                              description: |-
+                                Duration is a string value representing a duration in time. The format is as specified
+                                in GEP-2257, a strict subset of the syntax parsed by Golang time.ParseDuration.
+                              pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$
+                              type: string
                             uri:
                               description: |-
                                 URI is the HTTPS URI to fetch the JWKS. Envoy's system trust bundle is used to validate the server certificate.
diff --git a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml
index 5162de0c3..2a5654e44 100644
--- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml
+++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml
@@ -3760,6 +3760,13 @@ spec:
                                       type: object
                                   type: object
                               type: object
+                            cacheDuration:
+                              default: 300s
+                              description: |-
+                                Duration is a string value representing a duration in time. The format is as specified
+                                in GEP-2257, a strict subset of the syntax parsed by Golang time.ParseDuration.
+                              pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$
+                              type: string
                             uri:
                               description: |-
                                 URI is the HTTPS URI to fetch the JWKS. Envoy's system trust bundle is used to validate the server certificate.
diff --git a/examples/kubernetes/jwt/jwt.yaml b/examples/kubernetes/jwt/jwt.yaml
index 46381b13a..a5ba7f4c1 100644
--- a/examples/kubernetes/jwt/jwt.yaml
+++ b/examples/kubernetes/jwt/jwt.yaml
@@ -12,6 +12,7 @@ spec:
     - name: example
       remoteJWKS:
         uri: https://raw.githubusercontent.com/envoyproxy/gateway/main/examples/kubernetes/jwt/jwks.json
+        cacheDuration: 60s
 ---
 apiVersion: gateway.networking.k8s.io/v1
 kind: HTTPRoute
diff --git a/internal/gatewayapi/luavalidator/lua_validator.go b/internal/gatewayapi/luavalidator/lua_validator.go
index 1c103ee17..2410349e0 100644
--- a/internal/gatewayapi/luavalidator/lua_validator.go
+++ b/internal/gatewayapi/luavalidator/lua_validator.go
@@ -24,7 +24,7 @@ const (
 // Refer: https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/lua_filter#stream-handle-api
 //
 //go:embed mocks.lua
-var mockData []byte
+var mockData string
 
 // LuaValidator validates user provided Lua for compatibility with Envoy supported Lua HTTP filter
 // Validation strictness is controlled by the validation field
@@ -47,12 +47,12 @@ func (l *LuaValidator) Validate() error {
 		return fmt.Errorf("expected one of %s() or %s() to be defined", envoyOnRequestFunctionName, envoyOnResponseFunctionName)
 	}
 	if strings.Contains(l.code, envoyOnRequestFunctionName) {
-		if err := l.validate(string(mockData) + "\n" + l.code + "\n" + envoyOnRequestFunctionName + "(StreamHandle)"); err != nil {
+		if err := l.validate(mockData + "\n" + l.code + "\n" + envoyOnRequestFunctionName + "(StreamHandle)"); err != nil {
 			return fmt.Errorf("failed to validate with %s: %w", envoyOnRequestFunctionName, err)
 		}
 	}
 	if strings.Contains(l.code, envoyOnResponseFunctionName) {
-		if err := l.validate(string(mockData) + "\n" + l.code + "\n" + envoyOnResponseFunctionName + "(StreamHandle)"); err != nil {
+		if err := l.validate(mockData + "\n" + l.code + "\n" + envoyOnResponseFunctionName + "(StreamHandle)"); err != nil {
 			return fmt.Errorf("failed to validate with %s: %w", envoyOnResponseFunctionName, err)
 		}
 	}
diff --git a/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go
index b2930b270..f12709d57 100644
--- a/internal/gatewayapi/securitypolicy.go
+++ b/internal/gatewayapi/securitypolicy.go
@@ -1007,10 +1007,11 @@ func (t *Translator) buildRemoteJWKS(
 	envoyProxy *egv1a1.EnvoyProxy,
 ) (*ir.RemoteJWKS, error) {
 	var (
-		protocol ir.AppProtocol
-		rd       *ir.RouteDestination
-		traffic  *ir.TrafficFeatures
-		err      error
+		protocol      ir.AppProtocol
+		rd            *ir.RouteDestination
+		traffic       *ir.TrafficFeatures
+		err           error
+		cacheDuration *metav1.Duration
 	)
 
 	u, err := url.Parse(remoteJWKS.URI)
@@ -1037,10 +1038,19 @@ func (t *Translator) buildRemoteJWKS(
 		}
 	}
 
+	if remoteJWKS.CacheDuration != nil {
+		d, err := time.ParseDuration(string(*remoteJWKS.CacheDuration))
+		if err != nil {
+			return nil, err
+		}
+		cacheDuration = ir.MetaV1DurationPtr(d)
+	}
+
 	return &ir.RemoteJWKS{
-		Destination: rd,
-		Traffic:     traffic,
-		URI:         remoteJWKS.URI,
+		Destination:   rd,
+		Traffic:       traffic,
+		URI:           remoteJWKS.URI,
+		CacheDuration: cacheDuration,
 	}, nil
 }
 
diff --git a/internal/ir/xds.go b/internal/ir/xds.go
index da5b2510e..bd333839e 100644
--- a/internal/ir/xds.go
+++ b/internal/ir/xds.go
@@ -1077,6 +1077,9 @@ type RemoteJWKS struct {
 	// URI is the HTTPS URI to fetch the JWKS. Envoy's system trust bundle is used to validate the server certificate.
 	// If a custom trust bundle is needed, it can be specified in a BackendTLSConfig resource and target the BackendRefs.
 	URI string `json:"uri"`
+
+	// Duration after which the cached JWKS should be expired. If not specified, default cache duration is 5 minutes.
+	CacheDuration *metav1.Duration `json:"cacheDuration,omitempty"`
 }
 
 // OIDC defines the schema for authenticating HTTP requests using
diff --git a/internal/ir/zz_generated.deepcopy.go b/internal/ir/zz_generated.deepcopy.go
index dadc7e7b7..5ae55046a 100644
--- a/internal/ir/zz_generated.deepcopy.go
+++ b/internal/ir/zz_generated.deepcopy.go
@@ -3140,6 +3140,11 @@ func (in *RemoteJWKS) DeepCopyInto(out *RemoteJWKS) {
 		*out = new(TrafficFeatures)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.CacheDuration != nil {
+		in, out := &in.CacheDuration, &out.CacheDuration
+		*out = new(v1.Duration)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoteJWKS.
diff --git a/internal/provider/kubernetes/controller.go b/internal/provider/kubernetes/controller.go
index 8d767f23c..9dc537c2e 100644
--- a/internal/provider/kubernetes/controller.go
+++ b/internal/provider/kubernetes/controller.go
@@ -298,18 +298,18 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 		gwcResource.GatewayClass = managedGC
 
 		gwcResourceMapping := newResourceMapping()
-
+		logger := r.log.WithValues("GatewayClass", managedGC.Name)
 		// Process the parametersRef of the accepted GatewayClass.
 		// This should run before processGateways and processBackendRefs
 		failToProcessGCParamsRef := false
 		if managedGC.Spec.ParametersRef != nil && managedGC.DeletionTimestamp == nil {
 			if err := r.processGatewayClassParamsRef(ctx, managedGC, gwcResourceMapping, gwcResource); err != nil {
 				if isTransientError(err) {
-					r.log.Error(err, "transient error processing GatewayClass parametersRef", "gatewayClass", managedGC.Name)
+					logger.Error(err, "transient error processing parametersRef for GatewayClass")
 					return reconcile.Result{}, err
 				}
 
-				r.log.Error(err, fmt.Sprintf("failed processGatewayClassParamsRef for gatewayClass %s", managedGC.Name))
+				logger.Error(err, "failed to process ParametersRef for GatewayClass")
 				msg := fmt.Sprintf("%s: %v", status.MsgGatewayClassInvalidParams, err)
 				gc := status.SetGatewayClassAccepted(
 					managedGC.DeepCopy(),
@@ -328,11 +328,11 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 		// process envoy gateway secret refs
 		if err := r.processEnvoyProxySecretRef(ctx, gwcResource); err != nil {
 			if isTransientError(err) {
-				r.log.Error(err, "transient error processing TLS SecretRef for EnvoyProxy", "gatewayClass", managedGC.Name)
+				logger.Error(err, "transient error processing TLS SecretRef for EnvoyProxy")
 				return reconcile.Result{}, err
 			}
 
-			r.log.Error(err, fmt.Sprintf("failed process TLS SecretRef for EnvoyProxy for gatewayClass %s", managedGC.Name))
+			r.log.Error(err, "failed to process TLS SecretRef for EnvoyProxy for GatewayClass")
 			gc := status.SetGatewayClassAccepted(
 				managedGC.DeepCopy(),
 				false,
@@ -348,6 +348,7 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 
 		if !failToProcessGCParamsRef {
 			// GatewayClass is valid so far, mark it as accepted.
+			logger.V(6).Info("Set GatewayClass Accepted")
 			gc := status.SetGatewayClassAccepted(
 				managedGC.DeepCopy(),
 				true,
@@ -362,38 +363,38 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 		// add the OIDC HMAC Secret to the resourceTree
 		if err = r.processOIDCHMACSecret(ctx, gwcResource, gwcResourceMapping); err != nil {
 			if isTransientError(err) {
-				r.log.Error(err, "transient error processing OIDC HMAC Secret", "gatewayClass", managedGC.Name)
+				logger.Error(err, "transient error processing OIDC HMAC Secret")
 				return reconcile.Result{}, err
 			}
-			r.log.Error(err, fmt.Sprintf("failed processOIDCHMACSecret for gatewayClass %s", managedGC.Name))
+			logger.Error(err, "failed to process OIDC HMAC Secret for GatewayClass")
 		}
 
 		// add the Envoy TLS Secret to the resourceTree
 		if err = r.processEnvoyTLSSecret(ctx, gwcResource, gwcResourceMapping); err != nil {
 			if isTransientError(err) {
-				r.log.Error(err, "transient error processing Envoy TLS Secret", "gatewayClass", managedGC.Name)
+				logger.Error(err, "transient error processing Envoy TLS Secret")
 				return reconcile.Result{}, err
 			}
-			r.log.Error(err, fmt.Sprintf("failed processEnvoyTLSSecret for gatewayClass %s", managedGC.Name))
+			logger.Error(err, "failed to process EnvoyTLSSecret")
 		}
 
 		// Add all Gateways, their associated Routes, and referenced resources to the resourceTree
 		if err = r.processGateways(ctx, managedGC, gwcResourceMapping, gwcResource); err != nil {
 			if isTransientError(err) {
-				r.log.Error(err, "transient error processing gateways", "gatewayClass", managedGC.Name)
+				logger.Error(err, "transient error processing gateways")
 				return reconcile.Result{}, err
 			}
-			r.log.Error(err, fmt.Sprintf("failed processGateways for gatewayClass %s", managedGC.Name))
+			logger.Error(err, "failed process gateways for GatewayClass")
 		}
 
 		if r.eppCRDExists {
 			// Add all EnvoyPatchPolicies to the resourceTree
 			if err = r.processEnvoyPatchPolicies(ctx, gwcResource, gwcResourceMapping); err != nil {
 				if isTransientError(err) {
-					r.log.Error(err, "transient error processing EnvoyPatchPolicies", "gatewayClass", managedGC.Name)
+					logger.Error(err, "transient error processing EnvoyPatchPolicies")
 					return reconcile.Result{}, err
 				}
-				r.log.Error(err, fmt.Sprintf("failed processEnvoyPatchPolicies for gatewayClass %s", managedGC.Name))
+				logger.Error(err, "failed to process EnvoyPatchPolicies for GatewayClass")
 			}
 		}
 
@@ -401,10 +402,10 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 			// Add all ClientTrafficPolicies and their referenced resources to the resourceTree
 			if err = r.processClientTrafficPolicies(ctx, gwcResource, gwcResourceMapping); err != nil {
 				if isTransientError(err) {
-					r.log.Error(err, "transient error processing ClientTrafficPolicies", "gatewayClass", managedGC.Name)
+					logger.Error(err, "transient error processing ClientTrafficPolicies")
 					return reconcile.Result{}, err
 				}
-				r.log.Error(err, fmt.Sprintf("failed processClientTrafficPolicies for gatewayClass %s", managedGC.Name))
+				logger.Error(err, "failed process to ClientTrafficPolicies for GatewayClass")
 			}
 		}
 
@@ -412,10 +413,10 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 			// Add all BackendTrafficPolicies to the resourceTree
 			if err = r.processBackendTrafficPolicies(ctx, gwcResource, gwcResourceMapping); err != nil {
 				if isTransientError(err) {
-					r.log.Error(err, "transient error processing BackendTrafficPolicies", "gatewayClass", managedGC.Name)
+					logger.Error(err, "transient error processing BackendTrafficPolicies")
 					return reconcile.Result{}, err
 				}
-				r.log.Error(err, fmt.Sprintf("failed processBackendTrafficPolicies for gatewayClass %s", managedGC.Name))
+				logger.Error(err, "failed to process BackendTrafficPolicies for GatewayClass")
 			}
 		}
 
@@ -423,10 +424,10 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 			// Add all SecurityPolicies and their referenced resources to the resourceTree
 			if err = r.processSecurityPolicies(ctx, gwcResource, gwcResourceMapping); err != nil {
 				if isTransientError(err) {
-					r.log.Error(err, "transient error processing SecurityPolicies", "gatewayClass", managedGC.Name)
+					logger.Error(err, "transient error processing SecurityPolicies")
 					return reconcile.Result{}, err
 				}
-				r.log.Error(err, fmt.Sprintf("failed processSecurityPolicies for gatewayClass %s", managedGC.Name))
+				logger.Error(err, "failed to process SecurityPolicies for GatewayClass")
 			}
 		}
 
@@ -434,10 +435,10 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 			// Add all BackendTLSPolies to the resourceTree
 			if err = r.processBackendTLSPolicies(ctx, gwcResource, gwcResourceMapping); err != nil {
 				if isTransientError(err) {
-					r.log.Error(err, "transient error processing BackendTLSPolicies", "gatewayClass", managedGC.Name)
+					logger.Error(err, "transient error processing BackendTLSPolicies")
 					return reconcile.Result{}, err
 				}
-				r.log.Error(err, fmt.Sprintf("failed processBackendTLSPolicies for gatewayClass %s", managedGC.Name))
+				logger.Error(err, "failed to process BackendTLSPolicies for GatewayClass")
 			}
 		}
 
@@ -445,19 +446,19 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 			// Add all EnvoyExtensionPolicies and their referenced resources to the resourceTree
 			if err = r.processEnvoyExtensionPolicies(ctx, gwcResource, gwcResourceMapping); err != nil {
 				if isTransientError(err) {
-					r.log.Error(err, "transient error processing EnvoyExtensionPolicies", "gatewayClass", managedGC.Name)
+					logger.Error(err, "transient error processing EnvoyExtensionPolicies")
 					return reconcile.Result{}, err
 				}
-				r.log.Error(err, fmt.Sprintf("failed processEnvoyExtensionPolicies for gatewayClass %s", managedGC.Name))
+				logger.Error(err, "failed to process EnvoyExtensionPolicies for GatewayClass")
 			}
 		}
 
 		if err = r.processExtensionServerPolicies(ctx, gwcResource); err != nil {
 			if isTransientError(err) {
-				r.log.Error(err, "transient error processing ExtensionServerPolicies", "gatewayClass", managedGC.Name)
+				logger.Error(err, "transient error processing ExtensionServerPolicies")
 				return reconcile.Result{}, err
 			}
-			r.log.Error(err, fmt.Sprintf("failed processExtensionServerPolicies for gatewayClass %s", managedGC.Name))
+			logger.Error(err, "failed to process ExtensionServerPolicies for GatewayClass")
 		}
 
 		// Add the referenced services, ServiceImports, and EndpointSlices in
@@ -465,11 +466,11 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 		// BackendRefs are referred by various Route objects and the ExtAuth in SecurityPolicies.
 		if err = r.processBackendRefs(ctx, gwcResource, gwcResourceMapping); err != nil {
 			if isTransientError(err) {
-				r.log.Error(err, "transient error processing BackendRefs", "gatewayClass", managedGC.Name)
+				logger.Error(err, "transient error processing BackendRefs")
 				return reconcile.Result{}, err
 			}
 
-			r.log.Error(err, fmt.Sprintf("failed  processBackendRefs for gatewayClass %s", managedGC.Name))
+			logger.Error(err, "failed to process BackendRefs for GatewayClass")
 		}
 
 		// For this particular Gateway, and all associated objects, check whether the
@@ -478,10 +479,10 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 			namespace, err := r.getNamespace(ctx, ns)
 			if err != nil {
 				if isTransientError(err) {
-					r.log.Error(err, "transient error getting namespace", "namespace", ns, "gatewayClass", managedGC.Name)
+					logger.Error(err, "transient error getting namespace", "namespace", ns)
 					return reconcile.Result{}, err
 				}
-				r.log.Error(err, "unable to find the namespace")
+				logger.Error(err, "unable to find the namespace", "namespace", ns)
 				if kerrors.IsNotFound(err) {
 					continue
 				}
@@ -500,26 +501,24 @@ func (r *gatewayAPIReconciler) Reconcile(ctx context.Context, _ reconcile.Reques
 		}
 
 		if len(gwcResource.Gateways) == 0 {
-			r.log.Info("No gateways found for accepted gatewayClass")
+			logger.Info("No gateways found for accepted GatewayClass")
 
 			// If needed, remove the finalizer from the accepted GatewayClass.
 			if err := r.removeFinalizer(ctx, managedGC); err != nil {
 				if isTransientError(err) {
-					r.log.Error(err, "transient error removing finalizer from gatewayClass", "gatewayClass", managedGC.Name)
+					logger.Error(err, "transient error removing finalizer from GatewayClass")
 					return reconcile.Result{}, err
 				}
-				r.log.Error(err, fmt.Sprintf("failed to remove finalizer from gatewayClass %s",
-					managedGC.Name))
+				logger.Error(err, "failed to remove finalizer from GatewayClass")
 			}
 		} else {
 			// finalize the accepted GatewayClass.
 			if err := r.addFinalizer(ctx, managedGC); err != nil {
 				if isTransientError(err) {
-					r.log.Error(err, "transient error adding finalizer to gatewayClass", "gatewayClass", managedGC.Name)
+					logger.Error(err, "transient error adding finalizer to gatewayClass")
 					return reconcile.Result{}, err
 				}
-				r.log.Error(err, fmt.Sprintf("failed adding finalizer to gatewayClass %s",
-					managedGC.Name))
+				logger.Error(err, "failed adding finalizer to gatewayClass")
 			}
 		}
 	}
@@ -1379,14 +1378,14 @@ func (r *gatewayAPIReconciler) processGateways(ctx context.Context, managedGC *g
 	if err := r.client.List(ctx, gatewayList, &client.ListOptions{
 		FieldSelector: fields.OneTermEqualSelector(classGatewayIndex, managedGC.Name),
 	}); err != nil {
-		r.log.Error(err, "failed to list gateways for gatewayClass %s", managedGC.Name)
+		r.log.Error(err, "failed to list gateways for GatewayClass", "GatewayClass", managedGC.Name)
 		return err
 	}
 
 	mergedGateways := false
 	if r.mergeGateways.Has(managedGC.Name) {
 		mergedGateways = true
-		// processGatewayClassParamsRef has been called for this gatewayclass, its EnvoyProxy should exist in resourceTree
+		// processGatewayClassParamsRef has been called for this GatewayClass, its EnvoyProxy should exist in resourceTree
 		r.processServiceClusterForGatewayClass(resourceTree.EnvoyProxyForGatewayClass, managedGC, resourceMap)
 	}
 
@@ -1398,7 +1397,7 @@ func (r *gatewayAPIReconciler) processGateways(ctx context.Context, managedGC *g
 				if isTransientError(err) {
 					return err
 				}
-				r.log.Error(err, "failed to check namespace labels for gateway %s in namespace %s: %w", gtw.GetName(), gtw.GetNamespace())
+				r.log.Error(err, "failed to check namespace labels for gateway", "name", gtw.GetName(), "namespace", gtw.GetNamespace())
 				continue
 			} else if !ok {
 				continue
@@ -2367,7 +2366,7 @@ func (r *gatewayAPIReconciler) processGatewayParamsRef(ctx context.Context, gtw
 
 	ep := new(egv1a1.EnvoyProxy)
 	if err := r.client.Get(ctx, types.NamespacedName{Namespace: gtw.Namespace, Name: ref.Name}, ep); err != nil {
-		return fmt.Errorf("failed to find envoyproxy %s/%s: %w", gtw.Namespace, ref.Name, err)
+		return fmt.Errorf("failed to find envoyproxy %s/%s for Gateway %s: %w", gtw.Namespace, ref.Name, gtw.Name, err)
 	}
 
 	if err := r.processEnvoyProxy(ep, resourceMap); err != nil {
@@ -2397,8 +2396,9 @@ func (r *gatewayAPIReconciler) processGatewayClassParamsRef(ctx context.Context,
 	}
 
 	ep := new(egv1a1.EnvoyProxy)
-	if err := r.client.Get(ctx, types.NamespacedName{Namespace: string(*gc.Spec.ParametersRef.Namespace), Name: gc.Spec.ParametersRef.Name}, ep); err != nil {
-		return fmt.Errorf("failed to find envoyproxy %s/%s: %w", r.namespace, gc.Spec.ParametersRef.Name, err)
+	nn := types.NamespacedName{Namespace: string(*gc.Spec.ParametersRef.Namespace), Name: gc.Spec.ParametersRef.Name}
+	if err := r.client.Get(ctx, nn, ep); err != nil {
+		return fmt.Errorf("failed to find envoyproxy %s/%s for GatewayClass %s: %w", nn.Namespace, nn.Name, gc.Name, err)
 	}
 
 	// Check for incompatible configuration: both MergeGateways and GatewayNamespaceMode enabled
diff --git a/internal/provider/kubernetes/helpers.go b/internal/provider/kubernetes/helpers.go
index 634f64aef..b2b69f513 100644
--- a/internal/provider/kubernetes/helpers.go
+++ b/internal/provider/kubernetes/helpers.go
@@ -97,35 +97,6 @@ func (cc *controlledClasses) removeMatch(gc *gwapiv1.GatewayClass) {
 	}
 }
 
-// isAccepted returns true if the provided gatewayclass contains the Accepted=true
-// status condition.
-func isAccepted(gc *gwapiv1.GatewayClass) bool {
-	if gc == nil {
-		return false
-	}
-	for _, cond := range gc.Status.Conditions {
-		if cond.Type == string(gwapiv1.GatewayClassConditionStatusAccepted) && cond.Status == metav1.ConditionTrue {
-			return true
-		}
-	}
-	return false
-}
-
-// gatewaysOfClass returns a list of gateways that reference gc from the provided gwList.
-func gatewaysOfClass(gc *gwapiv1.GatewayClass, gwList *gwapiv1.GatewayList) []gwapiv1.Gateway {
-	var gateways []gwapiv1.Gateway
-	if gwList == nil || gc == nil {
-		return gateways
-	}
-	for i := range gwList.Items {
-		gw := gwList.Items[i]
-		if string(gw.Spec.GatewayClassName) == gc.Name {
-			gateways = append(gateways, gw)
-		}
-	}
-	return gateways
-}
-
 // terminatesTLS returns true if the provided gateway contains a listener configured
 // for TLS termination.
 func terminatesTLS(listener *gwapiv1.Listener) bool {
diff --git a/internal/provider/kubernetes/status.go b/internal/provider/kubernetes/status.go
index 5735260e0..a0e4f0ef7 100644
--- a/internal/provider/kubernetes/status.go
+++ b/internal/provider/kubernetes/status.go
@@ -109,7 +109,7 @@ func (r *gatewayAPIReconciler) subscribeAndUpdateStatus(ctx context.Context, ext
 							Spec:       h.Spec,
 							Status: gwapiv1.HTTPRouteStatus{
 								RouteStatus: gwapiv1.RouteStatus{
-									Parents: mergeRouteParentStatus(h.Namespace, r.envoyGateway.Gateway.ControllerName, h.Status.Parents, val.Parents),
+									Parents: mergeRouteParentStatus(h.Namespace, h.Status.Parents, val.Parents),
 								},
 							},
 						}
@@ -147,7 +147,7 @@ func (r *gatewayAPIReconciler) subscribeAndUpdateStatus(ctx context.Context, ext
 							Spec:       g.Spec,
 							Status: gwapiv1.GRPCRouteStatus{
 								RouteStatus: gwapiv1.RouteStatus{
-									Parents: mergeRouteParentStatus(g.Namespace, r.envoyGateway.Gateway.ControllerName, g.Status.Parents, val.Parents),
+									Parents: mergeRouteParentStatus(g.Namespace, g.Status.Parents, val.Parents),
 								},
 							},
 						}
@@ -187,7 +187,7 @@ func (r *gatewayAPIReconciler) subscribeAndUpdateStatus(ctx context.Context, ext
 							Spec:       t.Spec,
 							Status: gwapiv1a2.TLSRouteStatus{
 								RouteStatus: gwapiv1.RouteStatus{
-									Parents: mergeRouteParentStatus(t.Namespace, r.envoyGateway.Gateway.ControllerName, t.Status.Parents, val.Parents),
+									Parents: mergeRouteParentStatus(t.Namespace, t.Status.Parents, val.Parents),
 								},
 							},
 						}
@@ -227,7 +227,7 @@ func (r *gatewayAPIReconciler) subscribeAndUpdateStatus(ctx context.Context, ext
 							Spec:       t.Spec,
 							Status: gwapiv1a2.TCPRouteStatus{
 								RouteStatus: gwapiv1.RouteStatus{
-									Parents: mergeRouteParentStatus(t.Namespace, r.envoyGateway.Gateway.ControllerName, t.Status.Parents, val.Parents),
+									Parents: mergeRouteParentStatus(t.Namespace, t.Status.Parents, val.Parents),
 								},
 							},
 						}
@@ -267,7 +267,7 @@ func (r *gatewayAPIReconciler) subscribeAndUpdateStatus(ctx context.Context, ext
 							Spec:       u.Spec,
 							Status: gwapiv1a2.UDPRouteStatus{
 								RouteStatus: gwapiv1.RouteStatus{
-									Parents: mergeRouteParentStatus(u.Namespace, r.envoyGateway.Gateway.ControllerName, u.Status.Parents, val.Parents),
+									Parents: mergeRouteParentStatus(u.Namespace, u.Status.Parents, val.Parents),
 								},
 							},
 						}
@@ -569,14 +569,15 @@ func (r *gatewayAPIReconciler) subscribeAndUpdateStatus(ctx context.Context, ext
 
 // mergeRouteParentStatus merges the old and new RouteParentStatus.
 // This is needed because the RouteParentStatus doesn't support strategic merge patch yet.
-func mergeRouteParentStatus(ns, controllerName string, old, new []gwapiv1.RouteParentStatus) []gwapiv1.RouteParentStatus {
+func mergeRouteParentStatus(ns string, old, new []gwapiv1.RouteParentStatus) []gwapiv1.RouteParentStatus {
 	// Allocating with worst-case capacity to avoid reallocation.
 	merged := make([]gwapiv1.RouteParentStatus, 0, len(old)+len(new))
 
 	// Range over old status parentRefs in order:
 	// 1. The parentRef exists in the new status: append the new one to the final status.
 	// 2. The parentRef doesn't exist in the new status and it's not our controller: append it to the final status.
-	// 3. The parentRef doesn't exist in the new status, and it is our controller: don't append it to the final status.
+	// 3. The parentRef doesn't exist in the new status, and it is our controller: keep it in the final status.
+	//    This is important for routes with multiple parent references - not all parents are updated in each reconciliation.
 	for _, oldP := range old {
 		found := -1
 		for newI, newP := range new {
@@ -587,7 +588,10 @@ func mergeRouteParentStatus(ns, controllerName string, old, new []gwapiv1.RouteP
 		}
 		if found >= 0 {
 			merged = append(merged, new[found])
-		} else if oldP.ControllerName != gwapiv1.GatewayController(controllerName) {
+		} else {
+			// Keep all old parent statuses, regardless of controller.
+			// For routes with multiple parents managed by the same controller,
+			// not all parents are necessarily updated in each reconciliation.
 			merged = append(merged, oldP)
 		}
 	}
diff --git a/internal/xds/translator/jwt.go b/internal/xds/translator/jwt.go
index 8803b0529..423e31f1d 100644
--- a/internal/xds/translator/jwt.go
+++ b/internal/xds/translator/jwt.go
@@ -8,7 +8,6 @@ package translator
 import (
 	"errors"
 	"fmt"
-	"time"
 
 	corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
 	routev3 "github.com/envoyproxy/go-control-plane/envoy/config/route/v3"
@@ -159,11 +158,13 @@ func buildJWTAuthn(irListener *ir.HTTPListener) (*jwtauthnv3.JwtAuthentication,
 							},
 							Timeout: durationpb.New(defaultExtServiceRequestTimeout),
 						},
-						CacheDuration: durationpb.New(5 * time.Minute),
-						AsyncFetch:    &jwtauthnv3.JwksAsyncFetch{},
+
+						AsyncFetch: &jwtauthnv3.JwksAsyncFetch{},
 					},
 				}
-
+				if jwks.CacheDuration != nil {
+					remote.RemoteJwks.CacheDuration = durationpb.New(jwks.CacheDuration.Duration)
+				}
 				// Set the retry policy if it exists.
 				if jwks.Traffic != nil && jwks.Traffic.Retry != nil {
 					var rp *corev3.RetryPolicy
diff --git a/release-notes/current.yaml b/release-notes/current.yaml
index b9134cd12..aa9e22b35 100644
--- a/release-notes/current.yaml
+++ b/release-notes/current.yaml
@@ -13,6 +13,7 @@ new features: |
   Added support for RetryPolicy in gRPC ExtAuth callouts via SecurityPolicy backend settings fields.
   Added support for late response headers in ClientTrafficPolicy.
   Added max connection/stream duration and max requests per connection to ClientTrafficPolicy.
+  Added cacheDuration for remoteJWKS in SecurityPolicy.
 
 bug fixes: |
   Fixed %ROUTE_KIND% operator to be lower-cased when used by clusterStatName in EnvoyProxy API.



Test patch:
diff --git a/internal/cmd/egctl/testdata/translate/in/jwt-single-route-single-match-to-xds.yaml b/internal/cmd/egctl/testdata/translate/in/jwt-single-route-single-match-to-xds.yaml
index cef3ccc53..c3555103d 100644
--- a/internal/cmd/egctl/testdata/translate/in/jwt-single-route-single-match-to-xds.yaml
+++ b/internal/cmd/egctl/testdata/translate/in/jwt-single-route-single-match-to-xds.yaml
@@ -84,6 +84,7 @@ spec:
       - name: example
         remoteJWKS:
           uri: https://raw.githubusercontent.com/envoyproxy/gateway/main/examples/kubernetes/jwt/jwks.json
+          cacheDuration: 300s
 ---
 apiVersion: gateway.networking.k8s.io/v1
 kind: HTTPRoute
diff --git a/internal/gatewayapi/resource/testdata/all-resources.out.yaml b/internal/gatewayapi/resource/testdata/all-resources.out.yaml
index b62951040..f70580f5d 100644
--- a/internal/gatewayapi/resource/testdata/all-resources.out.yaml
+++ b/internal/gatewayapi/resource/testdata/all-resources.out.yaml
@@ -383,6 +383,7 @@ securityPolicies:
       providers:
       - name: example
         remoteJWKS:
+          cacheDuration: 300s
           uri: https://raw.githubusercontent.com/envoyproxy/gateway/main/examples/kubernetes/jwt/jwks.json
     targetRef:
       group: gateway.networking.k8s.io
diff --git a/internal/provider/kubernetes/helpers_test.go b/internal/provider/kubernetes/helpers_test.go
index 38a634f9c..dd7aa92ea 100644
--- a/internal/provider/kubernetes/helpers_test.go
+++ b/internal/provider/kubernetes/helpers_test.go
@@ -111,6 +111,21 @@ func TestGatewaysOfClass(t *testing.T) {
 	}
 }
 
+// gatewaysOfClass returns a list of gateways that reference gc from the provided gwList.
+func gatewaysOfClass(gc *gwapiv1.GatewayClass, gwList *gwapiv1.GatewayList) []gwapiv1.Gateway {
+	var gateways []gwapiv1.Gateway
+	if gwList == nil || gc == nil {
+		return gateways
+	}
+	for i := range gwList.Items {
+		gw := gwList.Items[i]
+		if string(gw.Spec.GatewayClassName) == gc.Name {
+			gateways = append(gateways, gw)
+		}
+	}
+	return gateways
+}
+
 func TestIsGatewayClassAccepted(t *testing.T) {
 	testCases := []struct {
 		name   string
@@ -185,12 +200,26 @@ func TestIsGatewayClassAccepted(t *testing.T) {
 
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
-			actual := isAccepted(tc.gc)
+			actual := isGatewayClassAccepted(tc.gc)
 			require.Equal(t, tc.expect, actual)
 		})
 	}
 }
 
+// isAccepted returns true if the provided gatewayclass contains the Accepted=true
+// status condition.
+func isGatewayClassAccepted(gc *gwapiv1.GatewayClass) bool {
+	if gc == nil {
+		return false
+	}
+	for _, cond := range gc.Status.Conditions {
+		if cond.Type == string(gwapiv1.GatewayClassConditionStatusAccepted) && cond.Status == metav1.ConditionTrue {
+			return true
+		}
+	}
+	return false
+}
+
 func TestRefsEnvoyProxy(t *testing.T) {
 	testCases := []struct {
 		name   string
diff --git a/internal/provider/kubernetes/status_test.go b/internal/provider/kubernetes/status_test.go
index 63b7fdea7..1cb4c5730 100644
--- a/internal/provider/kubernetes/status_test.go
+++ b/internal/provider/kubernetes/status_test.go
@@ -361,6 +361,24 @@ func Test_mergeRouteParentStatus(t *testing.T) {
 						},
 					},
 				},
+				{
+					ControllerName: "gateway.envoyproxy.io/gatewayclass-controller",
+					ParentRef: gwapiv1.ParentReference{
+						Name: "gateway2",
+					},
+					Conditions: []metav1.Condition{
+						{
+							Type:   string(gwapiv1.RouteConditionAccepted),
+							Status: metav1.ConditionTrue,
+							Reason: "Accepted",
+						},
+						{
+							Type:   string(gwapiv1.RouteConditionResolvedRefs),
+							Status: metav1.ConditionTrue,
+							Reason: "ResolvedRefs",
+						},
+					},
+				},
 				{
 					ControllerName: "gateway.envoyproxy.io/gatewayclass-controller",
 					ParentRef: gwapiv1.ParentReference{
@@ -456,6 +474,24 @@ func Test_mergeRouteParentStatus(t *testing.T) {
 						},
 					},
 				},
+				{
+					ControllerName: "gateway.envoyproxy.io/gatewayclass-controller",
+					ParentRef: gwapiv1.ParentReference{
+						Name: "gateway2",
+					},
+					Conditions: []metav1.Condition{
+						{
+							Type:   string(gwapiv1.RouteConditionAccepted),
+							Status: metav1.ConditionTrue,
+							Reason: "Accepted",
+						},
+						{
+							Type:   string(gwapiv1.RouteConditionResolvedRefs),
+							Status: metav1.ConditionTrue,
+							Reason: "ResolvedRefs",
+						},
+					},
+				},
 			},
 		},
 
@@ -670,6 +706,24 @@ func Test_mergeRouteParentStatus(t *testing.T) {
 				},
 			},
 			want: []gwapiv1.RouteParentStatus{
+				{
+					ControllerName: "gateway.envoyproxy.io/gatewayclass-controller",
+					ParentRef: gwapiv1.ParentReference{
+						Name: "gateway2",
+					},
+					Conditions: []metav1.Condition{
+						{
+							Type:   string(gwapiv1.RouteConditionAccepted),
+							Status: metav1.ConditionTrue,
+							Reason: "Accepted",
+						},
+						{
+							Type:   string(gwapiv1.RouteConditionResolvedRefs),
+							Status: metav1.ConditionTrue,
+							Reason: "ResolvedRefs",
+						},
+					},
+				},
 				{
 					ControllerName: "gateway.envoyproxy.io/gatewayclass-controller",
 					ParentRef: gwapiv1.ParentReference{
@@ -690,9 +744,10 @@ func Test_mergeRouteParentStatus(t *testing.T) {
 				},
 			},
 		},
-		// Similar note about practicality of occurrence.
+		// Test that parent refs managed by our controller are preserved even when not in new update.
+		// This is important for routes with multiple parent references.
 		{
-			name: "old contains one parentRef of ours, and it gets dropped in new.",
+			name: "old contains one parentRef of ours, and it's not in new - should be preserved.",
 			args: args{
 				old: []gwapiv1.RouteParentStatus{
 					{
@@ -716,12 +771,118 @@ func Test_mergeRouteParentStatus(t *testing.T) {
 				},
 				new: []gwapiv1.RouteParentStatus{},
 			},
-			want: []gwapiv1.RouteParentStatus{},
+			want: []gwapiv1.RouteParentStatus{
+				{
+					ControllerName: "gateway.envoyproxy.io/gatewayclass-controller",
+					ParentRef: gwapiv1.ParentReference{
+						Name: "gateway2",
+					},
+					Conditions: []metav1.Condition{
+						{
+							Type:   string(gwapiv1.RouteConditionAccepted),
+							Status: metav1.ConditionTrue,
+							Reason: "Accepted",
+						},
+						{
+							Type:   string(gwapiv1.RouteConditionResolvedRefs),
+							Status: metav1.ConditionTrue,
+							Reason: "ResolvedRefs",
+						},
+					},
+				},
+			},
+		},
+		// Test multi-parent scenario where only one parent is updated at a time.
+		{
+			name: "multiple parents from same controller - update one, preserve others",
+			args: args{
+				old: []gwapiv1.RouteParentStatus{
+					{
+						ControllerName: "gateway.envoyproxy.io/gatewayclass-controller",
+						ParentRef: gwapiv1.ParentReference{
+							Name: "gateway1",
+						},
+						Conditions: []metav1.Condition{
+							{
+								Type:   string(gwapiv1.RouteConditionAccepted),
+								Status: metav1.ConditionTrue,
+								Reason: "Accepted",
+							},
+						},
+					},
+					{
+						ControllerName: "gateway.envoyproxy.io/gatewayclass-controller",
+						ParentRef: gwapiv1.ParentReference{
+							Name: "gateway2",
+						},
+						Conditions: []metav1.Condition{
+							{
+								Type:   string(gwapiv1.RouteConditionAccepted),
+								Status: metav1.ConditionTrue,
+								Reason: "Accepted",
+							},
+						},
+					},
+				},
+				new: []gwapiv1.RouteParentStatus{
+					{
+						ControllerName: "gateway.envoyproxy.io/gatewayclass-controller",
+						ParentRef: gwapiv1.ParentReference{
+							Name: "gateway1",
+						},
+						Conditions: []metav1.Condition{
+							{
+								Type:   string(gwapiv1.RouteConditionAccepted),
+								Status: metav1.ConditionTrue,
+								Reason: "Accepted",
+							},
+							{
+								Type:   string(gwapiv1.RouteConditionResolvedRefs),
+								Status: metav1.ConditionTrue,
+								Reason: "ResolvedRefs",
+							},
+						},
+					},
+				},
+			},
+			want: []gwapiv1.RouteParentStatus{
+				{
+					ControllerName: "gateway.envoyproxy.io/gatewayclass-controller",
+					ParentRef: gwapiv1.ParentReference{
+						Name: "gateway1",
+					},
+					Conditions: []metav1.Condition{
+						{
+							Type:   string(gwapiv1.RouteConditionAccepted),
+							Status: metav1.ConditionTrue,
+							Reason: "Accepted",
+						},
+						{
+							Type:   string(gwapiv1.RouteConditionResolvedRefs),
+							Status: metav1.ConditionTrue,
+							Reason: "ResolvedRefs",
+						},
+					},
+				},
+				{
+					ControllerName: "gateway.envoyproxy.io/gatewayclass-controller",
+					ParentRef: gwapiv1.ParentReference{
+						Name: "gateway2",
+					},
+					Conditions: []metav1.Condition{
+						{
+							Type:   string(gwapiv1.RouteConditionAccepted),
+							Status: metav1.ConditionTrue,
+							Reason: "Accepted",
+						},
+					},
+				},
+			},
 		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			if got := mergeRouteParentStatus("default", "gateway.envoyproxy.io/gatewayclass-controller", tt.args.old, tt.args.new); !reflect.DeepEqual(got, tt.want) {
+			if got := mergeRouteParentStatus("default", tt.args.old, tt.args.new); !reflect.DeepEqual(got, tt.want) {
 				t.Errorf("mergeRouteParentStatus() = %v, want %v", got, tt.want)
 			}
 		})
diff --git a/internal/xds/translator/testdata/in/xds-ir/authorization-jwt-claim.yaml b/internal/xds/translator/testdata/in/xds-ir/authorization-jwt-claim.yaml
index 321ad85ef..d2a6caf02 100644
--- a/internal/xds/translator/testdata/in/xds-ir/authorization-jwt-claim.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/authorization-jwt-claim.yaml
@@ -55,6 +55,7 @@ http:
           name: example1
           remoteJWKS:
             uri: https://two.example.com/jwt/public-key/jwks.json
+            cacheDuration: 300s
   - destination:
       name: httproute/default/httproute-2/rule/0
       settings:
@@ -99,3 +100,5 @@ http:
           name: example1
           remoteJWKS:
             uri: https://one.example.com/jwt/public-key/jwks.json
+            cacheDuration: 300s
+
diff --git a/internal/xds/translator/testdata/in/xds-ir/authorization-jwt-scope.yaml b/internal/xds/translator/testdata/in/xds-ir/authorization-jwt-scope.yaml
index 95ffc4a94..df3d838fb 100644
--- a/internal/xds/translator/testdata/in/xds-ir/authorization-jwt-scope.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/authorization-jwt-scope.yaml
@@ -55,6 +55,7 @@ http:
           name: example1
           remoteJWKS:
             uri: https://two.example.com/jwt/public-key/jwks.json
+            cacheDuration: 300s
   - destination:
       name: httproute/default/httproute-2/rule/0
       settings:
@@ -95,3 +96,5 @@ http:
           name: example1
           remoteJWKS:
             uri: https://one.example.com/jwt/public-key/jwks.json
+            cacheDuration: 300s
+
diff --git a/internal/xds/translator/testdata/in/xds-ir/custom-filter-order.yaml b/internal/xds/translator/testdata/in/xds-ir/custom-filter-order.yaml
index 4971328e8..7f224f4f1 100644
--- a/internal/xds/translator/testdata/in/xds-ir/custom-filter-order.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/custom-filter-order.yaml
@@ -52,6 +52,7 @@ http:
           name: example1
           remoteJWKS:
             uri: https://one.example.com/jwt/public-key/jwks.json
+            cacheDuration: 300s
         - audiences:
           - two.foo.com
           claimToHeaders:
@@ -61,6 +62,7 @@ http:
           name: example2
           remoteJWKS:
             uri: http://two.example.com/jwt/public-key/jwks.json
+            cacheDuration: 300s
     envoyExtensions:
       wasms:
       - config:
diff --git a/internal/xds/translator/testdata/in/xds-ir/jwt-custom-extractor.yaml b/internal/xds/translator/testdata/in/xds-ir/jwt-custom-extractor.yaml
index 66fc43f92..07e8fdd38 100644
--- a/internal/xds/translator/testdata/in/xds-ir/jwt-custom-extractor.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/jwt-custom-extractor.yaml
@@ -21,6 +21,7 @@ http:
           - foo.com
           remoteJWKS:
             uri: https://localhost/jwt/public-key/jwks.json
+            cacheDuration: 300s
           extractFrom:
             cookies:
             - session_access_token
diff --git a/internal/xds/translator/testdata/in/xds-ir/jwt-multi-route-multi-provider.yaml b/internal/xds/translator/testdata/in/xds-ir/jwt-multi-route-multi-provider.yaml
index f2ebba7af..e1ae6a567 100644
--- a/internal/xds/translator/testdata/in/xds-ir/jwt-multi-route-multi-provider.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/jwt-multi-route-multi-provider.yaml
@@ -21,6 +21,7 @@ http:
           - foo.com
           remoteJWKS:
             uri: http://localhost/jwt/public-key/jwks.json
+            cacheDuration: 300s
           claimToHeaders:
           - header: one-route-example-key1
             claim: claim.neteased.key
@@ -64,6 +65,7 @@ http:
           - foo.com
           remoteJWKS:
             uri: http://localhost/jwt/public-key/jwks.json
+            cacheDuration: 300s
           claimToHeaders:
           - header: second-route-example-key1
             claim: claim.neteased.key
@@ -74,6 +76,7 @@ http:
           - two.foo.com
           remoteJWKS:
             uri: https://192.168.1.250:8080/jwt/public-key/jwks.json
+            cacheDuration: 300s
     destination:
       name: "second-route-www.test.com-dest"
       settings:
diff --git a/internal/xds/translator/testdata/in/xds-ir/jwt-multi-route-single-provider.yaml b/internal/xds/translator/testdata/in/xds-ir/jwt-multi-route-single-provider.yaml
index 4944995ad..6e9de3de4 100644
--- a/internal/xds/translator/testdata/in/xds-ir/jwt-multi-route-single-provider.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/jwt-multi-route-single-provider.yaml
@@ -26,6 +26,7 @@ http:
           - foo.com
           remoteJWKS:
             uri: https://localhost/jwt/public-key/jwks.json
+            cacheDuration: 300s
           claimToHeaders:
           - header: first-route-key
             claim: claim.neteased.key
@@ -49,6 +50,7 @@ http:
           - foo.com
           remoteJWKS:
             uri: https://localhost/jwt/public-key/jwks.json
+            cacheDuration: 300s
     destination:
       name: "second-route-dest"
       settings:
diff --git a/internal/xds/translator/testdata/in/xds-ir/jwt-optional.yaml b/internal/xds/translator/testdata/in/xds-ir/jwt-optional.yaml
index 18baee23c..0eb580353 100644
--- a/internal/xds/translator/testdata/in/xds-ir/jwt-optional.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/jwt-optional.yaml
@@ -21,6 +21,7 @@ http:
           - foo.com
           remoteJWKS:
             uri: https://localhost/jwt/public-key/jwks.json
+            cacheDuration: 300s
           extractFrom:
             cookies:
             - session_access_token
diff --git a/internal/xds/translator/testdata/in/xds-ir/jwt-ratelimit.yaml b/internal/xds/translator/testdata/in/xds-ir/jwt-ratelimit.yaml
index b84df0fb0..3c69d1ac8 100644
--- a/internal/xds/translator/testdata/in/xds-ir/jwt-ratelimit.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/jwt-ratelimit.yaml
@@ -43,6 +43,7 @@ http:
           - foo.com
           remoteJWKS:
             uri: https://192.168.1.250/jwt/public-key/jwks.json
+            cacheDuration: 300s
   - name: "second-route"
     hostname: "*"
     traffic:
diff --git a/internal/xds/translator/testdata/in/xds-ir/jwt-single-route-single-match.yaml b/internal/xds/translator/testdata/in/xds-ir/jwt-single-route-single-match.yaml
index efa91fec8..2094b3ac5 100644
--- a/internal/xds/translator/testdata/in/xds-ir/jwt-single-route-single-match.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/jwt-single-route-single-match.yaml
@@ -21,6 +21,7 @@ http:
           - foo.com
           remoteJWKS:
             uri: https://localhost/jwt/public-key/jwks.json
+            cacheDuration: 300s
     destination:
       name: "first-route-dest"
       settings:
diff --git a/internal/xds/translator/testdata/in/xds-ir/jwt-with-backend-tls-retry.yaml b/internal/xds/translator/testdata/in/xds-ir/jwt-with-backend-tls-retry.yaml
index f1ba24346..ca4e8e284 100644
--- a/internal/xds/translator/testdata/in/xds-ir/jwt-with-backend-tls-retry.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/jwt-with-backend-tls-retry.yaml
@@ -75,3 +75,4 @@ http:
                   - gateway-error
                   - reset
             uri: https://foo.bar.com/jwt/public-key/jwks.json
+            cacheDuration: 300s
diff --git a/internal/xds/translator/testdata/in/xds-ir/oidc-and-jwt-with-passthrough.yaml b/internal/xds/translator/testdata/in/xds-ir/oidc-and-jwt-with-passthrough.yaml
index 4cddbaafd..b93364c10 100644
--- a/internal/xds/translator/testdata/in/xds-ir/oidc-and-jwt-with-passthrough.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/oidc-and-jwt-with-passthrough.yaml
@@ -28,6 +28,7 @@ http:
                 name: example1
                 remoteJWKS:
                   uri: https://oauth.foo.com/jwt/public-key/jwks.json
+                  cacheDuration: 300s
               - issuer: https://oauth.foo.com
                 name: example2
                 extractFrom:
@@ -37,6 +38,7 @@ http:
                     - name: MyHeaderNoPrefix
                 remoteJWKS:
                   uri: https://oauth.foo.com/jwt/public-key/jwks.json
+                  cacheDuration: 300s
               - issuer: https://oauth.foo.com
                 name: example3
                 extractFrom:
@@ -45,6 +47,7 @@ http:
                     - Cookie1
                 remoteJWKS:
                   uri: https://oauth.foo.com/jwt/public-key/jwks.json
+                  cacheDuration: 300s
               - issuer: https://oauth.foo.com
                 name: example4
                 extractFrom:
@@ -53,6 +56,7 @@ http:
                     - Param1
                 remoteJWKS:
                   uri: https://oauth.foo.com/jwt/public-key/jwks.json
+                  cacheDuration: 300s
           oidc:
             clientID: client.oauth.foo.com
             clientSecret: Y2xpZW50MTpzZWNyZXQK
diff --git a/internal/xds/translator/testdata/in/xds-ir/securitypolicy-with-oidc-jwt-authz.yaml b/internal/xds/translator/testdata/in/xds-ir/securitypolicy-with-oidc-jwt-authz.yaml
index f3e839938..a9352668d 100644
--- a/internal/xds/translator/testdata/in/xds-ir/securitypolicy-with-oidc-jwt-authz.yaml
+++ b/internal/xds/translator/testdata/in/xds-ir/securitypolicy-with-oidc-jwt-authz.yaml
@@ -61,6 +61,7 @@ http:
           name: exjwt
           remoteJWKS:
             uri: https://oidc.example.com/auth/realms/example/protocol/openid-connect/certs
+            cacheDuration: 300s
       oidc:
         clientID: prometheus
         clientSecret: '[redacted]'
diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md
index 070d62c4d..113e45648 100644
--- a/site/content/en/latest/api/extension_types.md
+++ b/site/content/en/latest/api/extension_types.md
@@ -4370,6 +4370,7 @@ _Appears in:_
 | `backendRefs` | _[BackendRef](#backendref) array_ |  false  |  | BackendRefs references a Kubernetes object that represents the<br />backend server to which the authorization request will be sent. |
 | `backendSettings` | _[ClusterSettings](#clustersettings)_ |  false  |  | BackendSettings holds configuration for managing the connection<br />to the backend. |
 | `uri` | _string_ |  true  |  | URI is the HTTPS URI to fetch the JWKS. Envoy's system trust bundle is used to validate the server certificate.<br />If a custom trust bundle is needed, it can be specified in a BackendTLSConfig resource and target the BackendRefs. |
+| `cacheDuration` | _[Duration](https://gateway-api.sigs.k8s.io/reference/spec/#gateway.networking.k8s.io/v1.Duration)_ |  false  | 300s |  |
 
 
 #### ReplaceRegexMatch
diff --git a/test/e2e/testdata/jwt-backend-remote-jwks.yaml b/test/e2e/testdata/jwt-backend-remote-jwks.yaml
index 17c335df4..816541ac2 100644
--- a/test/e2e/testdata/jwt-backend-remote-jwks.yaml
+++ b/test/e2e/testdata/jwt-backend-remote-jwks.yaml
@@ -152,6 +152,7 @@ spec:
               retryOn:
                 triggers: ["5xx", "gateway-error", "reset"]
           uri: https://remote-jwks-server.gateway-conformance-infra/jwt/jwks.json
+          cacheDuration: 300s
 ---
 apiVersion: gateway.networking.k8s.io/v1
 kind: HTTPRoute
diff --git a/test/e2e/testdata/jwt.yaml b/test/e2e/testdata/jwt.yaml
index 32b1d96dc..46e10602e 100644
--- a/test/e2e/testdata/jwt.yaml
+++ b/test/e2e/testdata/jwt.yaml
@@ -21,6 +21,7 @@ spec:
             header: x-name
         remoteJWKS:
           uri: http://static-file-server.gateway-conformance-infra/jwt/jwks.json
+          cacheDuration: 300s
 ---
 apiVersion: gateway.networking.k8s.io/v1
 kind: HTTPRoute
diff --git a/test/e2e/testdata/oidc-securitypolicy.yaml b/test/e2e/testdata/oidc-securitypolicy.yaml
index 37aaafcdd..4477fd739 100644
--- a/test/e2e/testdata/oidc-securitypolicy.yaml
+++ b/test/e2e/testdata/oidc-securitypolicy.yaml
@@ -85,6 +85,8 @@ spec:
     - name: "keycloak"  # This is needed so JWTs generated through the OIDC flow can be validated
       remoteJWKS:
         uri: "http://keycloak.gateway-conformance-infra/realms/master/protocol/openid-connect/certs"
+        cacheDuration: 300s
     - name: "example"  # This allows us to use the static JWTs in the test
       remoteJWKS:
         uri: "http://static-file-server.gateway-conformance-infra/jwt/jwks.json"
+        cacheDuration: 300s
diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml
index 518673af3..4ec1931a6 100644
--- a/test/helm/gateway-crds-helm/all.out.yaml
+++ b/test/helm/gateway-crds-helm/all.out.yaml
@@ -43575,6 +43575,13 @@ spec:
                                       type: object
                                   type: object
                               type: object
+                            cacheDuration:
+                              default: 300s
+                              description: |-
+                                Duration is a string value representing a duration in time. The format is as specified
+                                in GEP-2257, a strict subset of the syntax parsed by Golang time.ParseDuration.
+                              pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$
+                              type: string
                             uri:
                               description: |-
                                 URI is the HTTPS URI to fetch the JWKS. Envoy's system trust bundle is used to validate the server certificate.
diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
index a34779284..8875240ca 100644
--- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
+++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
@@ -26263,6 +26263,13 @@ spec:
                                       type: object
                                   type: object
                               type: object
+                            cacheDuration:
+                              default: 300s
+                              description: |-
+                                Duration is a string value representing a duration in time. The format is as specified
+                                in GEP-2257, a strict subset of the syntax parsed by Golang time.ParseDuration.
+                              pattern: ^([0-9]{1,5}(h|m|s|ms)){1,4}$
+                              type: string
                             uri:
                               description: |-
                                 URI is the HTTPS URI to fetch the JWKS. Envoy's system trust bundle is used to validate the server certificate.