Repository: envoyproxy/gateway

Issue:
Filters for OIDC authentication mechanisms
*Description*:

Currently, when OIDC and JWT authentication mechanisms are configured in the same `SecurityPolicy`, the OIDC is applied first. It ensures the presence of the bearer and refresh tokens in cookies, and adds the `Authorisation` header to the request. Then JWT is applied, validating the added header.

This setup works perfectly for browser requests. However, it prevents monitoring systems and regression tests, which provide a valid `Authorisation` header in the requests, from accessing the services on the same URLs. The OIDC mechanism kicks in first and redirects the requests to the login pages because of the missing cookies.

We suggest extending the configuration of, at least, the OIDC mechanism with the possibility of adding a header-based filter.
When implemented, the OIDC can be skipped if the `Authorization` header is present.

An example of the configuration:

```
  oidc:
    provider: {}
    clientID: ""
    scopes: []
    clientSecret: {}
    matches:
    - ifRequestHeader:
        name: Authorization
        valueRegex: Bearer.*
      onMatch: skip
```

Envoy Proxy supports Composite Filters, which support such decisions and therefore can be used to implement this functionality.

*Relevant Links*:

* The [Combining OIDC and JWT authentication](https://github.com/envoyproxy/gateway/discussions/2425) discussion
* Envoy [Composite Filters](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/composite_filter)


Hints from issue comments:
@zetaab: @arkodg any comments to this one? As I see this is quite important feature to have possibility to use service with human users and machines.@arkodg: @zetaab @sadovnikov my hesitance is with including another field like `matches` within `oidc` . Today the request is matching header attributes defined within the `HTTPRoute` and the `SecurityPolicy` is applied to a `HTTPRoute`. Adding another `matches` within `oidc` is not required and may confuse the user.

You could implement the same logic (https://github.com/envoyproxy/gateway/discussions/2425#discussioncomment-8141456) with 2 HTTPRoutes today
* Security Policy 1 with a `oidc` config that applies to a HTTPRoute1 which has your routing rules
* Security Policy 2 with a `jwt` config that applies to a HTTPRoute2 which has your routing rules along with extra matches such as  `BearerToken` is set && `OauthHMAC` is empty && `OauthExpires` is empty (will take precedence to the extra matches)

This should unblock you for now, but will end up causing some duplicate configuration for you.

You could also use a EnvoyPatchPolicy with a `pass_through_matcher` defined in https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/oauth2/v3/oauth.proto#extensions-filters-http-oauth2-v3-oauth2config

A more long term would be to to figure out the right UX / field for this use case.

@sadovnikov: @arkodg From my [discussion](https://github.com/envoyproxy/gateway/discussions/2425) with @zhaohuabing I understood that two HTTP Routes cannot be applied to the same host/path. It seems I read it wrong, it's just necessary to ensure the uniqueness of the "matches" in the routes. We'll give it a try@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@zetaab: here is the example of how it can be used currently https://gist.github.com/zetaab/31b835bfed5bdc7b2a3e29bc6557e3c1

What that does?
- it can have public paths which is allowed to everyone
- it have /login path which will make the OIDC flow for humans and create cookies
- /api path which will work for both: humans (with cookie) and machines (with bearer token) (with two different identity providers)

However, if its used like this it means that application should have the logic to redirect to /login when its needed to use refresh token / create new token. 

tl;dr it works but its not pretty@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@arkodg: rethinking this one, if we add introduce a field - `skipIfJWTExists`, we can solve this, we may also need to then add another field `myJWTLivesInThisHeader` @zhaohuabing: Yeah, this sounds reasonable to me. I'll raise an issue in Envoy and try to support this in v1.3.0.@arkodg: @zhaohuabing envoy already supports this as highlighted in https://github.com/envoyproxy/gateway/issues/2496#issue-2097680500

An API field is needed in EG that solves the use case@github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@StephenRobin: It looks like the `pass_through_matcher` option in Envoy's oauth2 filter can be used to make this work.

https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/oauth2/v3/oauth.proto#envoy-v3-api-msg-extensions-filters-http-oauth2-v3-oauth2

I've tried the following patch and it appears to be working. 

```
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: EnvoyPatchPolicy
metadata:
  name: oidc-and-jwt-patch
  namespace: example
spec:
  jsonPatches:
    - name: example/eg/https
      operation:
        op: add
        path: >-
          /filter_chains/0/filters/0/typed_config/http_filters/0/typed_config/config/pass_through_matcher
        value:
          - name: Authorization
            prefix_match: Bearer
      type: type.googleapis.com/envoy.config.listener.v3.Listener
  targetRef:
    group: gateway.networking.k8s.io
    kind: Gateway
    name: eg
  type: JSONPatch
```

I guess the fix for this issue is to change the [xds translator](https://github.com/envoyproxy/gateway/blob/main/internal/xds/translator/oidc.go#L135) so that if jwt auth is also specified in the gateway CRD then `pass_through_matcher` is added to the xDS.

~It would be nice if oidc and basic auth also worked well together. In this case the logic would need extending to pass through when "Authorization: Basic...." is in the headers, but the default filter order would also need changing to put the basic auth filter after oidc.~

Edit: scratch that last bit about basic auth... clearly not going to work@arkodg: hey @StephenRobin thanks for driving this work
we'll need an API field to opt into this functionality (ref to https://github.com/envoyproxy/gateway/issues/2496#issuecomment-2392039096 )@StephenRobin: Hi @arkodg, I'm not so sure that a new field makes sense.

This was my thinking:

1. Would there ever be a situation where you would have OIDC enabled and JWT enabled and want `newField=false`? I can't think of one, JWT is broken without it.
2. Would there ever be a situation where you have only OIDC enabled and want `newField=true`? I can't think of one, OIDC is insecure in this case.
3. Why put the new field on OIDC? It feels rather arbitrary, it could equally go on JWT. Its purpose is to make the two work well together.

Therefore it made more sense to just automatically allow oidc bypass when jwt is also enabled.

Thinking about it again, this might be the wrong behaviour if they had either changed the order of the filters, or if they were using the extractFrom feature in JWT.


@arkodg: 
Thanks for thinking this through @StephenRobin 

> Would there ever be a situation where you would have OIDC enabled and JWT enabled and want newField=false? I can't think of one, JWT is broken without it.

There may be a case, when a user may want to force OIDC Auth.

@missBerg @zhaohuabing @denniskniep probably have a better understanding of the spec, but I think there's a chance we **may** want to implicitly add JWT validation in the future if the spec clearly mentions it, this would mean that a separate JWT instantiation is not required.

> Would there ever be a situation where you have only OIDC enabled and want newField=true? I can't think of one, OIDC is insecure in this case.

yes, this is something that can be flagged with a bad status `Accepted=False` for the SecurityPolicy which should also block the route (EG adds a `500` direct response for this case) . 

> Why put the new field on OIDC? It feels rather arbitrary, it could equally go on JWT. Its purpose is to make the two work well together.

It pertains to skipping OIDC Auth, so imo should live in OIDC.

> Thinking about it again, this might be the wrong behaviour if they had either changed the order of the filters, or if they were using the extractFrom feature in JWT.

We can make this intelligent and peek into `extractFrom` and use that to define the skip condition@denniskniep: My thoughts on that:
I would appreciate a default behavior which makes sense in 90% of cases, but still having the ability to adjust to requirement i.e. able to specify a custom name for the authorization header. 


> ExtractFrom defines different ways to extract the JWT token from HTTP request.
If empty, it defaults to extract JWT token from the Authorization HTTP request header using Bearer schema
or access_token from query parameters.

https://gateway.envoyproxy.io/docs/api/extension_types/#jwtprovider 

That means, that `ExtractFrom` is not by default extracting jwt from the OIDC Filter issued Cookie, right?
Does it make sense to add this cookie extractions also by default


> There may be a case, when a user may want to force OIDC Auth

We do have a case where there is an application specific Authorization Header set which has nothing to do with the OIDC/JWT Filter.
That would break the functionality, if OIDC is skipped ALWAYS if that Authorization Header is present. Because the jwt filter then will validate against the jwt filter authority, but the token is issued by the application specific authority. 


Therefore the two proposed fields (`skipIfJWTExists` and `myJWTLivesInThisHeader`) are able to tackle those case as far as I can see.

Would make sense to define good conditional defaults if it´s not explicitly specified i.e.
* skipIfJWTExists = true, when jwt filter exists &&  `ExtractFrom` is not specified 
* myJWTLivesInThisHeader = Authorization, when jwt filter exists &&  `ExtractFrom` is not specified @arkodg: thanks @denniskniep 
imo we can make the boolean `skipIfJWTExists` smart and have it peek into `jwt.extractFrom`, if unset it will skip based on the existance of `Authorization` , else will find the header, cookies, params from `jwt.extractFrom` and skip if those exist@StephenRobin: How about keeping this simple for now, just satisfy the known problems people have actually asked us to resolve:
- Add a property to the oidc config, passThroughAuthHeaders, default to false.
- If passThroughAuthHeaders is true then add a PassThroughMatcher for header "Authorization: Bearer..." to the underlying xDS
- If passThroughAuthHeaders is true and jwt is absent, or jwt.extractFrom is incompatible, then reject the SecurityPolicy with Accepted=false

These criteria will, I believe, ensure we're not breaking or changing the behaviour of existing SecurityPolicy's that people may be running. I'd be particularly concerned about defaulting passThroughAuthHeaders to true if jwt is present, potentially leaving user's configurations insecure if they have some set-up we've not thought of.

These criteria should also make it hard for someone to misconfigure the SecurityPolicy in an insecure way.

I chose the naming to be consistent with the naming of the underlying passThroughMatcher in xDS.

I think this leaves us open to future changes, including those suggested above:
- Adding a second property for the authorization header would be a non breaking change
- Obsoleting passThroughAuthHeaders and exposing the full passThroughMatcher of xDS would be straightforward and non-breaking.
- Relaxing the constraints that reject the SecurityPolicy would be fairly safe (highly unlikely that someone is relying on a policy being blocked). 
- Adding additional constraints would be a breaking but safe change.

I hope this strikes a reasonable balance between the concerns.

Thoughts?@missBerg: What about making it so that the OIDC filter only does anything if there is no token in the Authorization header?

If a request comes in with a token in the Authorization header it should indicate no OIDC should happen.@StephenRobin: Hi @missBerg 

> What about making it so that the OIDC filter only does anything if there is no token in the Authorization header?
> 
> If a request comes in with a token in the Authorization header it should indicate no OIDC should happen.

This is exactly what we're proposing. Envoy itself has a passthroughMatcher option, this can be configured so that if a request comes in with the header "Authorization: Bearer ...." then OIDC does nothing and passes to the next filter for JWT checks.

We propose adding a new option that switches this on (as users may not always want it) and also making it an error to specify this new option when JWT is switched off or not configured to read that header.@arkodg: hey @StephenRobin your proposal looks good@denniskniep: >If a request comes in with a token in the Authorization header it should indicate no OIDC should happen.

That would break our setup. We do have an application which does some app specific stuff with the Authorization-Header. But regardless what the app is doing with the Authorization-Header we want to authenticate with OIDC Filter and use the JWT Filter to extract the idtoken from the cookie and do authorization decisions.@StephenRobin: > That would break our setup

Hi @denniskniep, as I said above, the behaviour will be unchanged unless the new `passThroughAuthHeaders` option is specified.@denniskniep: @StephenRobin  you are right, it will not break if I do not use the new `passThroughAuthHeaders` option.

But if I want to use the new option, then it breaks, because the auth header is already used differently by the upstream.
Therefore it would be good if one could specify in which header the jwt lives (agree that it makes sense to set it by default to Authorization).@arkodg: So @denniskniep you're referring to a case where multiple auth tokens exist ? @denniskniep: exactly, one which is controlled by the upstream app (I have no control over this) and the upstream app is using the Authorization header for this.

And another one which is then used for envoy auth inside jwt filter. Because Authorization header is already used, it should be possible to specify an alternative header @arkodg: I think thats fine @denniskniep , this is my assumption of the API, please keep me honest here @StephenRobin
* by default `passThroughAuthHeaders` is `false`, and `oidc` is not skipped
* if `passThroughAuthHeaders:true` and `jwt.providers[*].extractFrom: nil` then skip if `Authorization` exists
* if `passThroughAuthHeaders:true` and `jwt.providers[*].extractFrom: <some header/cookie/param>` then skip if that header/cookie/param exists
since `providers` is based off `OR` logic, if any provider auth is successful, then the request is accepted, similarly all `extractFrom` will need to be `OR`d here as well@denniskniep: We probably don't want to skip oidc filter if the cookie, which was issued by the oidc filter, is in the extractFrom of the jwt filter, right?@StephenRobin: Ok. That makes sense.

One alternative to consider is just exposing the full pass_through_matcher of Envoy, giving the user complete control.

I think Envoy doesn't support pass through based on params, and I agree with @denniskniep that cookies could be problematic, so I think it should just `OR` the set of headers specified in `extractFrom`.

I'm going to code this now, seems like a good Friday afternoon task. I can always change it later.@StephenRobin: Implemented in #5142 @github-actions: This issue has been automatically marked as stale because it has not had activity in the last 30 days.@zhaohuabing: > We probably don't want to skip oidc filter if the cookie, which was issued by the oidc filter, is in the extractFrom of the jwt filter, right?

The ID and access cookies only exist after the OIDC authentication, so it's OK to skip OIDC if they're already in the request.@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/OWNERS b/OWNERS
index bd29b3425..5e4a3b86e 100644
--- a/OWNERS
+++ b/OWNERS
@@ -15,6 +15,8 @@ maintainers:
 - zhaohuabing
 - guydc
 - shawnh2
+- cnvergence
+- rudrakhp
 
 emeritus-maintainers:
 
@@ -30,9 +32,7 @@ reviewers:
 
 - kflynn
 - tanujd11
-- cnvergence
 - liorokman
-- rudrakhp
 
 emeritus-reviewers:
 
diff --git a/api/v1alpha1/oidc_types.go b/api/v1alpha1/oidc_types.go
index de05eb167..11fb22051 100644
--- a/api/v1alpha1/oidc_types.go
+++ b/api/v1alpha1/oidc_types.go
@@ -102,6 +102,16 @@ type OIDC struct {
 	// Note: this field is only applicable when the "refreshToken" field is set to true.
 	// +optional
 	DefaultRefreshTokenTTL *metav1.Duration `json:"defaultRefreshTokenTTL,omitempty"`
+
+	// Skips OIDC authentication when the request contains a header that will be extracted by the JWT filter. Unless
+	// explicitly stated otherwise in the extractFrom field, this will be the "Authorization: Bearer ..." header.
+	//
+	// The passThroughAuthHeader option is typically used for non-browser clients that may not be able to handle OIDC
+	// redirects and wish to directly supply a token instead.
+	//
+	// If not specified, defaults to false.
+	// +optional
+	PassThroughAuthHeader *bool `json:"passThroughAuthHeader,omitempty"`
 }
 
 // OIDCProvider defines the OIDC Provider configuration.
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 87c150497..322875c03 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -4419,6 +4419,11 @@ func (in *OIDC) DeepCopyInto(out *OIDC) {
 		*out = new(metav1.Duration)
 		**out = **in
 	}
+	if in.PassThroughAuthHeader != nil {
+		in, out := &in.PassThroughAuthHeader, &out.PassThroughAuthHeader
+		*out = new(bool)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OIDC.
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 a63bf9162..5e4f67965 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
@@ -3626,6 +3626,16 @@ spec:
 
                       If not specified, uses a default logout path "/logout"
                     type: string
+                  passThroughAuthHeader:
+                    description: |-
+                      Skips OIDC authentication when the request contains a header that will be extracted by the JWT filter. Unless
+                      explicitly stated otherwise in the extractFrom field, this will be the "Authorization: Bearer ..." header.
+
+                      The passThroughAuthHeader option is typically used for non-browser clients that may not be able to handle OIDC
+                      redirects and wish to directly supply a token instead.
+
+                      If not specified, defaults to false.
+                    type: boolean
                   provider:
                     description: The OIDC Provider configuration.
                     properties:
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 da26002a0..accfa3aa2 100644
--- a/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml
+++ b/charts/gateway-helm/crds/generated/gateway.envoyproxy.io_securitypolicies.yaml
@@ -3625,6 +3625,16 @@ spec:
 
                       If not specified, uses a default logout path "/logout"
                     type: string
+                  passThroughAuthHeader:
+                    description: |-
+                      Skips OIDC authentication when the request contains a header that will be extracted by the JWT filter. Unless
+                      explicitly stated otherwise in the extractFrom field, this will be the "Authorization: Bearer ..." header.
+
+                      The passThroughAuthHeader option is typically used for non-browser clients that may not be able to handle OIDC
+                      redirects and wish to directly supply a token instead.
+
+                      If not specified, defaults to false.
+                    type: boolean
                   provider:
                     description: The OIDC Provider configuration.
                     properties:
diff --git a/examples/kubernetes/gateway-namespace-mode.yaml b/examples/kubernetes/gateway-namespace-mode.yaml
new file mode 100644
index 000000000..f7c8e1a83
--- /dev/null
+++ b/examples/kubernetes/gateway-namespace-mode.yaml
@@ -0,0 +1,192 @@
+apiVersion: gateway.networking.k8s.io/v1
+kind: GatewayClass
+metadata:
+  name: eg
+spec:
+  controllerName: gateway.envoyproxy.io/gatewayclass-controller
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: team-a-backend
+  namespace: team-a
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: team-a-backend
+  namespace: team-a
+  labels:
+    app: team-a-backend
+    service: team-a-backend
+spec:
+  ports:
+    - name: http
+      port: 3000
+      targetPort: 3000
+  selector:
+    app: team-a-backend
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: team-a-backend
+  namespace: team-a
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: team-a-backend
+      version: v1
+  template:
+    metadata:
+      labels:
+        app: team-a-backend
+        version: v1
+    spec:
+      serviceAccountName: team-a-backend
+      containers:
+        - image: gcr.io/k8s-staging-gateway-api/echo-basic:v20231214-v1.0.0-140-gf544a46e
+          imagePullPolicy: IfNotPresent
+          name: team-a-backend
+          ports:
+            - containerPort: 3000
+          env:
+            - name: POD_NAME
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.name
+            - name: NAMESPACE
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.namespace
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  name: team-b-backend
+  namespace: team-b
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: team-b-backend
+  namespace: team-b
+  labels:
+    app: team-b-backend
+    service: team-b-backend
+spec:
+  ports:
+    - name: http
+      port: 3000
+      targetPort: 3000
+  selector:
+    app: team-b-backend
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: team-b-backend
+  namespace: team-b
+spec:
+  replicas: 1
+  selector:
+    matchLabels:
+      app: team-b-backend
+      version: v1
+  template:
+    metadata:
+      labels:
+        app: team-b-backend
+        version: v1
+    spec:
+      serviceAccountName: team-b-backend
+      containers:
+        - image: gcr.io/k8s-staging-gateway-api/echo-basic:v20231214-v1.0.0-140-gf544a46e
+          imagePullPolicy: IfNotPresent
+          name: team-b-backend
+          ports:
+            - containerPort: 3000
+          env:
+            - name: POD_NAME
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.name
+            - name: NAMESPACE
+              valueFrom:
+                fieldRef:
+                  fieldPath: metadata.namespace
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+  name: gateway-a
+  namespace: team-a
+spec:
+  gatewayClassName: eg
+  listeners:
+    - allowedRoutes:
+        namespaces:
+          from: Same
+      name: http
+      port: 8080
+      protocol: HTTP
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+  name: gateway-b
+  namespace: team-b
+spec:
+  gatewayClassName: eg
+  listeners:
+    - allowedRoutes:
+        namespaces:
+          from: Same
+      name: http
+      port: 8081
+      protocol: HTTP
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: team-a-route
+  namespace: team-a
+spec:
+  parentRefs:
+    - name: gateway-a
+  hostnames:
+    - "www.team-a.com"
+  rules:
+    - backendRefs:
+        - group: ""
+          kind: Service
+          name: team-a-backend
+          port: 3000
+          weight: 1
+      matches:
+        - path:
+            type: PathPrefix
+            value: /example
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: team-b-route
+  namespace: team-b
+spec:
+  parentRefs:
+    - name: gateway-b
+  hostnames:
+    - "www.team-b.com"
+  rules:
+    - backendRefs:
+        - group: ""
+          kind: Service
+          name: team-b-backend
+          port: 3000
+          weight: 1
+      matches:
+        - path:
+            type: PathPrefix
+            value: /example
diff --git a/go.mod b/go.mod
index 808c826c4..2ad40b462 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,6 @@ go 1.24.2
 require (
 	fortio.org/fortio v1.69.4
 	fortio.org/log v1.17.2
-	github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24
 	github.com/Masterminds/semver/v3 v3.3.1
 	github.com/andybalholm/brotli v1.1.1
 	github.com/avast/retry-go v3.0.0+incompatible
@@ -111,6 +110,7 @@ require (
 	fortio.org/version v1.0.4 // indirect
 	github.com/4meepo/tagalign v1.4.2 // indirect
 	github.com/Abirdcfly/dupword v0.1.3 // indirect
+	github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
 	github.com/Antonboom/errname v1.1.0 // indirect
 	github.com/Antonboom/nilnil v1.1.0 // indirect
 	github.com/Antonboom/testifylint v1.6.0 // indirect
diff --git a/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go
index e2dd18b2b..fce38a2e5 100644
--- a/internal/gatewayapi/securitypolicy.go
+++ b/internal/gatewayapi/securitypolicy.go
@@ -39,11 +39,12 @@ import (
 )
 
 const (
-	defaultRedirectURL        = "%REQ(x-forwarded-proto)%://%REQ(:authority)%/oauth2/callback"
-	defaultRedirectPath       = "/oauth2/callback"
-	defaultLogoutPath         = "/logout"
-	defaultForwardAccessToken = false
-	defaultRefreshToken       = false
+	defaultRedirectURL           = "%REQ(x-forwarded-proto)%://%REQ(:authority)%/oauth2/callback"
+	defaultRedirectPath          = "/oauth2/callback"
+	defaultLogoutPath            = "/logout"
+	defaultForwardAccessToken    = false
+	defaultRefreshToken          = false
+	defaultPassThroughAuthHeader = false
 
 	// nolint: gosec
 	oidcHMACSecretName = "envoy-oidc-hmac"
@@ -270,6 +271,27 @@ func validateSecurityPolicy(p *egv1a1.SecurityPolicy) error {
 			return err
 		}
 	}
+
+	oidc := p.Spec.OIDC
+	jwt := p.Spec.JWT
+	if oidc != nil && oidc.PassThroughAuthHeader != nil && *oidc.PassThroughAuthHeader {
+		if jwt == nil {
+			return errors.New("the OIDC.PassThroughAuthHeader setting must be used in conjunction with JWT settings")
+		}
+
+		hasValidJwtExtractor := false
+		for _, provider := range jwt.Providers {
+			// When ExtractFrom is not specified it falls back to looking at the "Authorization: Bearer ..." header
+			if provider.ExtractFrom == nil || len(provider.ExtractFrom.Headers) > 0 {
+				hasValidJwtExtractor = true
+				break
+			}
+		}
+		if !hasValidJwtExtractor {
+			return errors.New("the OIDC.PassThroughAuthHeader setting must be used in conjunction with a JWT provider that is configured to read from a header")
+		}
+	}
+
 	return nil
 }
 
@@ -843,15 +865,16 @@ func (t *Translator) buildOIDC(
 	envoyProxy *egv1a1.EnvoyProxy,
 ) (*ir.OIDC, error) {
 	var (
-		oidc               = policy.Spec.OIDC
-		provider           *ir.OIDCProvider
-		clientSecret       *corev1.Secret
-		redirectURL        = defaultRedirectURL
-		redirectPath       = defaultRedirectPath
-		logoutPath         = defaultLogoutPath
-		forwardAccessToken = defaultForwardAccessToken
-		refreshToken       = defaultRefreshToken
-		err                error
+		oidc                  = policy.Spec.OIDC
+		provider              *ir.OIDCProvider
+		clientSecret          *corev1.Secret
+		redirectURL           = defaultRedirectURL
+		redirectPath          = defaultRedirectPath
+		logoutPath            = defaultLogoutPath
+		forwardAccessToken    = defaultForwardAccessToken
+		refreshToken          = defaultRefreshToken
+		passThroughAuthHeader = defaultPassThroughAuthHeader
+		err                   error
 	)
 
 	if provider, err = t.buildOIDCProvider(policy, resources, envoyProxy); err != nil {
@@ -896,6 +919,10 @@ func (t *Translator) buildOIDC(
 		refreshToken = *oidc.RefreshToken
 	}
 
+	if oidc.PassThroughAuthHeader != nil {
+		passThroughAuthHeader = *oidc.PassThroughAuthHeader
+	}
+
 	// Generate a unique cookie suffix for oauth filters.
 	// This is to avoid cookie name collision when multiple security policies are applied
 	// to the same route.
@@ -933,6 +960,7 @@ func (t *Translator) buildOIDC(
 		CookieNameOverrides:    policy.Spec.OIDC.CookieNames,
 		CookieDomain:           policy.Spec.OIDC.CookieDomain,
 		HMACSecret:             hmacData,
+		PassThroughAuthHeader:  passThroughAuthHeader,
 	}, nil
 }
 
diff --git a/internal/ir/xds.go b/internal/ir/xds.go
index 30bd1d1fc..b3759e45f 100644
--- a/internal/ir/xds.go
+++ b/internal/ir/xds.go
@@ -1094,6 +1094,11 @@ type OIDC struct {
 
 	// CookieDomain sets the domain of the cookies set by the oauth filter.
 	CookieDomain *string `json:"cookieDomain,omitempty"`
+
+	// Skips OIDC authentication when the request contains any header that will be extracted by the JWT
+	// filter, normally "Authorization: Bearer ...". This is typically used for non-browser clients that
+	// may not be able to handle OIDC redirects and wish to directly supply a token instead.
+	PassThroughAuthHeader bool `json:"passThroughAuthHeader,omitempty"`
 }
 
 // OIDCProvider defines the schema for the OIDC Provider.
diff --git a/internal/utils/helm/package.go b/internal/utils/helm/package.go
index 68d769a4b..11a2af4da 100644
--- a/internal/utils/helm/package.go
+++ b/internal/utils/helm/package.go
@@ -66,12 +66,14 @@ type PackageTool struct {
 	logger Printer
 }
 
-func NewPackageTool() *PackageTool {
+func NewPackageTool(valueFiles ...string) *PackageTool {
 	return &PackageTool{
 		envSettings:  cli.New(),
 		actionConfig: &action.Configuration{},
 		chartName:    "oci://docker.io/envoyproxy/gateway-helm",
-		valuesOpts:   &values.Options{},
+		valuesOpts: &values.Options{
+			ValueFiles: valueFiles,
+		},
 	}
 }
 
@@ -256,6 +258,7 @@ func (pt *PackageTool) RunInstall(opts *PackageOptions) error {
 		return err
 	}
 
+	pt.logger.Println(fmt.Sprintf("Install with values: %v", egChartValues))
 	pt.setInstallOptions(opts)
 	release, err := pt.actionInstall.Run(egChart, egChartValues)
 	if err != nil {
diff --git a/internal/wasm/premissioncache.go b/internal/wasm/premissioncache.go
index 053b3880c..e680574ad 100644
--- a/internal/wasm/premissioncache.go
+++ b/internal/wasm/premissioncache.go
@@ -71,9 +71,6 @@ type permissionCache struct {
 
 	cache  map[string]*permissionCacheEntry
 	logger logging.Logger
-
-	// For tests
-	triggerChan chan struct{}
 }
 
 // permissionCacheEntry is an entry in the permission cache.
@@ -128,7 +125,6 @@ func newPermissionCache(options permissionCacheOptions, logger logging.Logger) *
 		cache:                  make(map[string]*permissionCacheEntry),
 		permissionCacheOptions: options,
 		logger:                 logger,
-		triggerChan:            make(chan struct{}),
 	}
 }
 
@@ -148,11 +144,48 @@ func (p *permissionCache) Start(ctx context.Context) {
 		defer ticker.Stop()
 		for {
 			select {
-			// explicitly trigger a check for tests
-			case <-p.triggerChan:
-				p.checkPermissions(ctx)
 			case <-ticker.C:
-				p.checkPermissions(ctx)
+				func() {
+					p.Lock()
+					defer p.Unlock()
+					for _, e := range p.cache {
+						if e.isCacheExpired(p.cacheExpiry) {
+							p.logger.Info("removing permission cache entry", "image", e.image.String())
+							delete(p.cache, e.key())
+							continue
+						}
+						if e.isPermissionExpired(p.permissionExpiry) {
+							const retryAttempts = 3
+							const retryDelay = 1 * time.Second
+							p.logger.Info("rechecking permission for image", "image", e.image.String())
+							err := retry.Do(
+								func() error {
+									err := p.checkAndUpdatePermission(ctx, e)
+									if err != nil && isRetriableError(err) {
+										p.logger.Error(
+											err,
+											"failed to check permission for image, will retry again",
+											"image",
+											e.image.String())
+										return err
+									}
+									return nil
+								},
+								retry.Attempts(retryAttempts),
+								retry.DelayType(retry.BackOffDelay),
+								retry.Delay(retryDelay),
+								retry.Context(ctx),
+							)
+							if err != nil {
+								p.logger.Error(
+									err,
+									fmt.Sprintf("failed to recheck permission for image after %d attempts", retryAttempts),
+									"image",
+									e.image.String())
+							}
+						}
+					}
+				}()
 			case <-ctx.Done():
 				return
 			}
@@ -160,48 +193,6 @@ func (p *permissionCache) Start(ctx context.Context) {
 	}()
 }
 
-func (p *permissionCache) checkPermissions(ctx context.Context) {
-	p.Lock()
-	defer p.Unlock()
-	for _, e := range p.cache {
-		if e.isCacheExpired(p.cacheExpiry) {
-			p.logger.Info("removing permission cache entry", "image", e.image.String())
-			delete(p.cache, e.key())
-			continue
-		}
-		if e.isPermissionExpired(p.permissionExpiry) {
-			const retryAttempts = 3
-			const retryDelay = 1 * time.Second
-			p.logger.Info("rechecking permission for image", "image", e.image.String())
-			err := retry.Do(
-				func() error {
-					err := p.checkAndUpdatePermission(ctx, e)
-					if err != nil && isRetriableError(err) {
-						p.logger.Error(
-							err,
-							"failed to check permission for image, will retry again",
-							"image",
-							e.image.String())
-						return err
-					}
-					return nil
-				},
-				retry.Attempts(retryAttempts),
-				retry.DelayType(retry.BackOffDelay),
-				retry.Delay(retryDelay),
-				retry.Context(ctx),
-			)
-			if err != nil {
-				p.logger.Error(
-					err,
-					fmt.Sprintf("failed to recheck permission for image after %d attempts", retryAttempts),
-					"image",
-					e.image.String())
-			}
-		}
-	}
-}
-
 // isRetriableError checks if the error is retriable.
 // If the error is a permission error, it's not retriable. For example, 401 and 403 HTTP status code.
 func isRetriableError(err error) bool {
diff --git a/internal/xds/translator/oidc.go b/internal/xds/translator/oidc.go
index 60527be07..b5082361e 100644
--- a/internal/xds/translator/oidc.go
+++ b/internal/xds/translator/oidc.go
@@ -60,7 +60,7 @@ func (*oidc) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListe
 			continue
 		}
 
-		filter, err := buildHCMOAuth2Filter(route.Security.OIDC)
+		filter, err := buildHCMOAuth2Filter(route.Security)
 		if err != nil {
 			errs = errors.Join(errs, err)
 			continue
@@ -73,8 +73,8 @@ func (*oidc) patchHCM(mgr *hcmv3.HttpConnectionManager, irListener *ir.HTTPListe
 }
 
 // buildHCMOAuth2Filter returns an OAuth2 HTTP filter from the provided IR HTTPRoute.
-func buildHCMOAuth2Filter(oidc *ir.OIDC) (*hcmv3.HttpFilter, error) {
-	oauth2Proto, err := oauth2Config(oidc)
+func buildHCMOAuth2Filter(securityFeatures *ir.SecurityFeatures) (*hcmv3.HttpFilter, error) {
+	oauth2Proto, err := oauth2Config(securityFeatures)
 	if err != nil {
 		return nil, err
 	}
@@ -85,7 +85,7 @@ func buildHCMOAuth2Filter(oidc *ir.OIDC) (*hcmv3.HttpFilter, error) {
 	}
 
 	return &hcmv3.HttpFilter{
-		Name:     oauth2FilterName(oidc),
+		Name:     oauth2FilterName(securityFeatures.OIDC),
 		Disabled: true,
 		ConfigType: &hcmv3.HttpFilter_TypedConfig{
 			TypedConfig: OAuth2Any,
@@ -97,12 +97,14 @@ func oauth2FilterName(oidc *ir.OIDC) string {
 	return perRouteFilterName(egv1a1.EnvoyFilterOAuth2, oidc.Name)
 }
 
-func oauth2Config(oidc *ir.OIDC) (*oauth2v3.OAuth2, error) {
+func oauth2Config(securityFeatures *ir.SecurityFeatures) (*oauth2v3.OAuth2, error) {
 	var (
 		tokenEndpointCluster string
 		err                  error
 	)
 
+	oidc := securityFeatures.OIDC
+
 	if oidc.Provider.Destination != nil && len(oidc.Provider.Destination.Settings) > 0 {
 		tokenEndpointCluster = oidc.Provider.Destination.Name
 	} else {
@@ -221,9 +223,48 @@ func oauth2Config(oidc *ir.OIDC) (*oauth2v3.OAuth2, error) {
 		}
 		oauth2.Config.RetryPolicy = rp
 	}
+
+	if oidc.PassThroughAuthHeader {
+		oauth2.Config.PassThroughMatcher = buildHeaderMatchers(securityFeatures.JWT)
+	}
+
 	return oauth2, nil
 }
 
+func buildHeaderMatchers(jwt *ir.JWT) []*routev3.HeaderMatcher {
+	// Bypass OIDC if a header that will be handled by JWT is passed.
+	passThroughMatchers := make([]*routev3.HeaderMatcher, 0, len(jwt.Providers))
+
+	for _, provider := range jwt.Providers {
+		if provider.ExtractFrom == nil {
+			// If extractFrom is not specified, it adds "Authorization: Bearer ..." as a default
+			stringMatcher := matcherv3.StringMatcher{MatchPattern: &matcherv3.StringMatcher_Prefix{Prefix: "Bearer "}}
+			headerMatcher := routev3.HeaderMatcher{
+				Name:                 "Authorization",
+				HeaderMatchSpecifier: &routev3.HeaderMatcher_StringMatch{StringMatch: &stringMatcher},
+			}
+			passThroughMatchers = append(passThroughMatchers, &headerMatcher)
+		} else {
+			// Any matching header will be bypassed (JWT effectively OR's them).
+			for _, extractHeader := range provider.ExtractFrom.Headers {
+				if extractHeader.ValuePrefix == nil {
+					headerMatcher := routev3.HeaderMatcher{Name: extractHeader.Name}
+					passThroughMatchers = append(passThroughMatchers, &headerMatcher)
+				} else {
+					stringMatcher := matcherv3.StringMatcher{MatchPattern: &matcherv3.StringMatcher_Prefix{Prefix: *extractHeader.ValuePrefix}}
+					headerMatcher := routev3.HeaderMatcher{
+						Name:                 extractHeader.Name,
+						HeaderMatchSpecifier: &routev3.HeaderMatcher_StringMatch{StringMatch: &stringMatcher},
+					}
+					passThroughMatchers = append(passThroughMatchers, &headerMatcher)
+				}
+			}
+		}
+	}
+
+	return passThroughMatchers
+}
+
 func buildNonRouteRetryPolicy(rr *ir.Retry) (*corev3.RetryPolicy, error) {
 	rp := &corev3.RetryPolicy{
 		RetryOn: retryDefaultRetryOn,
diff --git a/release-notes/current.yaml b/release-notes/current.yaml
index 3271a4f90..818799641 100644
--- a/release-notes/current.yaml
+++ b/release-notes/current.yaml
@@ -8,6 +8,8 @@ security updates: |
 
 # New features or capabilities added in this release.
 new features: |
+  Added support for percentage-based request mirroring
+  Add an option to OIDC authentication to bypass it and defer to JWT when the request contains an "Authorization: Bearer ..." header.
 
 bug fixes: |
 
diff --git a/site/content/en/contributions/CODEOWNERS.md b/site/content/en/contributions/CODEOWNERS.md
index 071532f02..e5d6f0af6 100644
--- a/site/content/en/contributions/CODEOWNERS.md
+++ b/site/content/en/contributions/CODEOWNERS.md
@@ -6,10 +6,12 @@ description: "This section includes Maintainers of Envoy Gateway."
 ## The following maintainers, listed in alphabetical order, own everything
 
 - @arkodg
-- @qicz
 - @Xunzhuo
 - @zirain
 - @zhaohuabing
+- @guydc
+- @cnvergence
+- @rudrakhp
 
 ## Emeritus Maintainers
 
diff --git a/site/content/en/v1.4/api/extension_types.md b/site/content/en/v1.4/api/extension_types.md
index 4858d5b3d..9441bf08d 100644
--- a/site/content/en/v1.4/api/extension_types.md
+++ b/site/content/en/v1.4/api/extension_types.md
@@ -3146,6 +3146,7 @@ _Appears in:_
 | `defaultTokenTTL` | _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#duration-v1-meta)_ |  false  |  | DefaultTokenTTL is the default lifetime of the id token and access token.<br />Please note that Envoy will always use the expiry time from the response<br />of the authorization server if it is provided. This field is only used when<br />the expiry time is not provided by the authorization.<br />If not specified, defaults to 0. In this case, the "expires_in" field in<br />the authorization response must be set by the authorization server, or the<br />OAuth flow will fail. |
 | `refreshToken` | _boolean_ |  false  |  | RefreshToken indicates whether the Envoy should automatically refresh the<br />id token and access token when they expire.<br />When set to true, the Envoy will use the refresh token to get a new id token<br />and access token when they expire.<br />If not specified, defaults to false. |
 | `defaultRefreshTokenTTL` | _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#duration-v1-meta)_ |  false  |  | DefaultRefreshTokenTTL is the default lifetime of the refresh token.<br />This field is only used when the exp (expiration time) claim is omitted in<br />the refresh token or the refresh token is not JWT.<br />If not specified, defaults to 604800s (one week).<br />Note: this field is only applicable when the "refreshToken" field is set to true. |
+| `passThroughAuthHeader` | _boolean_ |  false  |  | Skips OIDC authentication when the request contains any header that will be extracted by the JWT<br />filter, normally "Authorization: Bearer ...". This is typically used for non-browser clients that<br />may not be able to handle OIDC redirects and wish to directly supply a token instead.<br /><br />If not specified, defaults to false. |
 
 
 #### OIDCCookieNames
diff --git a/site/content/en/v1.4/tasks/operations/deployment-mode.md b/site/content/en/v1.4/tasks/operations/deployment-mode.md
index c50d65b28..725f7c3cb 100644
--- a/site/content/en/v1.4/tasks/operations/deployment-mode.md
+++ b/site/content/en/v1.4/tasks/operations/deployment-mode.md
@@ -4,18 +4,22 @@ title: "Deployment Mode"
 ## Deployment modes
 
 ### One GatewayClass per Envoy Gateway Controller
+
 * An Envoy Gateway is associated with a single [GatewayClass][] resource under one controller.
 This is the simplest deployment mode and is suitable for scenarios where each Gateway needs to have its own dedicated set of resources and configurations.
 
 ### Multiple GatewayClasses per Envoy Gateway Controller
+
 * An Envoy Gateway is associated with multiple [GatewayClass][] resources under one controller.
 * Support for accepting multiple GatewayClasses was added [here][issue1231].
 
 ### Separate Envoy Gateway Controllers
+
 If you've instantiated multiple GatewayClasses, you can also run separate Envoy Gateway controllers in different namespaces, linking a GatewayClass to each of them for multi-tenancy.
 Please follow the example [Multi-tenancy](#multi-tenancy).
 
 ### Merged Gateways onto a single EnvoyProxy fleet
+
 By default, each Gateway has its own dedicated set of Envoy Proxy and its configurations.
 However, for some deployments, it may be more convenient to merge listeners across multiple Gateways and deploy a single Envoy Proxy fleet.
 
@@ -26,6 +30,14 @@ Setting the `mergeGateways` field in the EnvoyProxy resource linked to GatewayCl
 
 Please follow the example [Merged gateways deployment](#merged-gateways-deployment).
 
+### Gateway Namespace Mode
+
+Gateway Namespace Mode is a deployment model for Envoy Gateway that creates Envoy Proxy infrastructure resources like Deployments, Services and ServiceAccounts in the namespace where each Gateway resource is defined, rather than in the Envoy Gateway controller namespace.
+
+* Support for this deployment mode was added [here][issue2629].
+
+Please follow the example [Gateway Namespace Mode][].
+
 ### Supported Modes
 
 #### Kubernetes
@@ -34,7 +46,6 @@ Please follow the example [Merged gateways deployment](#merged-gateways-deployme
 and **creates** managed data plane resources such as EnvoyProxy `Deployment` in the **namespace where Envoy Gateway is running**.
 * Envoy Gateway also supports [Namespaced deployment mode][], you can watch resources in the specific namespaces by assigning
 `EnvoyGateway.provider.kubernetes.watch.namespaces` or `EnvoyGateway.provider.kubernetes.watch.namespaceSelector` and **creates** managed data plane resources in the **namespace where Envoy Gateway is running**.
-* Support for alternate deployment modes is being tracked [here][issue1117].
 
 ### Multi-tenancy
 
@@ -1068,5 +1079,6 @@ curl --header "Host: www.merged3.com" http://$GATEWAY_HOST:8082/example3
 [EnvoyProxy]: ../../api/extension_types#envoyproxy
 [GatewayClass]: https://gateway-api.sigs.k8s.io/api-types/gatewayclass/
 [Namespaced deployment mode]: ../../api/extension_types#kuberneteswatchmode
+[Gateway Namespace Mode]: ./gateway-namespace-mode
 [issue1231]: https://github.com/envoyproxy/gateway/issues/1231
-[issue1117]: https://github.com/envoyproxy/gateway/issues/1117
+[issue2629]: https://github.com/envoyproxy/gateway/issues/2629
diff --git a/site/content/en/v1.4/tasks/operations/gateway-namespace-mode.md b/site/content/en/v1.4/tasks/operations/gateway-namespace-mode.md
new file mode 100644
index 000000000..ad7f3cd51
--- /dev/null
+++ b/site/content/en/v1.4/tasks/operations/gateway-namespace-mode.md
@@ -0,0 +1,270 @@
+---
+title: "Gateway Namespace Mode"
+---
+
+{{% alert title="Notice" color="warning" %}}
+
+Gateway Namespace Mode is currently an **alpha** feature. We recommend against using it in production workloads until it reaches beta status.
+
+For status updates or to provide feedback, please follow our [GitHub issues](https://github.com/envoyproxy/gateway/issues).
+
+{{% /alert %}}
+
+## Overview
+
+In standard deployment mode, Envoy Gateway creates all data plane resources in the controller namespace (typically `envoy-gateway-system`).
+
+Gateway Namespace Mode changes this behavior by placing Envoy Proxy data plane resources like Deployments, Services and ServiceAccounts in each Gateway's namespace, providing stronger isolation and multi-tenancy.
+
+Traditional deployment mode uses mTLS where both the client and server authenticate each other. However, in Gateway Namespace Mode, we've shifted to server-side TLS and JWT token validation between infra and control-plane.
+
+* Only the CA certificate is available in pods running in Gateway namespaces
+* Client certificates are not mounted in these namespaces
+* The Envoy proxy still validates server certificates using the CA certificate
+
+Gateway Namespace Mode uses projected service account JWT tokens for authentication.
+* Use short-lived, audience-specific JWT tokens. These tokens are automatically mounted into pods via the projected volume mechanism
+* JWT validation ensures that only authorized proxies can connect to the xDS server
+
+{{% alert title="Note" color="warning" %}}
+
+Currently it is not supported to run Gateway Namespace Mode with Merged Gateways deployments.
+
+{{% /alert %}}
+
+## Configuration
+
+To enable Gateway Namespace Mode, configure the `provider.kubernetes.deploy.type` field in your Envoy Gateway Helm chart values:
+
+```yaml
+config:
+  envoyGateway:
+    provider:
+      type: Kubernetes
+      kubernetes:
+        deploy:
+          type: GatewayNamespace
+```
+
+You can also use this command to install Envoy Gateway with Gateway Namespace Mode using Helm:
+
+```shell
+helm install \
+  --set config.envoyGateway.provider.kubernetes.deploy.type=GatewayNamespace \
+  eg oci://docker.io/envoyproxy/gateway-helm \
+  --version {{< helm-version >}} -n envoy-gateway-system --create-namespace
+```
+
+### RBAC configuration
+
+When using Gateway Namespace Mode, Envoy Gateway needs additional RBAC permissions to create and manage resources across different namespaces. The following RBAC resources are automatically created when installing Envoy Gateway Helm Chart with Gateway Namespace Mode enabled.
+
+```yaml
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: gateway-helm-cluster-infra-manager
+rules:
+- apiGroups: [""]
+  resources: ["serviceaccounts", "services", "configmaps"]
+  verbs: ["create", "get", "delete", "deletecollection", "patch"]
+- apiGroups: ["apps"]
+  resources: ["deployments", "daemonsets"]
+  verbs: ["create", "get", "delete", "deletecollection", "patch"]
+- apiGroups: ["autoscaling", "policy"]
+  resources: ["horizontalpodautoscalers", "poddisruptionbudgets"]
+  verbs: ["create", "get", "delete", "deletecollection", "patch"]
+- apiGroups: ["authentication.k8s.io"]
+  resources: ["tokenreviews"]
+  verbs: ["create"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: gateway-helm-cluster-infra-manager
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: 'gateway-helm-cluster-infra-manager'
+subjects:
+- kind: ServiceAccount
+  name: 'envoy-gateway'
+  namespace: 'envoy-gateway-system'
+```
+
+Envoy Gateway also supports configuration to you only watch resources in the specific namespaces by assigning
+`EnvoyGateway.provider.kubernetes.watch.namespaces` or `EnvoyGateway.provider.kubernetes.watch.namespaceSelector`.
+In this case, when you specify this configuration with Gateway Namespace Mode,Envoy Gateway will only watch for Gateway API resources in the specified namespaces and create needed Roles for infrastructure management in the specified namespaces.
+
+## Testing
+
+The following example demonstrates deploying two Gateways in different namespaces `team-a` and `team-b`.
+
+### Create test namespaces
+
+```shell
+kubectl create namespace team-a
+kubectl create namespace team-b
+```
+
+### Deploy Gateway Namespace Mode Example
+
+Deploy resources on your cluster from the example, it will create two sets of backend deployments, Gateways and their respective HTTPRoutes in the previously created namespaces `team-a` and `team-b`.
+
+```shell
+kubectl apply -f https://raw.githubusercontent.com/envoyproxy/gateway/{{< yaml-version >}}/examples/kubernetes/gateway-namespace-mode.yaml
+```
+
+Verify that Gateways are deployed and programmed
+
+```shell
+kubectl get gateways -n team-a
+
+NAME        CLASS   ADDRESS        PROGRAMMED   AGE
+gateway-a   eg      172.18.0.200   True         67s
+```
+
+```shell
+kubectl get gateways -n team-b
+
+NAME        CLASS   ADDRESS        PROGRAMMED   AGE
+gateway-b   eg      172.18.0.201   True         67s
+```
+
+Verify that HTTPRoutes are deployed
+
+```shell
+kubectl get httproute -n team-a
+
+NAME           HOSTNAMES            AGE
+team-a-route   ["www.team-a.com"]   67s
+```
+
+```shell
+kubectl get httproute -n team-b
+
+NAME           HOSTNAMES            AGE
+team-b-route   ["www.team-b.com"]   67s
+```
+
+Envoy Proxy resources should be created now in the namespace of every Gateway.
+
+```shell
+kubectl get pods -n team-a
+
+NAME                                              READY   STATUS    RESTARTS   AGE
+envoy-team-a-gateway-a-b65c6264-d56f5d989-6dv5s   2/2     Running   0          65s
+team-a-backend-6f786fb76f-nx26p                   1/1     Running   0          65s
+```
+
+```shell
+kubectl get pods -n team-b
+
+NAME                                               READY   STATUS    RESTARTS   AGE
+envoy-team-b-gateway-b-0ac91f5a-74f445884f-95pl8   2/2     Running   0          87s
+team-b-backend-966b5f47c-zxngl                     1/1     Running   0          87s
+```
+
+```shell
+kubectl get services -n team-a
+
+NAME                              TYPE           CLUSTER-IP      EXTERNAL-IP    PORT(S)          AGE
+envoy-team-a-gateway-a-b65c6264   LoadBalancer   10.96.191.198   172.18.0.200   8080:30999/TCP   3m2s
+team-a-backend                    ClusterIP      10.96.92.226    <none>         3000/TCP         3m2s
+```
+
+```shell
+kubectl get services -n team-b
+
+NAME                              TYPE           CLUSTER-IP     EXTERNAL-IP    PORT(S)          AGE
+envoy-team-b-gateway-b-0ac91f5a   LoadBalancer   10.96.144.13   172.18.0.201   8081:31683/TCP   3m43s
+team-b-backend                    ClusterIP      10.96.26.162   <none>         3000/TCP         3m43s
+```
+
+### Testing the Configuration
+
+Fetch external IPs of the services:
+
+```shell
+export GATEWAY_HOST_A=$(kubectl get gateway/gateway-a -n team-a -o jsonpath='{.status.addresses[0].value}')
+```
+
+```shell
+export GATEWAY_HOST_B=$(kubectl get gateway/gateway-b -n team-b -o jsonpath='{.status.addresses[0].value}')
+```
+
+Curl the route team-a-route through Envoy proxy:
+
+```shell
+curl --header "Host: www.team-a.com" http://$GATEWAY_HOST_A:8080/example
+```
+
+```shell
+{
+ "path": "/example",
+ "host": "www.team-a.com",
+ "method": "GET",
+ "proto": "HTTP/1.1",
+ "headers": {
+  "Accept": [
+   "*/*"
+  ],
+  "User-Agent": [
+   "curl/8.7.1"
+  ],
+  "X-Envoy-External-Address": [
+   "172.18.0.3"
+  ],
+  "X-Forwarded-For": [
+   "172.18.0.3"
+  ],
+  "X-Forwarded-Proto": [
+   "http"
+  ],
+  "X-Request-Id": [
+   "52f08a5c-7e07-43b7-bd23-44693c60fc0c"
+  ]
+ },
+ "namespace": "team-a",
+ "ingress": "",
+ "service": "",
+ "pod": "team-a-backend-6f786fb76f-nx26p"
+```
+
+Curl the route team-b-route through Envoy proxy:
+
+```shell
+curl --header "Host: www.team-b.com" http://$GATEWAY_HOST_B:8081/example
+```
+
+```shell
+{
+ "path": "/example",
+ "host": "www.team-b.com",
+ "method": "GET",
+ "proto": "HTTP/1.1",
+ "headers": {
+  "Accept": [
+   "*/*"
+  ],
+  "User-Agent": [
+   "curl/8.7.1"
+  ],
+  "X-Envoy-External-Address": [
+   "172.18.0.3"
+  ],
+  "X-Forwarded-For": [
+   "172.18.0.3"
+  ],
+  "X-Forwarded-Proto": [
+   "http"
+  ],
+  "X-Request-Id": [
+   "62a06bd7-4754-475b-854a-dca3fc159e93"
+  ]
+ },
+ "namespace": "team-b",
+ "ingress": "",
+ "service": "",
+ "pod": "team-b-backend-966b5f47c-d6jwj"
+```



Test patch:
diff --git a/internal/gatewayapi/securitypolicy_test.go b/internal/gatewayapi/securitypolicy_test.go
index 1caeff658..2d1308dc8 100644
--- a/internal/gatewayapi/securitypolicy_test.go
+++ b/internal/gatewayapi/securitypolicy_test.go
@@ -534,3 +534,129 @@ func Test_APIKeyAuth(t *testing.T) {
 		})
 	}
 }
+
+func Test_OIDC_PassThroughAuthHeader(t *testing.T) {
+	tests := []struct {
+		name      string
+		OIDC      egv1a1.OIDC
+		JWT       *egv1a1.JWT
+		wantError bool
+	}{
+		{
+			name: "oidc and jwt with PassThroughAuthHeader configured",
+			OIDC: egv1a1.OIDC{
+				PassThroughAuthHeader: ToPointer(true),
+			},
+			JWT: &egv1a1.JWT{
+				Providers: []egv1a1.JWTProvider{
+					{
+						Name: "test",
+					},
+				},
+			},
+			wantError: false,
+		},
+		{
+			name: "jwt configured to read a non-standard header is ok",
+			OIDC: egv1a1.OIDC{
+				PassThroughAuthHeader: ToPointer(true),
+			},
+			JWT: &egv1a1.JWT{
+				Providers: []egv1a1.JWTProvider{
+					{
+						Name: "test",
+						ExtractFrom: &egv1a1.JWTExtractor{
+							Headers: []egv1a1.JWTHeaderExtractor{{Name: "SomeHeader", ValuePrefix: ToPointer("Bearer ")}},
+						},
+					},
+				},
+			},
+			wantError: false,
+		},
+		{
+			name: "jwt configured to read a non-standard header without valuePrefix is ok",
+			OIDC: egv1a1.OIDC{
+				PassThroughAuthHeader: ToPointer(true),
+			},
+			JWT: &egv1a1.JWT{
+				Providers: []egv1a1.JWTProvider{
+					{
+						Name: "test",
+						ExtractFrom: &egv1a1.JWTExtractor{
+							Headers: []egv1a1.JWTHeaderExtractor{{Name: "SomeHeader", ValuePrefix: nil}},
+						},
+					},
+				},
+			},
+			wantError: false,
+		},
+		{
+			name: "oidc with PassThroughAuthHeader configured requires jwt configured too",
+			OIDC: egv1a1.OIDC{
+				PassThroughAuthHeader: ToPointer(true),
+			},
+			JWT:       nil,
+			wantError: true,
+		},
+		{
+			name: "jwt configured to read cookie only is not ok",
+			OIDC: egv1a1.OIDC{
+				PassThroughAuthHeader: ToPointer(true),
+			},
+			JWT: &egv1a1.JWT{
+				Providers: []egv1a1.JWTProvider{
+					{
+						Name: "test",
+						ExtractFrom: &egv1a1.JWTExtractor{
+							Cookies: []string{"SomeCookie"},
+						},
+					},
+				},
+			},
+			wantError: true,
+		},
+		{
+			name: "jwt configured with multiple providers is ok",
+			OIDC: egv1a1.OIDC{
+				PassThroughAuthHeader: ToPointer(true),
+			},
+			JWT: &(egv1a1.JWT{
+				Providers: []egv1a1.JWTProvider{
+					{
+						Name: "test",
+						ExtractFrom: &egv1a1.JWTExtractor{
+							Headers: []egv1a1.JWTHeaderExtractor{{Name: "Blah"}},
+						},
+					},
+					{
+						Name: "test2",
+						ExtractFrom: &egv1a1.JWTExtractor{
+							Cookies: []string{"SomeCookie"},
+						},
+					},
+				},
+			}),
+			wantError: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			securityPolicy := egv1a1.SecurityPolicy{
+				Spec: egv1a1.SecurityPolicySpec{
+					OIDC: &tt.OIDC,
+					JWT:  tt.JWT,
+				},
+			}
+			err := validateSecurityPolicy(&securityPolicy)
+			if (err != nil) != tt.wantError {
+				t.Errorf("validateSecurityPolicy() error = %v, wantErr %v", err, tt.wantError)
+				return
+			}
+		})
+	}
+}
+
+func ToPointer[T any](v T) *T {
+	return &v
+}
diff --git a/internal/gatewayapi/testdata/securitypolicy-with-oidc-and-jwt-passthrough.in.yaml b/internal/gatewayapi/testdata/securitypolicy-with-oidc-and-jwt-passthrough.in.yaml
new file mode 100644
index 000000000..b95792290
--- /dev/null
+++ b/internal/gatewayapi/testdata/securitypolicy-with-oidc-and-jwt-passthrough.in.yaml
@@ -0,0 +1,78 @@
+secrets:
+- apiVersion: v1
+  kind: Secret
+  metadata:
+    namespace: envoy-gateway
+    name: client-secret-1
+  data:
+    client-secret: Y2xpZW50MTpzZWNyZXQK
+- apiVersion: v1
+  kind: Secret
+  metadata:
+    namespace: envoy-gateway-system
+    name: envoy-oidc-hmac
+  data:
+    hmac-secret: qrOYACHXoe7UEDI/raOjNSx+Z9ufXSc/22C3T6X/zPY=
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    namespace: envoy-gateway
+    name: gateway-1
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - name: http
+      protocol: HTTP
+      port: 80
+      allowedRoutes:
+        namespaces:
+          from: All
+httpRoutes:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: HTTPRoute
+  metadata:
+    namespace: default
+    name: httproute-1
+  spec:
+    hostnames:
+    - www.example.com
+    parentRefs:
+    - namespace: envoy-gateway
+      name: gateway-1
+      sectionName: http
+    rules:
+    - matches:
+      - path:
+          value: "/"
+      backendRefs:
+      - name: service-1
+        port: 8080
+securityPolicies:
+- apiVersion: gateway.envoyproxy.io/v1alpha1
+  kind: SecurityPolicy
+  metadata:
+    namespace: envoy-gateway
+    name: security-policy-1
+    uid: b8284d0f-de82-4c65-b204-96a0d3f258a1
+  spec:
+    targetRef:
+      group: gateway.networking.k8s.io
+      kind: Gateway
+      name: gateway-1
+    oidc:
+      provider:
+        issuer: "https://oauth.foo.com"
+        authorizationEndpoint: "https://oauth.foo.com/oauth2/v2/auth"
+        tokenEndpoint: "https://oauth.foo.com/token"
+      clientID: "client.oauth.foo.com"
+      clientSecret:
+        name: "client-secret-1"
+      redirectURL: "https://www.example.com/oauth2/callback"
+      passThroughAuthHeader: true
+    jwt:
+      providers:
+      - name: example1
+        issuer: https://oauth.foo.com
+        remoteJWKS:
+          uri: https://oauth.foo.com/jwt/public-key/jwks.json
diff --git a/internal/gatewayapi/testdata/securitypolicy-with-oidc-and-jwt-passthrough.out.yaml b/internal/gatewayapi/testdata/securitypolicy-with-oidc-and-jwt-passthrough.out.yaml
new file mode 100644
index 000000000..6f46ebd26
--- /dev/null
+++ b/internal/gatewayapi/testdata/securitypolicy-with-oidc-and-jwt-passthrough.out.yaml
@@ -0,0 +1,211 @@
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    creationTimestamp: null
+    name: gateway-1
+    namespace: envoy-gateway
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - allowedRoutes:
+        namespaces:
+          from: All
+      name: http
+      port: 80
+      protocol: HTTP
+  status:
+    listeners:
+    - attachedRoutes: 1
+      conditions:
+      - lastTransitionTime: null
+        message: Sending translated listener configuration to the data plane
+        reason: Programmed
+        status: "True"
+        type: Programmed
+      - lastTransitionTime: null
+        message: Listener has been successfully translated
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      - lastTransitionTime: null
+        message: Listener references have been resolved
+        reason: ResolvedRefs
+        status: "True"
+        type: ResolvedRefs
+      name: http
+      supportedKinds:
+      - group: gateway.networking.k8s.io
+        kind: HTTPRoute
+      - group: gateway.networking.k8s.io
+        kind: GRPCRoute
+httpRoutes:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: HTTPRoute
+  metadata:
+    creationTimestamp: null
+    name: httproute-1
+    namespace: default
+  spec:
+    hostnames:
+    - www.example.com
+    parentRefs:
+    - name: gateway-1
+      namespace: envoy-gateway
+      sectionName: http
+    rules:
+    - backendRefs:
+      - name: service-1
+        port: 8080
+      matches:
+      - path:
+          value: /
+  status:
+    parents:
+    - conditions:
+      - lastTransitionTime: null
+        message: Route is accepted
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      - lastTransitionTime: null
+        message: Resolved all the Object references for the Route
+        reason: ResolvedRefs
+        status: "True"
+        type: ResolvedRefs
+      controllerName: gateway.envoyproxy.io/gatewayclass-controller
+      parentRef:
+        name: gateway-1
+        namespace: envoy-gateway
+        sectionName: http
+infraIR:
+  envoy-gateway/gateway-1:
+    proxy:
+      listeners:
+      - address: null
+        name: envoy-gateway/gateway-1/http
+        ports:
+        - containerPort: 10080
+          name: http-80
+          protocol: HTTP
+          servicePort: 80
+      metadata:
+        labels:
+          gateway.envoyproxy.io/owning-gateway-name: gateway-1
+          gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway
+      name: envoy-gateway/gateway-1
+      namespace: envoy-gateway-system
+securityPolicies:
+- apiVersion: gateway.envoyproxy.io/v1alpha1
+  kind: SecurityPolicy
+  metadata:
+    creationTimestamp: null
+    name: security-policy-1
+    namespace: envoy-gateway
+    uid: b8284d0f-de82-4c65-b204-96a0d3f258a1
+  spec:
+    jwt:
+      providers:
+      - issuer: https://oauth.foo.com
+        name: example1
+        remoteJWKS:
+          uri: https://oauth.foo.com/jwt/public-key/jwks.json
+    oidc:
+      clientID: client.oauth.foo.com
+      clientSecret:
+        group: null
+        kind: null
+        name: client-secret-1
+      passThroughAuthHeader: true
+      provider:
+        authorizationEndpoint: https://oauth.foo.com/oauth2/v2/auth
+        issuer: https://oauth.foo.com
+        tokenEndpoint: https://oauth.foo.com/token
+      redirectURL: https://www.example.com/oauth2/callback
+    targetRef:
+      group: gateway.networking.k8s.io
+      kind: Gateway
+      name: gateway-1
+  status:
+    ancestors:
+    - ancestorRef:
+        group: gateway.networking.k8s.io
+        kind: Gateway
+        name: gateway-1
+        namespace: envoy-gateway
+      conditions:
+      - lastTransitionTime: null
+        message: Policy has been accepted.
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      controllerName: gateway.envoyproxy.io/gatewayclass-controller
+xdsIR:
+  envoy-gateway/gateway-1:
+    accessLog:
+      json:
+      - path: /dev/stdout
+    http:
+    - address: 0.0.0.0
+      hostnames:
+      - '*'
+      isHTTP2: false
+      metadata:
+        kind: Gateway
+        name: gateway-1
+        namespace: envoy-gateway
+        sectionName: http
+      name: envoy-gateway/gateway-1/http
+      path:
+        escapedSlashesAction: UnescapeAndRedirect
+        mergeSlashes: true
+      port: 10080
+      routes:
+      - destination:
+          name: httproute/default/httproute-1/rule/0
+          settings:
+          - addressType: IP
+            endpoints:
+            - host: 7.7.7.7
+              port: 8080
+            name: httproute/default/httproute-1/rule/0/backend/0
+            protocol: HTTP
+            weight: 1
+        hostname: www.example.com
+        isHTTP2: false
+        metadata:
+          kind: HTTPRoute
+          name: httproute-1
+          namespace: default
+        name: httproute/default/httproute-1/rule/0/match/0/www_example_com
+        pathMatch:
+          distinct: false
+          name: ""
+          prefix: /
+        security:
+          jwt:
+            providers:
+            - issuer: https://oauth.foo.com
+              name: example1
+              remoteJWKS:
+                uri: https://oauth.foo.com/jwt/public-key/jwks.json
+          oidc:
+            clientID: client.oauth.foo.com
+            clientSecret: '[redacted]'
+            cookieSuffix: b0a1b740
+            hmacSecret: '[redacted]'
+            logoutPath: /logout
+            name: securitypolicy/envoy-gateway/security-policy-1
+            passThroughAuthHeader: true
+            provider:
+              authorizationEndpoint: https://oauth.foo.com/oauth2/v2/auth
+              tokenEndpoint: https://oauth.foo.com/token
+            redirectPath: /oauth2/callback
+            redirectURL: https://www.example.com/oauth2/callback
+            scopes:
+            - openid
+    readyListener:
+      address: 0.0.0.0
+      ipFamily: IPv4
+      path: /ready
+      port: 19003
diff --git a/internal/wasm/premissioncache_test.go b/internal/wasm/premissioncache_test.go
index 9cc7bb10c..a6231fe59 100644
--- a/internal/wasm/premissioncache_test.go
+++ b/internal/wasm/premissioncache_test.go
@@ -65,193 +65,270 @@ func TestPermissionCache(t *testing.T) {
 	latestImage, _ := url.Parse(ociURLWithLatestTag)
 	secret := []byte("")
 
-	t.Run("Cached permission should be updated", func(t *testing.T) {
-		lock.Lock()
-		failPermissionCheck = false
-		lock.Unlock()
-
-		ctx := context.Background()
-		defer ctx.Done()
-		cache, entry := setupTestPermissionCache(
-			permissionCacheOptions{
-				checkInterval:    1 * time.Nanosecond,
-				permissionExpiry: 10 * time.Nanosecond,
-			},
-			image,
-			latestImage,
-			secret)
-		cache.Start(ctx)
-
-		lastAccessTime := entry.lastAccess
-		lastCheckTime := entry.lastCheck
-
-		cache.triggerChan <- struct{}{}
-		time.Sleep(1 * time.Millisecond)
-		allowed, err := cache.IsAllowed(context.Background(), image, secret, true)
-		require.True(
-			t,
-			allowed,
-			"permission should be rechecked and allowed after permission expired")
-		require.NoError(
-			t,
-			err,
-			"permission should be rechecked and allowed after permission expired")
-
-		entry, ok := cache.getForTest(entry.key())
-		require.True(t, ok, "cache entry should exist")
-		require.True(t, entry.lastAccess.After(lastAccessTime), "last access time should be updated")
-		require.True(t, entry.lastCheck.After(lastCheckTime), "last check time should be updated")
+	t.Run("Cached permission should be updated after expiry", func(t *testing.T) {
+		require.Eventually(t, func() bool {
+			lock.Lock()
+			failPermissionCheck = false
+			lock.Unlock()
+
+			ctx := context.Background()
+			defer ctx.Done()
+			cache, entry := setupTestPermissionCache(
+				permissionCacheOptions{
+					checkInterval:    1 * time.Nanosecond,
+					permissionExpiry: 10 * time.Nanosecond,
+				},
+				image,
+				latestImage,
+				secret)
+			cache.Start(ctx)
+
+			lastAccessTime := entry.lastAccess
+			lastCheckTime := entry.lastCheck
+
+			// Wait for the cache to expire
+			time.Sleep(10 * time.Millisecond)
+			allowed, err := cache.IsAllowed(context.Background(), image, secret, true)
+			if err != nil {
+				t.Logf("permission rechecked should not return error: %v", err)
+				return false
+			}
+			if !allowed {
+				t.Log("ppermission should be rechecked and allowed after permission expired")
+				return false
+			}
+			entry, ok := cache.getForTest(entry.key())
+			if !ok {
+				t.Log("cache entry should exist")
+				return false
+			}
+			// Verify the cached image permission is rechecked
+			if !entry.lastAccess.After(lastAccessTime) {
+				t.Log("last access time should be updated")
+				return false
+			}
+			if !entry.lastCheck.After(lastCheckTime) {
+				t.Log("last check time should be updated")
+				return false
+			}
+			return true
+		}, time.Second*5, time.Millisecond*20)
 	})
 
 	t.Run("Cached permission failed after recheck", func(t *testing.T) {
-		lock.Lock()
-		failPermissionCheck = true
-		lock.Unlock()
-
-		ctx := context.Background()
-		defer ctx.Done()
-		cache, entry := setupTestPermissionCache(
-			permissionCacheOptions{
-				checkInterval:    1 * time.Nanosecond,
-				permissionExpiry: 10 * time.Nanosecond,
-			},
-			image,
-			latestImage,
-			secret)
-		cache.Start(ctx)
-
-		lastAccessTime := entry.lastAccess
-		lastCheckTime := entry.lastCheck
-
-		cache.triggerChan <- struct{}{}
-		time.Sleep(1 * time.Millisecond)
-		allowed, err := cache.IsAllowed(context.Background(), image, secret, true)
-		require.False(t, isRetriableError(err), "permission check error should not be retriable")
-		require.False(
-			t,
-			allowed,
-			"permission should be rechecked and denied after permission expired and secret is invalid")
-		require.Error(
-			t,
-			err,
-			"permission should be rechecked and denied after permission expired and secret is invalid")
-
-		entry, ok := cache.getForTest(entry.key())
-		require.True(t, ok, "cache entry should exist")
-		require.True(t, entry.lastAccess.After(lastAccessTime), "last access time should be updated")
-		require.True(t, entry.lastCheck.After(lastCheckTime), "last check time should be updated")
+		require.Eventually(t, func() bool {
+			lock.Lock()
+			failPermissionCheck = true
+			lock.Unlock()
+
+			ctx := context.Background()
+			defer ctx.Done()
+			cache, entry := setupTestPermissionCache(
+				permissionCacheOptions{
+					checkInterval:    1 * time.Nanosecond,
+					permissionExpiry: 10 * time.Nanosecond,
+				},
+				image,
+				latestImage,
+				secret)
+			cache.Start(ctx)
+
+			lastAccessTime := entry.lastAccess
+			lastCheckTime := entry.lastCheck
+
+			// Wait for the cache to expire
+			time.Sleep(10 * time.Millisecond)
+			allowed, err := cache.IsAllowed(context.Background(), image, secret, true)
+			if err == nil {
+				t.Log("permission rechecked should return error if failed permission check")
+				return false
+			}
+			if isRetriableError(err) {
+				t.Logf("permission check error should not be retriable: %v", err)
+				return false
+			}
+			if allowed {
+				t.Log("permission should be rechecked and denied after permission expired and secret is invalid")
+				return false
+			}
+			entry, ok := cache.getForTest(entry.key())
+			if !ok {
+				t.Log("cache entry should exist")
+				return false
+			}
+			if !entry.lastAccess.After(lastAccessTime) {
+				t.Log("last access time should be updated")
+				return false
+			}
+			if !entry.lastCheck.After(lastCheckTime) {
+				t.Log("last check time should be updated")
+				return false
+			}
+			return true
+		}, time.Second*5, time.Millisecond*20)
 	})
 
 	t.Run("Cached permission should be removed after expiry", func(t *testing.T) {
-		lock.Lock()
-		failPermissionCheck = false
-		lock.Unlock()
-
-		ctx := context.Background()
-		defer ctx.Done()
-		cache, entry := setupTestPermissionCache(
-			permissionCacheOptions{
-				checkInterval: 1 * time.Nanosecond,
-				cacheExpiry:   10 * time.Nanosecond,
-			},
-			image,
-			latestImage,
-			secret)
-		cache.Start(ctx)
-
-		lastAccessTime := entry.lastAccess
-		lastCheckTime := entry.lastCheck
-
-		cache.triggerChan <- struct{}{}
-		time.Sleep(1 * time.Millisecond)
-		key := entry.key()
-		entry, ok := cache.getForTest(key)
-		require.False(t, ok, "cache entry should be removed after expiry")
-		allowed, err := cache.IsAllowed(context.Background(), image, secret, true)
-		require.True(t,
-			allowed,
-			"permission should be rechecked and allowed after cache removed")
-		require.NoError(t,
-			err,
-			"permission should be rechecked and allowed after cache removed")
-		entry, ok = cache.getForTest(key)
-		require.True(t, ok, "expired entry should be added after recheck")
-		require.True(t, entry.lastAccess.After(lastAccessTime), "last access time should be updated")
-		require.True(t, entry.lastCheck.After(lastCheckTime), "last check time should be updated")
+		require.Eventually(t, func() bool {
+			lock.Lock()
+			failPermissionCheck = false
+			lock.Unlock()
+
+			ctx := context.Background()
+			defer ctx.Done()
+			cache, entry := setupTestPermissionCache(
+				permissionCacheOptions{
+					checkInterval: 1 * time.Nanosecond,
+					cacheExpiry:   10 * time.Nanosecond,
+				},
+				image,
+				latestImage,
+				secret)
+			cache.Start(ctx)
+
+			lastAccessTime := entry.lastAccess
+			lastCheckTime := entry.lastCheck
+
+			// Wait for the cache to expire
+			time.Sleep(10 * time.Millisecond)
+			key := entry.key()
+			entry, ok := cache.getForTest(key)
+			if ok {
+				t.Log("cache entry should be removed after expiry")
+				return false
+			}
+			allowed, err := cache.IsAllowed(context.Background(), image, secret, true)
+			if err != nil {
+				t.Logf("permission rechecked should not return error: %v", err)
+				return false
+			}
+			if !allowed {
+				t.Log("permission should be rechecked and allowed after cache removed")
+				return false
+			}
+			entry, ok = cache.getForTest(key)
+			if !ok {
+				t.Log("expired entry should be added after recheck")
+				return false
+			}
+			if !entry.lastAccess.After(lastAccessTime) {
+				t.Log("last access time should be updated")
+				return false
+			}
+			if !entry.lastCheck.After(lastCheckTime) {
+				t.Log("last check time should be updated")
+				return false
+			}
+			return true
+		}, time.Second*5, time.Millisecond*20)
 	})
 
 	t.Run("Non-exist permission should be checked and cached after first access for allowed permission", func(t *testing.T) {
-		lock.Lock()
-		failPermissionCheck = false
-		lock.Unlock()
-
-		ctx := context.Background()
-		defer ctx.Done()
-		cache, entry := setupTestPermissionCache(
-			permissionCacheOptions{
-				checkInterval: 1 * time.Nanosecond,
-			},
-			image,
-			latestImage,
-			secret)
-		key := entry.key()
-		// remove the cache entry
-		cache.deleteForTest(key)
-		cache.Start(ctx)
-
-		_, ok := cache.getForTest(key)
-		require.False(t, ok, "cache entry should not exist before access")
-
-		now := time.Now()
-		allowed, err := cache.IsAllowed(context.Background(), image, secret, true)
-		require.True(t,
-			allowed,
-			"non-exist permission should be checked and allowed at first access")
-		require.NoError(t,
-			err,
-			"non-exist permission should be checked and allowed at first access")
-
-		entry, ok = cache.getForTest(key)
-		require.True(t, ok, "non-exist permission should be added to the cache after first access ")
-		require.True(t, entry.lastAccess.After(now), "last access time should be updated after first access")
-		require.True(t, entry.lastCheck.After(now), "last check time should be updated after first access")
+		require.Eventually(t, func() bool {
+			lock.Lock()
+			failPermissionCheck = false
+			lock.Unlock()
+
+			ctx := context.Background()
+			defer ctx.Done()
+			cache, entry := setupTestPermissionCache(
+				permissionCacheOptions{
+					checkInterval: 1 * time.Nanosecond,
+				},
+				image,
+				latestImage,
+				secret)
+			key := entry.key()
+			// remove the cache entry
+			cache.deleteForTest(key)
+			cache.Start(ctx)
+
+			_, ok := cache.getForTest(key)
+			if ok {
+				t.Log("cache entry should not exist before access")
+				return false
+			}
+
+			now := time.Now()
+			allowed, err := cache.IsAllowed(context.Background(), image, secret, true)
+			if err != nil {
+				t.Logf("permission check should not return error: %v", err)
+				return false
+			}
+			if !allowed {
+				t.Log("non-exist permission should be checked and allowed at first access")
+				return false
+			}
+
+			entry, ok = cache.getForTest(key)
+			if !ok {
+				t.Log("non-exist permission should be added to the cache after first access")
+				return false
+			}
+			if !entry.lastAccess.After(now) {
+				t.Log("last access time should be updated after first access")
+				return false
+			}
+			if !entry.lastCheck.After(now) {
+				t.Log("last check time should be updated after first access")
+				return false
+			}
+			return true
+		}, time.Second*5, time.Millisecond*20)
 	})
 
 	t.Run("Non-exist permission should be checked and cached after first access for denied permission", func(t *testing.T) {
-		lock.Lock()
-		failPermissionCheck = true
-		lock.Unlock()
-
-		ctx := context.Background()
-		defer ctx.Done()
-		cache, entry := setupTestPermissionCache(
-			permissionCacheOptions{
-				checkInterval: 1 * time.Nanosecond,
-			},
-			image,
-			latestImage,
-			secret)
-		key := entry.key()
-		// remove the cache entry
-		cache.deleteForTest(key)
-		cache.Start(ctx)
-
-		_, ok := cache.getForTest(key)
-		require.False(t, ok, "cache entry should not exist before access")
-
-		now := time.Now()
-		allowed, err := cache.IsAllowed(context.Background(), image, secret, true)
-		require.False(t,
-			allowed,
-			"non-exist permission should be checked and denied at first access if secret is invalid")
-		require.Error(t,
-			err,
-			"non-exist permission should be checked and denied at first access if secret is invalid")
-
-		entry, ok = cache.getForTest(key)
-		require.True(t, ok, "non-exist permission should be added to the cache after first access ")
-		require.True(t, entry.lastAccess.After(now), "last access time should be updated after first access")
-		require.True(t, entry.lastCheck.After(now), "last check time should be updated after first access")
+		require.Eventually(t, func() bool {
+			lock.Lock()
+			failPermissionCheck = true
+			lock.Unlock()
+
+			ctx := context.Background()
+			defer ctx.Done()
+			cache, entry := setupTestPermissionCache(
+				permissionCacheOptions{
+					checkInterval: 1 * time.Nanosecond,
+				},
+				image,
+				latestImage,
+				secret)
+			key := entry.key()
+			// remove the cache entry
+			cache.deleteForTest(key)
+			cache.Start(ctx)
+
+			_, ok := cache.getForTest(key)
+			if ok {
+				t.Log("cache entry should not exist before access")
+				return false
+			}
+
+			now := time.Now()
+			allowed, err := cache.IsAllowed(context.Background(), image, secret, true)
+			if err == nil {
+				t.Logf("non-exist permission should be checked and denied at first access if secret is invalid")
+				return false
+			}
+			if allowed {
+				t.Log("non-exist permission should be checked and denied at first access if secret is invalid")
+				return false
+			}
+
+			entry, ok = cache.getForTest(key)
+			if !ok {
+				t.Log("non-exist permission should be added to the cache after first access")
+				return false
+			}
+			if !entry.lastAccess.After(now) {
+				t.Log("last access time should be updated after first access")
+				return false
+			}
+			if !entry.lastCheck.After(now) {
+				t.Log("last check time should be updated after first access")
+				return false
+			}
+			return true
+		}, time.Second*5, time.Millisecond*20)
 	})
 }
 
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
new file mode 100644
index 000000000..4cddbaafd
--- /dev/null
+++ b/internal/xds/translator/testdata/in/xds-ir/oidc-and-jwt-with-passthrough.yaml
@@ -0,0 +1,70 @@
+http:
+  - name: envoy-gateway/gateway-1/http
+    address: 0.0.0.0
+    port: 10080
+    hostnames:
+      - '*'
+    path:
+      escapedSlashesAction: UnescapeAndRedirect
+      mergeSlashes: true
+    routes:
+      - name: httproute/default/httproute-1/rule/0/match/0/www_example_com
+        hostname: www.example.com
+        pathMatch:
+          distinct: false
+          name: ""
+          prefix: /
+        destination:
+          name: httproute/default/httproute-1/rule/0
+          settings:
+            - endpoints:
+                - host: 7.7.7.7
+                  port: 8080
+              name: httproute/default/httproute-1/rule/0/backend/0
+        security:
+          jwt:
+            providers:
+              - issuer: https://oauth.foo.com
+                name: example1
+                remoteJWKS:
+                  uri: https://oauth.foo.com/jwt/public-key/jwks.json
+              - issuer: https://oauth.foo.com
+                name: example2
+                extractFrom:
+                  headers:
+                    - name: MyHeaderPrefixed
+                      valuePrefix: MyPrefix
+                    - name: MyHeaderNoPrefix
+                remoteJWKS:
+                  uri: https://oauth.foo.com/jwt/public-key/jwks.json
+              - issuer: https://oauth.foo.com
+                name: example3
+                extractFrom:
+                  cookies:
+                    - Cookie1
+                    - Cookie1
+                remoteJWKS:
+                  uri: https://oauth.foo.com/jwt/public-key/jwks.json
+              - issuer: https://oauth.foo.com
+                name: example4
+                extractFrom:
+                  params:
+                    - Param1
+                    - Param1
+                remoteJWKS:
+                  uri: https://oauth.foo.com/jwt/public-key/jwks.json
+          oidc:
+            clientID: client.oauth.foo.com
+            clientSecret: Y2xpZW50MTpzZWNyZXQK
+            cookieSuffix: b0a1b740
+            hmacSecret: qrOYACHXoe7UEDI/raOjNSx+Z9ufXSc/22C3T6X/zPY=
+            logoutPath: /logout
+            name: securitypolicy/envoy-gateway/security-policy-1
+            passThroughAuthHeader: true
+            provider:
+              authorizationEndpoint: https://oauth.foo.com/oauth2/v2/auth
+              tokenEndpoint: https://oauth.foo.com/token
+            redirectPath: /oauth2/callback
+            redirectURL: https://www.example.com/oauth2/callback
+            scopes:
+              - openid
diff --git a/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.clusters.yaml b/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.clusters.yaml
new file mode 100644
index 000000000..19ed98a02
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.clusters.yaml
@@ -0,0 +1,52 @@
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig:
+    localityWeightedLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_PREFERRED
+  edsClusterConfig:
+    edsConfig:
+      ads: {}
+      resourceApiVersion: V3
+    serviceName: httproute/default/httproute-1/rule/0
+  ignoreHealthOnHostRemoval: true
+  lbPolicy: LEAST_REQUEST
+  name: httproute/default/httproute-1/rule/0
+  perConnectionBufferLimitBytes: 32768
+  type: EDS
+- circuitBreakers:
+    thresholds:
+    - maxRetries: 1024
+  commonLbConfig:
+    localityWeightedLbConfig: {}
+  connectTimeout: 10s
+  dnsLookupFamily: V4_PREFERRED
+  dnsRefreshRate: 30s
+  lbPolicy: LEAST_REQUEST
+  loadAssignment:
+    clusterName: oauth_foo_com_443
+    endpoints:
+    - lbEndpoints:
+      - endpoint:
+          address:
+            socketAddress:
+              address: oauth.foo.com
+              portValue: 443
+        loadBalancingWeight: 1
+      loadBalancingWeight: 1
+      locality:
+        region: oauth_foo_com_443/backend/-1
+  name: oauth_foo_com_443
+  perConnectionBufferLimitBytes: 32768
+  respectDnsTtl: true
+  transportSocket:
+    name: envoy.transport_sockets.tls
+    typedConfig:
+      '@type': type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
+      commonTlsContext:
+        validationContext:
+          trustedCa:
+            filename: /etc/ssl/certs/ca-certificates.crt
+      sni: oauth.foo.com
+  type: STRICT_DNS
diff --git a/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.endpoints.yaml b/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.endpoints.yaml
new file mode 100644
index 000000000..29bb6b4e4
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.endpoints.yaml
@@ -0,0 +1,12 @@
+- clusterName: httproute/default/httproute-1/rule/0
+  endpoints:
+  - lbEndpoints:
+    - endpoint:
+        address:
+          socketAddress:
+            address: 7.7.7.7
+            portValue: 8080
+      loadBalancingWeight: 1
+    loadBalancingWeight: 1
+    locality:
+      region: httproute/default/httproute-1/rule/0/backend/0
diff --git a/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.listeners.yaml b/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.listeners.yaml
new file mode 100644
index 000000000..8996b15c6
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.listeners.yaml
@@ -0,0 +1,161 @@
+- address:
+    socketAddress:
+      address: 0.0.0.0
+      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:
+        - disabled: true
+          name: envoy.filters.http.oauth2/securitypolicy/envoy-gateway/security-policy-1
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.filters.http.oauth2.v3.OAuth2
+            config:
+              authScopes:
+              - openid
+              authType: BASIC_AUTH
+              authorizationEndpoint: https://oauth.foo.com/oauth2/v2/auth
+              credentials:
+                clientId: client.oauth.foo.com
+                cookieNames:
+                  bearerToken: AccessToken-b0a1b740
+                  idToken: IdToken-b0a1b740
+                  oauthExpires: OauthExpires-b0a1b740
+                  oauthHmac: OauthHMAC-b0a1b740
+                  oauthNonce: OauthNonce-b0a1b740
+                  refreshToken: RefreshToken-b0a1b740
+                hmacSecret:
+                  name: oauth2/hmac_secret/securitypolicy/envoy-gateway/security-policy-1
+                  sdsConfig:
+                    ads: {}
+                    resourceApiVersion: V3
+                tokenSecret:
+                  name: oauth2/client_secret/securitypolicy/envoy-gateway/security-policy-1
+                  sdsConfig:
+                    ads: {}
+                    resourceApiVersion: V3
+              passThroughMatcher:
+              - name: Authorization
+                stringMatch:
+                  prefix: 'Bearer '
+              - name: MyHeaderPrefixed
+                stringMatch:
+                  prefix: MyPrefix
+              - name: MyHeaderNoPrefix
+              preserveAuthorizationHeader: true
+              redirectPathMatcher:
+                path:
+                  exact: /oauth2/callback
+              redirectUri: https://www.example.com/oauth2/callback
+              signoutPath:
+                path:
+                  exact: /logout
+              tokenEndpoint:
+                cluster: oauth_foo_com_443
+                timeout: 10s
+                uri: https://oauth.foo.com/token
+              useRefreshToken: false
+        - name: envoy.filters.http.jwt_authn
+          typedConfig:
+            '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.JwtAuthentication
+            providers:
+              httproute/default/httproute-1/rule/0/match/0/www_example_com/example1:
+                forward: true
+                issuer: https://oauth.foo.com
+                normalizePayloadInMetadata:
+                  spaceDelimitedClaims:
+                  - scope
+                payloadInMetadata: example1
+                remoteJwks:
+                  asyncFetch: {}
+                  cacheDuration: 300s
+                  httpUri:
+                    cluster: oauth_foo_com_443
+                    timeout: 10s
+                    uri: https://oauth.foo.com/jwt/public-key/jwks.json
+              httproute/default/httproute-1/rule/0/match/0/www_example_com/example2:
+                forward: true
+                fromHeaders:
+                - name: MyHeaderPrefixed
+                  valuePrefix: MyPrefix
+                - name: MyHeaderNoPrefix
+                issuer: https://oauth.foo.com
+                normalizePayloadInMetadata:
+                  spaceDelimitedClaims:
+                  - scope
+                payloadInMetadata: example2
+                remoteJwks:
+                  asyncFetch: {}
+                  cacheDuration: 300s
+                  httpUri:
+                    cluster: oauth_foo_com_443
+                    timeout: 10s
+                    uri: https://oauth.foo.com/jwt/public-key/jwks.json
+              httproute/default/httproute-1/rule/0/match/0/www_example_com/example3:
+                forward: true
+                fromCookies:
+                - Cookie1
+                - Cookie1
+                issuer: https://oauth.foo.com
+                normalizePayloadInMetadata:
+                  spaceDelimitedClaims:
+                  - scope
+                payloadInMetadata: example3
+                remoteJwks:
+                  asyncFetch: {}
+                  cacheDuration: 300s
+                  httpUri:
+                    cluster: oauth_foo_com_443
+                    timeout: 10s
+                    uri: https://oauth.foo.com/jwt/public-key/jwks.json
+              httproute/default/httproute-1/rule/0/match/0/www_example_com/example4:
+                forward: true
+                fromParams:
+                - Param1
+                - Param1
+                issuer: https://oauth.foo.com
+                normalizePayloadInMetadata:
+                  spaceDelimitedClaims:
+                  - scope
+                payloadInMetadata: example4
+                remoteJwks:
+                  asyncFetch: {}
+                  cacheDuration: 300s
+                  httpUri:
+                    cluster: oauth_foo_com_443
+                    timeout: 10s
+                    uri: https://oauth.foo.com/jwt/public-key/jwks.json
+            requirementMap:
+              httproute/default/httproute-1/rule/0/match/0/www_example_com:
+                requiresAny:
+                  requirements:
+                  - providerName: httproute/default/httproute-1/rule/0/match/0/www_example_com/example1
+                  - providerName: httproute/default/httproute-1/rule/0/match/0/www_example_com/example2
+                  - providerName: httproute/default/httproute-1/rule/0/match/0/www_example_com/example3
+                  - providerName: httproute/default/httproute-1/rule/0/match/0/www_example_com/example4
+        - 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: envoy-gateway/gateway-1/http
+        serverHeaderTransformation: PASS_THROUGH
+        statPrefix: http-10080
+        useRemoteAddress: true
+    name: envoy-gateway/gateway-1/http
+  name: envoy-gateway/gateway-1/http
+  perConnectionBufferLimitBytes: 32768
diff --git a/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.routes.yaml b/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.routes.yaml
new file mode 100644
index 000000000..532d7d504
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.routes.yaml
@@ -0,0 +1,21 @@
+- ignorePortInHostMatching: true
+  name: envoy-gateway/gateway-1/http
+  virtualHosts:
+  - domains:
+    - www.example.com
+    name: envoy-gateway/gateway-1/http/www_example_com
+    routes:
+    - match:
+        prefix: /
+      name: httproute/default/httproute-1/rule/0/match/0/www_example_com
+      route:
+        cluster: httproute/default/httproute-1/rule/0
+        upgradeConfigs:
+        - upgradeType: websocket
+      typedPerFilterConfig:
+        envoy.filters.http.jwt_authn:
+          '@type': type.googleapis.com/envoy.extensions.filters.http.jwt_authn.v3.PerRouteConfig
+          requirementName: httproute/default/httproute-1/rule/0/match/0/www_example_com
+        envoy.filters.http.oauth2/securitypolicy/envoy-gateway/security-policy-1:
+          '@type': type.googleapis.com/envoy.config.route.v3.FilterConfig
+          config: {}
diff --git a/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.secrets.yaml b/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.secrets.yaml
new file mode 100755
index 000000000..bc57f5e0e
--- /dev/null
+++ b/internal/xds/translator/testdata/out/xds-ir/oidc-and-jwt-with-passthrough.secrets.yaml
@@ -0,0 +1,8 @@
+- genericSecret:
+    secret:
+      inlineBytes: Y2xpZW50MTpzZWNyZXQK
+  name: oauth2/client_secret/securitypolicy/envoy-gateway/security-policy-1
+- genericSecret:
+    secret:
+      inlineBytes: qrOYACHXoe7UEDI/raOjNSx+Z9ufXSc/22C3T6X/zPY=
+  name: oauth2/hmac_secret/securitypolicy/envoy-gateway/security-policy-1
diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md
index 8e7da3240..a29c08aa5 100644
--- a/site/content/en/latest/api/extension_types.md
+++ b/site/content/en/latest/api/extension_types.md
@@ -3146,6 +3146,7 @@ _Appears in:_
 | `defaultTokenTTL` | _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#duration-v1-meta)_ |  false  |  | DefaultTokenTTL is the default lifetime of the id token and access token.<br />Please note that Envoy will always use the expiry time from the response<br />of the authorization server if it is provided. This field is only used when<br />the expiry time is not provided by the authorization.<br />If not specified, defaults to 0. In this case, the "expires_in" field in<br />the authorization response must be set by the authorization server, or the<br />OAuth flow will fail. |
 | `refreshToken` | _boolean_ |  false  |  | RefreshToken indicates whether the Envoy should automatically refresh the<br />id token and access token when they expire.<br />When set to true, the Envoy will use the refresh token to get a new id token<br />and access token when they expire.<br />If not specified, defaults to false. |
 | `defaultRefreshTokenTTL` | _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#duration-v1-meta)_ |  false  |  | DefaultRefreshTokenTTL is the default lifetime of the refresh token.<br />This field is only used when the exp (expiration time) claim is omitted in<br />the refresh token or the refresh token is not JWT.<br />If not specified, defaults to 604800s (one week).<br />Note: this field is only applicable when the "refreshToken" field is set to true. |
+| `passThroughAuthHeader` | _boolean_ |  false  |  | Skips OIDC authentication when the request contains a header that will be extracted by the JWT filter. Unless<br />explicitly stated otherwise in the extractFrom field, this will be the "Authorization: Bearer ..." header.<br />The passThroughAuthHeader option is typically used for non-browser clients that may not be able to handle OIDC<br />redirects and wish to directly supply a token instead.<br />If not specified, defaults to false. |
 
 
 #### OIDCCookieNames
diff --git a/site/content/en/latest/tasks/operations/deployment-mode.md b/site/content/en/latest/tasks/operations/deployment-mode.md
index c50d65b28..725f7c3cb 100644
--- a/site/content/en/latest/tasks/operations/deployment-mode.md
+++ b/site/content/en/latest/tasks/operations/deployment-mode.md
@@ -4,18 +4,22 @@ title: "Deployment Mode"
 ## Deployment modes
 
 ### One GatewayClass per Envoy Gateway Controller
+
 * An Envoy Gateway is associated with a single [GatewayClass][] resource under one controller.
 This is the simplest deployment mode and is suitable for scenarios where each Gateway needs to have its own dedicated set of resources and configurations.
 
 ### Multiple GatewayClasses per Envoy Gateway Controller
+
 * An Envoy Gateway is associated with multiple [GatewayClass][] resources under one controller.
 * Support for accepting multiple GatewayClasses was added [here][issue1231].
 
 ### Separate Envoy Gateway Controllers
+
 If you've instantiated multiple GatewayClasses, you can also run separate Envoy Gateway controllers in different namespaces, linking a GatewayClass to each of them for multi-tenancy.
 Please follow the example [Multi-tenancy](#multi-tenancy).
 
 ### Merged Gateways onto a single EnvoyProxy fleet
+
 By default, each Gateway has its own dedicated set of Envoy Proxy and its configurations.
 However, for some deployments, it may be more convenient to merge listeners across multiple Gateways and deploy a single Envoy Proxy fleet.
 
@@ -26,6 +30,14 @@ Setting the `mergeGateways` field in the EnvoyProxy resource linked to GatewayCl
 
 Please follow the example [Merged gateways deployment](#merged-gateways-deployment).
 
+### Gateway Namespace Mode
+
+Gateway Namespace Mode is a deployment model for Envoy Gateway that creates Envoy Proxy infrastructure resources like Deployments, Services and ServiceAccounts in the namespace where each Gateway resource is defined, rather than in the Envoy Gateway controller namespace.
+
+* Support for this deployment mode was added [here][issue2629].
+
+Please follow the example [Gateway Namespace Mode][].
+
 ### Supported Modes
 
 #### Kubernetes
@@ -34,7 +46,6 @@ Please follow the example [Merged gateways deployment](#merged-gateways-deployme
 and **creates** managed data plane resources such as EnvoyProxy `Deployment` in the **namespace where Envoy Gateway is running**.
 * Envoy Gateway also supports [Namespaced deployment mode][], you can watch resources in the specific namespaces by assigning
 `EnvoyGateway.provider.kubernetes.watch.namespaces` or `EnvoyGateway.provider.kubernetes.watch.namespaceSelector` and **creates** managed data plane resources in the **namespace where Envoy Gateway is running**.
-* Support for alternate deployment modes is being tracked [here][issue1117].
 
 ### Multi-tenancy
 
@@ -1068,5 +1079,6 @@ curl --header "Host: www.merged3.com" http://$GATEWAY_HOST:8082/example3
 [EnvoyProxy]: ../../api/extension_types#envoyproxy
 [GatewayClass]: https://gateway-api.sigs.k8s.io/api-types/gatewayclass/
 [Namespaced deployment mode]: ../../api/extension_types#kuberneteswatchmode
+[Gateway Namespace Mode]: ./gateway-namespace-mode
 [issue1231]: https://github.com/envoyproxy/gateway/issues/1231
-[issue1117]: https://github.com/envoyproxy/gateway/issues/1117
+[issue2629]: https://github.com/envoyproxy/gateway/issues/2629
diff --git a/site/content/en/latest/tasks/operations/gateway-namespace-mode.md b/site/content/en/latest/tasks/operations/gateway-namespace-mode.md
new file mode 100644
index 000000000..ad7f3cd51
--- /dev/null
+++ b/site/content/en/latest/tasks/operations/gateway-namespace-mode.md
@@ -0,0 +1,270 @@
+---
+title: "Gateway Namespace Mode"
+---
+
+{{% alert title="Notice" color="warning" %}}
+
+Gateway Namespace Mode is currently an **alpha** feature. We recommend against using it in production workloads until it reaches beta status.
+
+For status updates or to provide feedback, please follow our [GitHub issues](https://github.com/envoyproxy/gateway/issues).
+
+{{% /alert %}}
+
+## Overview
+
+In standard deployment mode, Envoy Gateway creates all data plane resources in the controller namespace (typically `envoy-gateway-system`).
+
+Gateway Namespace Mode changes this behavior by placing Envoy Proxy data plane resources like Deployments, Services and ServiceAccounts in each Gateway's namespace, providing stronger isolation and multi-tenancy.
+
+Traditional deployment mode uses mTLS where both the client and server authenticate each other. However, in Gateway Namespace Mode, we've shifted to server-side TLS and JWT token validation between infra and control-plane.
+
+* Only the CA certificate is available in pods running in Gateway namespaces
+* Client certificates are not mounted in these namespaces
+* The Envoy proxy still validates server certificates using the CA certificate
+
+Gateway Namespace Mode uses projected service account JWT tokens for authentication.
+* Use short-lived, audience-specific JWT tokens. These tokens are automatically mounted into pods via the projected volume mechanism
+* JWT validation ensures that only authorized proxies can connect to the xDS server
+
+{{% alert title="Note" color="warning" %}}
+
+Currently it is not supported to run Gateway Namespace Mode with Merged Gateways deployments.
+
+{{% /alert %}}
+
+## Configuration
+
+To enable Gateway Namespace Mode, configure the `provider.kubernetes.deploy.type` field in your Envoy Gateway Helm chart values:
+
+```yaml
+config:
+  envoyGateway:
+    provider:
+      type: Kubernetes
+      kubernetes:
+        deploy:
+          type: GatewayNamespace
+```
+
+You can also use this command to install Envoy Gateway with Gateway Namespace Mode using Helm:
+
+```shell
+helm install \
+  --set config.envoyGateway.provider.kubernetes.deploy.type=GatewayNamespace \
+  eg oci://docker.io/envoyproxy/gateway-helm \
+  --version {{< helm-version >}} -n envoy-gateway-system --create-namespace
+```
+
+### RBAC configuration
+
+When using Gateway Namespace Mode, Envoy Gateway needs additional RBAC permissions to create and manage resources across different namespaces. The following RBAC resources are automatically created when installing Envoy Gateway Helm Chart with Gateway Namespace Mode enabled.
+
+```yaml
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: gateway-helm-cluster-infra-manager
+rules:
+- apiGroups: [""]
+  resources: ["serviceaccounts", "services", "configmaps"]
+  verbs: ["create", "get", "delete", "deletecollection", "patch"]
+- apiGroups: ["apps"]
+  resources: ["deployments", "daemonsets"]
+  verbs: ["create", "get", "delete", "deletecollection", "patch"]
+- apiGroups: ["autoscaling", "policy"]
+  resources: ["horizontalpodautoscalers", "poddisruptionbudgets"]
+  verbs: ["create", "get", "delete", "deletecollection", "patch"]
+- apiGroups: ["authentication.k8s.io"]
+  resources: ["tokenreviews"]
+  verbs: ["create"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: gateway-helm-cluster-infra-manager
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: 'gateway-helm-cluster-infra-manager'
+subjects:
+- kind: ServiceAccount
+  name: 'envoy-gateway'
+  namespace: 'envoy-gateway-system'
+```
+
+Envoy Gateway also supports configuration to you only watch resources in the specific namespaces by assigning
+`EnvoyGateway.provider.kubernetes.watch.namespaces` or `EnvoyGateway.provider.kubernetes.watch.namespaceSelector`.
+In this case, when you specify this configuration with Gateway Namespace Mode,Envoy Gateway will only watch for Gateway API resources in the specified namespaces and create needed Roles for infrastructure management in the specified namespaces.
+
+## Testing
+
+The following example demonstrates deploying two Gateways in different namespaces `team-a` and `team-b`.
+
+### Create test namespaces
+
+```shell
+kubectl create namespace team-a
+kubectl create namespace team-b
+```
+
+### Deploy Gateway Namespace Mode Example
+
+Deploy resources on your cluster from the example, it will create two sets of backend deployments, Gateways and their respective HTTPRoutes in the previously created namespaces `team-a` and `team-b`.
+
+```shell
+kubectl apply -f https://raw.githubusercontent.com/envoyproxy/gateway/{{< yaml-version >}}/examples/kubernetes/gateway-namespace-mode.yaml
+```
+
+Verify that Gateways are deployed and programmed
+
+```shell
+kubectl get gateways -n team-a
+
+NAME        CLASS   ADDRESS        PROGRAMMED   AGE
+gateway-a   eg      172.18.0.200   True         67s
+```
+
+```shell
+kubectl get gateways -n team-b
+
+NAME        CLASS   ADDRESS        PROGRAMMED   AGE
+gateway-b   eg      172.18.0.201   True         67s
+```
+
+Verify that HTTPRoutes are deployed
+
+```shell
+kubectl get httproute -n team-a
+
+NAME           HOSTNAMES            AGE
+team-a-route   ["www.team-a.com"]   67s
+```
+
+```shell
+kubectl get httproute -n team-b
+
+NAME           HOSTNAMES            AGE
+team-b-route   ["www.team-b.com"]   67s
+```
+
+Envoy Proxy resources should be created now in the namespace of every Gateway.
+
+```shell
+kubectl get pods -n team-a
+
+NAME                                              READY   STATUS    RESTARTS   AGE
+envoy-team-a-gateway-a-b65c6264-d56f5d989-6dv5s   2/2     Running   0          65s
+team-a-backend-6f786fb76f-nx26p                   1/1     Running   0          65s
+```
+
+```shell
+kubectl get pods -n team-b
+
+NAME                                               READY   STATUS    RESTARTS   AGE
+envoy-team-b-gateway-b-0ac91f5a-74f445884f-95pl8   2/2     Running   0          87s
+team-b-backend-966b5f47c-zxngl                     1/1     Running   0          87s
+```
+
+```shell
+kubectl get services -n team-a
+
+NAME                              TYPE           CLUSTER-IP      EXTERNAL-IP    PORT(S)          AGE
+envoy-team-a-gateway-a-b65c6264   LoadBalancer   10.96.191.198   172.18.0.200   8080:30999/TCP   3m2s
+team-a-backend                    ClusterIP      10.96.92.226    <none>         3000/TCP         3m2s
+```
+
+```shell
+kubectl get services -n team-b
+
+NAME                              TYPE           CLUSTER-IP     EXTERNAL-IP    PORT(S)          AGE
+envoy-team-b-gateway-b-0ac91f5a   LoadBalancer   10.96.144.13   172.18.0.201   8081:31683/TCP   3m43s
+team-b-backend                    ClusterIP      10.96.26.162   <none>         3000/TCP         3m43s
+```
+
+### Testing the Configuration
+
+Fetch external IPs of the services:
+
+```shell
+export GATEWAY_HOST_A=$(kubectl get gateway/gateway-a -n team-a -o jsonpath='{.status.addresses[0].value}')
+```
+
+```shell
+export GATEWAY_HOST_B=$(kubectl get gateway/gateway-b -n team-b -o jsonpath='{.status.addresses[0].value}')
+```
+
+Curl the route team-a-route through Envoy proxy:
+
+```shell
+curl --header "Host: www.team-a.com" http://$GATEWAY_HOST_A:8080/example
+```
+
+```shell
+{
+ "path": "/example",
+ "host": "www.team-a.com",
+ "method": "GET",
+ "proto": "HTTP/1.1",
+ "headers": {
+  "Accept": [
+   "*/*"
+  ],
+  "User-Agent": [
+   "curl/8.7.1"
+  ],
+  "X-Envoy-External-Address": [
+   "172.18.0.3"
+  ],
+  "X-Forwarded-For": [
+   "172.18.0.3"
+  ],
+  "X-Forwarded-Proto": [
+   "http"
+  ],
+  "X-Request-Id": [
+   "52f08a5c-7e07-43b7-bd23-44693c60fc0c"
+  ]
+ },
+ "namespace": "team-a",
+ "ingress": "",
+ "service": "",
+ "pod": "team-a-backend-6f786fb76f-nx26p"
+```
+
+Curl the route team-b-route through Envoy proxy:
+
+```shell
+curl --header "Host: www.team-b.com" http://$GATEWAY_HOST_B:8081/example
+```
+
+```shell
+{
+ "path": "/example",
+ "host": "www.team-b.com",
+ "method": "GET",
+ "proto": "HTTP/1.1",
+ "headers": {
+  "Accept": [
+   "*/*"
+  ],
+  "User-Agent": [
+   "curl/8.7.1"
+  ],
+  "X-Envoy-External-Address": [
+   "172.18.0.3"
+  ],
+  "X-Forwarded-For": [
+   "172.18.0.3"
+  ],
+  "X-Forwarded-Proto": [
+   "http"
+  ],
+  "X-Request-Id": [
+   "62a06bd7-4754-475b-854a-dca3fc159e93"
+  ]
+ },
+ "namespace": "team-b",
+ "ingress": "",
+ "service": "",
+ "pod": "team-b-backend-966b5f47c-d6jwj"
+```
diff --git a/test/config/gatewayclass.yaml b/test/config/gatewayclass.yaml
index ba40972c6..8c337911d 100644
--- a/test/config/gatewayclass.yaml
+++ b/test/config/gatewayclass.yaml
@@ -22,7 +22,6 @@ spec:
     kubernetes:
       envoyDeployment:
         container:
-          image: envoyproxy/envoy:distroless-v1.34.1   # https://github.com/envoyproxy/gateway/issues/6077
           volumeMounts:
             - mountPath: /var/run/ext-proc
               name: socket-dir
diff --git a/test/e2e/testdata/backend-tls-settings.yaml b/test/e2e/testdata/backend-tls-settings.yaml
index 2bcb291a2..0efea1ff2 100644
--- a/test/e2e/testdata/backend-tls-settings.yaml
+++ b/test/e2e/testdata/backend-tls-settings.yaml
@@ -1,7 +1,7 @@
 apiVersion: gateway.networking.k8s.io/v1
 kind: Gateway
 metadata:
-  name: backend-namespaces
+  name: backend-tls-setting
   namespace: gateway-conformance-infra
 spec:
   gatewayClassName: "{GATEWAY_CLASS_NAME}"
@@ -13,11 +13,11 @@ spec:
 apiVersion: gateway.networking.k8s.io/v1
 kind: HTTPRoute
 metadata:
-  name: http-with-backend-tls
+  name: backend-tls-setting
   namespace: gateway-conformance-infra
 spec:
   parentRefs:
-  - name: backend-namespaces
+  - name: backend-tls-setting
   rules:
   - matches:
     - path:
diff --git a/test/e2e/testdata/oidc-securitypolicy.yaml b/test/e2e/testdata/oidc-securitypolicy.yaml
index 0c34ab0e9..82e207929 100644
--- a/test/e2e/testdata/oidc-securitypolicy.yaml
+++ b/test/e2e/testdata/oidc-securitypolicy.yaml
@@ -77,3 +77,13 @@ spec:
       name: "oidctest-secret"
     redirectURL: "http://www.example.com/myapp/oauth2/callback"
     logoutPath: "/myapp/logout"
+    forwardAccessToken: true
+    passThroughAuthHeader: true
+  jwt:
+    providers:
+    - 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"
+    - 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"
diff --git a/test/e2e/tests/backend_tls_settings.go b/test/e2e/tests/backend_tls_settings.go
index 41d7bb03c..3d196c7ea 100644
--- a/test/e2e/tests/backend_tls_settings.go
+++ b/test/e2e/tests/backend_tls_settings.go
@@ -53,8 +53,8 @@ var BackendTLSSettingsTest = suite.ConformanceTest{
 		t.Run("Apply custom TLS settings when making backend requests.", func(t *testing.T) {
 			depNS := "envoy-gateway-system"
 			ns := "gateway-conformance-infra"
-			routeNN := types.NamespacedName{Name: "http-with-backend-tls", Namespace: ns}
-			gwNN := types.NamespacedName{Name: "backend-namespaces", Namespace: ns}
+			routeNN := types.NamespacedName{Name: "backend-tls-setting", Namespace: ns}
+			gwNN := types.NamespacedName{Name: "backend-tls-setting", Namespace: ns}
 			gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN)
 			kubernetes.NamespacesMustBeReady(t, suite.Client, suite.TimeoutConfig, []string{depNS})
 			backendTLSPolicy := &gwapiv1a3.BackendTLSPolicy{}
diff --git a/test/e2e/tests/eg_upgrade.go b/test/e2e/tests/eg_upgrade.go
index 7e37aa454..caeef8268 100644
--- a/test/e2e/tests/eg_upgrade.go
+++ b/test/e2e/tests/eg_upgrade.go
@@ -52,7 +52,7 @@ var EGUpgradeTest = suite.ConformanceTest{
 			chartPath := "../../../charts/gateway-helm"
 			relName := "eg"
 			depNS := "envoy-gateway-system"
-			lastVersionTag := "v1.3.2" //  the latest prior release
+			lastVersionTag := "v1.4.0" //  the latest prior release
 
 			t.Logf("Upgrading from version: %s", lastVersionTag)
 
@@ -60,7 +60,13 @@ var EGUpgradeTest = suite.ConformanceTest{
 			relNamespace := "envoy-gateway-system"
 			options.DefaultConfigFlags.Namespace = ptr.To(relNamespace)
 
-			ht := helm.NewPackageTool()
+			// use values file for e2e test
+			valuesFile := "../../config/helm/default.yaml"
+			if IsGatewayNamespaceMode() {
+				valuesFile = "../../config/helm/gateway-namespace-mode.yaml"
+			}
+
+			ht := helm.NewPackageTool(valuesFile)
 			if err := ht.Setup(); err != nil {
 				t.Errorf("failed to setup of packageTool: %v", err)
 			}
@@ -243,7 +249,7 @@ func upgradeChartFromPath(relName, relNamespace, chartPath string, timeout time.
 		return err
 	}
 
-	_, err = upgrade.Run(relName, gatewayChart, map[string]interface{}{})
+	_, err = upgrade.Run(relName, gatewayChart, map[string]any{})
 	if err != nil {
 		return err
 	}
diff --git a/test/e2e/tests/oidc.go b/test/e2e/tests/oidc.go
index a633e4ad1..dfbaedb8c 100644
--- a/test/e2e/tests/oidc.go
+++ b/test/e2e/tests/oidc.go
@@ -54,7 +54,7 @@ var OIDCTest = suite.ConformanceTest{
 			testOIDC(t, suite, "testdata/oidc-securitypolicy.yaml")
 		})
 
-		t.Run("http route without oidc authentication", func(t *testing.T) {
+		t.Run("oidc bypass", func(t *testing.T) {
 			ns := "gateway-conformance-infra"
 
 			podInitialized := corev1.PodCondition{Type: corev1.PodInitialized, Status: corev1.ConditionTrue}
@@ -64,9 +64,10 @@ var OIDCTest = suite.ConformanceTest{
 			// Apply the security policy that configures OIDC authentication
 			suite.Applier.MustApplyWithCleanup(t, suite.Client, suite.TimeoutConfig, "testdata/oidc-securitypolicy.yaml", true)
 
-			routeNN := types.NamespacedName{Name: "http-without-oidc", Namespace: ns}
+			routeWithOIDCNN := types.NamespacedName{Name: "http-with-oidc", Namespace: ns}
+			routeWithoutOIDCNN := types.NamespacedName{Name: "http-without-oidc", Namespace: ns}
 			gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns}
-			gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeNN)
+			gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), routeWithOIDCNN, routeWithoutOIDCNN)
 
 			ancestorRef := gwapiv1a2.ParentReference{
 				Group:     gatewayapi.GroupPtr(gwapiv1.GroupName),
@@ -76,25 +77,50 @@ var OIDCTest = suite.ConformanceTest{
 			}
 			SecurityPolicyMustBeAccepted(t, suite.Client, types.NamespacedName{Name: "oidc-test", Namespace: ns}, suite.ControllerName, ancestorRef)
 
-			expectedResponse := gwhttp.ExpectedResponse{
-				Request: gwhttp.Request{
-					Host: "www.example.com",
-					Path: "/public",
+			testCases := []gwhttp.ExpectedResponse{
+				{
+					TestCaseName: "http route without oidc authentication",
+					Request: gwhttp.Request{
+						Host: "www.example.com",
+						Path: "/public",
+					},
+					Response: gwhttp.Response{
+						StatusCode: 200,
+					},
+					Namespace: ns,
 				},
-				Response: gwhttp.Response{
-					StatusCode: 200,
+				{
+					TestCaseName: "oidc with jwt passthrough",
+					Request: gwhttp.Request{
+						Host: "www.example.com",
+						Path: "/myapp",
+						Headers: map[string]string{
+							"Authorization": "Bearer " + v1Token,
+						},
+					},
+					Backend: "infra-backend-v1",
+					Response: gwhttp.Response{
+						StatusCode: 200,
+					},
+					Namespace: ns,
 				},
-				Namespace: ns,
-			}
-
-			req := gwhttp.MakeRequest(t, &expectedResponse, gwAddr, "HTTP", "http")
-			cReq, cResp, err := suite.RoundTripper.CaptureRoundTrip(req)
-			if err != nil {
-				t.Errorf("failed to get expected response: %v", err)
 			}
 
-			if err := gwhttp.CompareRequest(t, &req, cReq, cResp, expectedResponse); err != nil {
-				t.Errorf("failed to compare request and response: %v", err)
+			for i := range testCases {
+				tc := testCases[i]
+				t.Run(tc.GetTestCaseName(i), func(t *testing.T) {
+					t.Parallel()
+
+					req := gwhttp.MakeRequest(t, &tc, gwAddr, "HTTP", "http")
+					cReq, cResp, err := suite.RoundTripper.CaptureRoundTrip(req)
+					if err != nil {
+						t.Errorf("failed to get expected response: %v", err)
+					}
+
+					if err := gwhttp.CompareRequest(t, &req, cReq, cResp, tc); err != nil {
+						t.Errorf("failed to compare request and response: %v", err)
+					}
+				})
 			}
 		})
 	},
diff --git a/test/e2e/upgrade/eg_upgrade_test.go b/test/e2e/upgrade/eg_upgrade_test.go
index 509b874fb..12bb99850 100644
--- a/test/e2e/upgrade/eg_upgrade_test.go
+++ b/test/e2e/upgrade/eg_upgrade_test.go
@@ -64,18 +64,10 @@ func TestEGUpgrade(t *testing.T) {
 		t.Fatalf("Failed to create test suite: %v", err)
 	}
 
-	if tests.IsGatewayNamespaceMode() {
-		tests.UpgradeTests = []suite.ConformanceTest{
-			tests.EnvoyShutdownTest,
-			// TODO: enable EGUpgradeTest in gateway namespace mode
-			// tests.EGUpgradeTest,
-		}
-	} else {
-		// upgrade tests should be executed in a specific order
-		tests.UpgradeTests = []suite.ConformanceTest{
-			tests.EnvoyShutdownTest,
-			tests.EGUpgradeTest,
-		}
+	// upgrade tests should be executed in a specific order
+	tests.UpgradeTests = []suite.ConformanceTest{
+		tests.EnvoyShutdownTest,
+		tests.EGUpgradeTest,
 	}
 
 	tlog.Logf(t, "Running %d Upgrade tests", len(tests.UpgradeTests))
diff --git a/test/fuzz/testdata/FuzzGatewayAPIToXDS/grpc_routing b/test/fuzz/testdata/FuzzGatewayAPIToXDS/grpc_routing
index af3477bf7..abd6e9ce2 100644
--- a/test/fuzz/testdata/FuzzGatewayAPIToXDS/grpc_routing
+++ b/test/fuzz/testdata/FuzzGatewayAPIToXDS/grpc_routing
@@ -37,13 +37,13 @@ spec:
             - name: com.example.Header
               value: foobar
       backendRefs:
-        - name: providedBackend
+        - name: provided-backend
           port: 9000
 ---
 apiVersion: gateway.envoyproxy.io/v1alpha1
 kind: Backend
 metadata:
-  name: providedBackend
+  name: provided-backend
   namespace: default
 spec:
   endpoints:
diff --git a/test/fuzz/testdata/FuzzGatewayAPIToXDS/http_routing b/test/fuzz/testdata/FuzzGatewayAPIToXDS/http_routing
index eb6b97651..cbcc895dc 100644
--- a/test/fuzz/testdata/FuzzGatewayAPIToXDS/http_routing
+++ b/test/fuzz/testdata/FuzzGatewayAPIToXDS/http_routing
@@ -29,13 +29,13 @@ spec:
     - "www.example.com"
   rules:
     - backendRefs:
-        - name: providedBackend
+        - name: provided-backend
           port: 8000
 ---
 apiVersion: gateway.envoyproxy.io/v1alpha1
 kind: Backend
 metadata:
-  name: providedBackend
+  name: provided-backend
   namespace: default
 spec:
   endpoints:
diff --git a/test/fuzz/testdata/FuzzGatewayAPIToXDS/udp_routing b/test/fuzz/testdata/FuzzGatewayAPIToXDS/udp_routing
index 9b8642c14..ea2e1b965 100644
--- a/test/fuzz/testdata/FuzzGatewayAPIToXDS/udp_routing
+++ b/test/fuzz/testdata/FuzzGatewayAPIToXDS/udp_routing
@@ -34,7 +34,7 @@ spec:
 apiVersion: gateway.envoyproxy.io/v1alpha1
 kind: Backend
 metadata:
-  name: providedBackend
+  name: provided-backend
   namespace: default
 spec:
   endpoints:
diff --git a/test/fuzz/xds_fuzz_test.go b/test/fuzz/xds_fuzz_test.go
index 38885226f..95120682f 100644
--- a/test/fuzz/xds_fuzz_test.go
+++ b/test/fuzz/xds_fuzz_test.go
@@ -9,24 +9,114 @@ import (
 	"strings"
 	"testing"
 
-	fuzz "github.com/AdaLogics/go-fuzz-headers"
-
 	"github.com/envoyproxy/gateway/internal/cmd/egctl"
 	"github.com/envoyproxy/gateway/internal/gatewayapi/resource"
 )
 
 func FuzzGatewayAPIToXDS(f *testing.F) {
+	baseYAML := `apiVersion: gateway.networking.k8s.io/v1
+kind: GatewayClass
+metadata:
+  name: eg
+spec:
+  controllerName: gateway.envoyproxy.io/gatewayclass-controller
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+  name: eg
+  namespace: default
+spec:
+  gatewayClassName: eg
+  listeners:
+    - name: http
+      protocol: HTTP
+      port: 80
+`
+
+	backendYAML := `---
+apiVersion: gateway.envoyproxy.io/v1alpha1
+kind: Backend
+metadata:
+  name: provided-backend
+  namespace: default
+spec:
+  endpoints:
+    - ip:
+        address: 0.0.0.0
+        port: 8000
+`
+
+	grpcRoute := `---
+apiVersion: gateway.networking.k8s.io/v1
+kind: GRPCRoute
+metadata:
+  name: backend
+  namespace: default
+spec:
+  parentRefs:
+    - name: eg
+      sectionName: grpc
+  hostnames:
+    - "www.grpc-example.com"
+  rules:
+    - matches:
+        - method:
+            service: com.example.Things
+            method: DoThing
+          headers:
+            - name: com.example.Header
+              value: foobar
+      backendRefs:
+        - name: provided-backend
+          port: 9000
+`
+
+	httpRoute := `---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: backend
+  namespace: default
+spec:
+  parentRefs:
+    - name: eg
+  hostnames:
+    - "www.example.com"
+  rules:
+    - backendRefs:
+        - name: provided-backend
+          port: 8000
+`
+
+	udpRoute := `---
+apiVersion: gateway.networking.k8s.io/v1alpha2
+kind: UDPRoute
+metadata:
+  name: backend
+  namespace: default
+spec:
+  parentRefs:
+    - name: eg
+      sectionName: udp
+  rules:
+    - backendRefs:
+        - name: provided-backend
+          port: 3000
+`
+
+	// Add seed corpus for golang native fuzzing. OSS-Fuzz will not take these corpus into consideration.
+	f.Add([]byte(baseYAML + grpcRoute + backendYAML))
+	f.Add([]byte(baseYAML + httpRoute + backendYAML))
+	f.Add([]byte(baseYAML + udpRoute + backendYAML))
+
 	f.Fuzz(func(t *testing.T, b []byte) {
 		rs, err := resource.LoadResourcesFromYAMLBytes(b, true)
 		if err != nil {
 			return
 		}
-		fc := fuzz.NewConsumer(b)
-		namespace, _ := fc.GetString()
-		dnsDomain, _ := fc.GetString()
-		resourceType, _ := fc.GetString()
 
-		_, err = egctl.TranslateGatewayAPIToXds(namespace, dnsDomain, resourceType, rs)
+		_, err = egctl.TranslateGatewayAPIToXds("default", "cluster.local", "all", rs)
 		if err != nil && strings.Contains(err.Error(), "failed to translate xds") {
 			t.Fatalf("%v", err)
 		}
diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml
index 2612431a8..96b52c21a 100644
--- a/test/helm/gateway-crds-helm/all.out.yaml
+++ b/test/helm/gateway-crds-helm/all.out.yaml
@@ -42006,6 +42006,16 @@ spec:
 
                       If not specified, uses a default logout path "/logout"
                     type: string
+                  passThroughAuthHeader:
+                    description: |-
+                      Skips OIDC authentication when the request contains a header that will be extracted by the JWT filter. Unless
+                      explicitly stated otherwise in the extractFrom field, this will be the "Authorization: Bearer ..." header.
+
+                      The passThroughAuthHeader option is typically used for non-browser clients that may not be able to handle OIDC
+                      redirects and wish to directly supply a token instead.
+
+                      If not specified, defaults to false.
+                    type: boolean
                   provider:
                     description: The OIDC Provider configuration.
                     properties:
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 9dc657ce7..6af055a91 100644
--- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
+++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml
@@ -24694,6 +24694,16 @@ spec:
 
                       If not specified, uses a default logout path "/logout"
                     type: string
+                  passThroughAuthHeader:
+                    description: |-
+                      Skips OIDC authentication when the request contains a header that will be extracted by the JWT filter. Unless
+                      explicitly stated otherwise in the extractFrom field, this will be the "Authorization: Bearer ..." header.
+
+                      The passThroughAuthHeader option is typically used for non-browser clients that may not be able to handle OIDC
+                      redirects and wish to directly supply a token instead.
+
+                      If not specified, defaults to false.
+                    type: boolean
                   provider:
                     description: The OIDC Provider configuration.
                     properties: