Repository: envoyproxy/gateway
Pull request #5142: fix(translator): make OIDC and JWT authentication work together
Author: login: StephenRobin
Created: 2025-01-24T13:08:59Z | Merged: 2025-05-16T23:00:53Z

**What type of PR is this?**
<!--
Your PR title should be descriptive, and generally start with type that contains a subsystem name with `()` if necessary
and summary followed by a colon. format `chore/docs/api/feat/fix/refactor/style/test: summary`.
Examples:
* "docs: fix grammar error"
* "feat(translator): add new feature"
* "fix: fix xx bug"
* "chore: change ci & build tools etc"
* "api: add xxx fields in ClientTrafficPolicy"
-->

<!--
NOTE: If your PR contains any API changes (changes under `/api`), we recommend you to separate these API changes into
a new PR, and we will review the API part first. It will save you lots of implementation time if the API get accepted.
-->

**What this PR does / why we need it**:
Currently, when both OIDC and JWT authentication mechanisms are configured in
the same SecurityPolicy, the OIDC is applied first. It ensures the presence of
the tokens in the request's 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 blocks requests
from clients that provide a valid "Authorization: Bearer..." header (normally
non-browser clients). The OIDC mechanism kicks in first and redirects the
requests to the login pages because of the missing cookies.

Envoy Proxy has a pass_through_matcher option that can be used to skip over the
OIDC filter when the request is going to be handled by the JWT filter later.
Add a new option passThroughAuthHeader on the OIDC config that will add this
option in xDS.

It would be an error to have the new passThroughAuthHeader enabled without also
having JWT config that will later check the validity of the token. If this is
not the case, the SecurityPolicy is rejected for safety.

Note that this change is designed to be entirely backwards compatible. The
system behaviour is unchanged unless the new option is switched on.


**Which issue(s) this PR fixes**:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes #2496

<!--
For any non-trivial changes, you need to provide a brief description of the changes in the release notes.
Please add the description to the release-notes/current.yaml file and include this file in the PR.
-->
Release Notes: Yes


Linked issue #2496: 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)

Linked issue comments: user: login: zetaab
created_at: 2024-02-15T20:58:01Z
body: @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.
author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2024-02-15T22:57:51Z
body: @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.


author_association: CONTRIBUTOR
user: login: sadovnikov
created_at: 2024-02-16T07:48:10Z
body: @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
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2024-03-17T08:01:48Z
body: This issue has been automatically marked as stale because it has not had activity in the last 30 days.
author_association: CONTRIBUTOR
user: login: zetaab
created_at: 2024-05-24T06:40:13Z
body: 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
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2024-06-23T12:02:16Z
body: This issue has been automatically marked as stale because it has not had activity in the last 30 days.
author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2024-10-03T18:18:05Z
body: rethinking this one, if we add introduce a field - `skipIfJWTExists`, we can solve this, we may also need to then add another field `myJWTLivesInThisHeader` 
author_association: CONTRIBUTOR
user: login: zhaohuabing
created_at: 2024-10-05T13:39:18Z
body: Yeah, this sounds reasonable to me. I'll raise an issue in Envoy and try to support this in v1.3.0.
author_association: MEMBER
user: login: arkodg
created_at: 2024-10-06T02:14:45Z
body: @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
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2024-11-07T12:02:55Z
body: This issue has been automatically marked as stale because it has not had activity in the last 30 days.
author_association: CONTRIBUTOR
user: login: StephenRobin
created_at: 2025-01-23T15:11:35Z
body: 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
author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2025-01-24T17:46:45Z
body: 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 )
author_association: CONTRIBUTOR
user: login: StephenRobin
created_at: 2025-01-27T13:55:20Z
body: 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.



author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2025-01-28T23:50:33Z
body: 
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
author_association: CONTRIBUTOR
user: login: denniskniep
created_at: 2025-01-31T09:15:00Z
body: 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 
author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2025-01-31T17:28:00Z
body: 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
author_association: CONTRIBUTOR
user: login: StephenRobin
created_at: 2025-02-04T22:24:46Z
body: 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?
author_association: CONTRIBUTOR
user: login: missBerg
created_at: 2025-02-05T14:22:15Z
body: 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.
author_association: CONTRIBUTOR
user: login: StephenRobin
created_at: 2025-02-05T15:47:39Z
body: 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.
author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2025-02-05T16:41:39Z
body: hey @StephenRobin your proposal looks good
author_association: CONTRIBUTOR
user: login: denniskniep
created_at: 2025-02-05T22:01:32Z
body: >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.
author_association: CONTRIBUTOR
user: login: StephenRobin
created_at: 2025-02-06T10:49:42Z
body: > That would break our setup

Hi @denniskniep, as I said above, the behaviour will be unchanged unless the new `passThroughAuthHeaders` option is specified.
author_association: CONTRIBUTOR
user: login: denniskniep
created_at: 2025-02-06T23:02:05Z
body: @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).
author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2025-02-06T23:03:43Z
body: So @denniskniep you're referring to a case where multiple auth tokens exist ? 
author_association: CONTRIBUTOR
user: login: denniskniep
created_at: 2025-02-06T23:08:19Z
body: 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 
author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2025-02-06T23:27:03Z
body: 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
author_association: CONTRIBUTOR
user: login: denniskniep
created_at: 2025-02-06T23:35:47Z
body: 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?
author_association: CONTRIBUTOR
user: login: StephenRobin
created_at: 2025-02-07T13:53:08Z
body: 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.
author_association: CONTRIBUTOR
user: login: StephenRobin
created_at: 2025-02-07T15:26:25Z
body: Implemented in #5142 
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-09T16:02:12Z
body: This issue has been automatically marked as stale because it has not had activity in the last 30 days.
author_association: CONTRIBUTOR
user: login: zhaohuabing
created_at: 2025-03-10T05:13:19Z
body: > 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.
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-04-09T08:02:31Z
body: This issue has been automatically marked as stale because it has not had activity in the last 30 days.
author_association: CONTRIBUTOR

Diff:
diff --git a/api/v1alpha1/oidc_types.go b/api/v1alpha1/oidc_types.go
index de05eb1678..11fb22051b 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 87c150497f..322875c03c 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 a63bf91625..5e4f679655 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 da26002a08..accfa3aa25 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/internal/gatewayapi/securitypolicy.go b/internal/gatewayapi/securitypolicy.go
index e2dd18b2b1..fce38a2e5a 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/gatewayapi/securitypolicy_test.go b/internal/gatewayapi/securitypolicy_test.go
index 1caeff658b..2d1308dc8a 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 0000000000..b95792290c
--- /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 0000000000..6f46ebd269
--- /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/ir/xds.go b/internal/ir/xds.go
index 30bd1d1fcb..b3759e45fd 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/xds/translator/oidc.go b/internal/xds/translator/oidc.go
index 60527be071..b5082361e6 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/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 0000000000..4cddbaafd2
--- /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 0000000000..19ed98a02b
--- /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 0000000000..29bb6b4e44
--- /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 0000000000..8996b15c63
--- /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 0000000000..532d7d5046
--- /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 0000000000..bc57f5e0e2
--- /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/release-notes/current.yaml b/release-notes/current.yaml
index 3271a4f907..818799641d 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/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md
index 8e7da32400..a29c08aa50 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/v1.4/api/extension_types.md b/site/content/en/v1.4/api/extension_types.md
index 4858d5b3d1..9441bf08d0 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/test/e2e/testdata/oidc-securitypolicy.yaml b/test/e2e/testdata/oidc-securitypolicy.yaml
index 0c34ab0e92..82e2079291 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/oidc.go b/test/e2e/tests/oidc.go
index a633e4ad18..dfbaedb8c8 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/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml
index 2612431a81..96b52c21a2 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 9dc657ce74..6af055a917 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:


Reviews:
user: login: arkodg
body: LGTM thanks !
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-02-12T23:49:10Z
user: login: zhaohuabing
body: LGTM thanks!
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-02-13T12:19:09Z
user: login: denniskniep
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-13T14:32:19Z
user: login: denniskniep
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-13T14:36:06Z
user: login: arkodg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-25T00:07:27Z
user: login: arkodg
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-25T00:13:26Z
user: login: zhaohuabing
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T00:37:19Z
user: login: StephenRobin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T09:46:42Z
user: login: StephenRobin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T09:52:00Z
user: login: zhaohuabing
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T01:21:53Z
user: login: zhaohuabing
body: Loos good to me, as long as I'm not misunderstanding [this part](https://github.com/envoyproxy/gateway/pull/5142#discussion_r2092173636). :-)
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-16T01:40:14Z
user: login: StephenRobin
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T10:19:57Z
user: login: zhaohuabing
state: DISMISSED
author_association: MEMBER
submitted_at: 2025-05-16T12:51:07Z
user: login: arkodg
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T19:23:14Z
user: login: arkodg
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T23:00:23Z

Review comments:
diff_hunk: @@ -228,9 +230,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 {
path: internal/xds/translator/oidc.go
user: login: denniskniep
body: Shouldn´t we do here:
`provider.ExtractFrom == nil || provider.ExtractFrom.Headers == nil`

Because ExtractFrom might contain the id-token-cookie from the OIDC Filter
created_at: 2025-02-13T14:32:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -228,9 +230,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 {
path: internal/xds/translator/oidc.go
user: login: denniskniep
body: Sorry, forget about the above comment, may fault 
docs for `extractFrom` say: 

> If **empty**, it defaults to extract JWT token from the Authorization HTTP request header using Bearer schema

If a cookie is specified its not empty...
created_at: 2025-02-13T14:35:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -228,9 +230,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 {
path: internal/xds/translator/oidc.go
user: login: arkodg
body: looks like this has been addressed in the preceding Gateway API layer
```

		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")
		}
```		
created_at: 2025-02-25T00:07:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -228,9 +230,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 {
path: internal/xds/translator/oidc.go
user: login: arkodg
body: oh nvm, I understand Dennis's question here, `if provider.ExtractFrom == nil` will not kick in if there's a cookie containing a JWT that has an authentication rule
so maybe lets add
```
provider.ExtractFrom == nil || provider.ExtractFrom.Headers == nil
```

to solve this @StephenRobin 
created_at: 2025-02-25T00:13:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -102,6 +102,14 @@ 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 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.
+	//
path: api/v1alpha1/oidc_types.go
user: login: zhaohuabing
body: Nit -  could we add a line in the comment to mention that JWT header extractor must be specified if this field is true? So we can have a clear instruction on how to set this field in the API doc.
created_at: 2025-05-14T00:37:19Z
author_association: MEMBER
diff_hunk: @@ -102,6 +102,14 @@ 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 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.
+	//
path: api/v1alpha1/oidc_types.go
user: login: StephenRobin
body: Thanks for the review! You actually don't need to specify the JWT header extractor. If it's not specified it looks for the "Authorization: Bearer ..." header.

https://gateway.envoyproxy.io/docs/api/extension_types/#jwtprovider

"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."

created_at: 2025-05-14T09:46:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -102,6 +102,14 @@ 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 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.
+	//
path: api/v1alpha1/oidc_types.go
user: login: StephenRobin
body: I have updated the comment, hopefully it is clearer now.
created_at: 2025-05-14T09:52:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -228,9 +230,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 {
path: internal/xds/translator/oidc.go
user: login: zhaohuabing
body: I might be misunderstanding this, but if we use `provider.ExtractFrom == nil || provider.ExtractFrom.Headers == nil` here,  and there is a JWT token cookie in the request, `Authorization` will be added to the passthroughMatchers, which it shouldn't because `Authorization`  should only be used by default when `provider.ExtractFrom` is completely nil.
created_at: 2025-05-16T01:21:53Z
author_association: MEMBER
diff_hunk: @@ -228,9 +230,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 {
path: internal/xds/translator/oidc.go
user: login: StephenRobin
body: @zhaohuabing You are correct. If `provider.ExtractFrom.Headers == nil` then no pass-through should be configured. The behaviour must match that of the JWT filter config; [this function](https://github.com/envoyproxy/gateway/blob/main/internal/xds/translator/jwt.go#L357) does nothing when it is passed nil.

Arguably I have made this code slightly more complicated than it needs to be

```
if provider.ExtractFrom == nil {
    ...
} else if provider.ExtractFrom.Headers != nil { // Just the else would be sufficient, range will treat a nil slice as equivalent to a zero length slice.
    for _, extractHeader := range provider.ExtractFrom.Headers {
        ...
    }
}
```



created_at: 2025-05-16T10:19:57Z
author_association: CONTRIBUTOR

PR comments:
user: login: arkodg
created_at: 2025-02-11T00:01:07Z
body: thanks @StephenRobin this looks good !
can you add a test in https://github.com/envoyproxy/gateway/tree/main/internal/gatewayapi/testdata & https://github.com/envoyproxy/gateway/tree/main/internal/xds/translator/testdata/in/xds-ir ? 

author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-02-11T00:02:02Z
body: ## [Codecov](https://app.codecov.io/gh/envoyproxy/gateway/pull/5142?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 70.46%. Comparing base [(`3e1bce3`)](https://app.codecov.io/gh/envoyproxy/gateway/commit/3e1bce315feba4b197f5c8ae520f029c210b51fd?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy) to head [(`d238f3d`)](https://app.codecov.io/gh/envoyproxy/gateway/commit/d238f3d641dbc0020c069b85e831a242e0cda533?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=envoyproxy).
> Report is 8 commits behind head on main.

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


```diff
@@            Coverage Diff             @@
##             main    #5142      +/-   ##
==========================================
+ Coverage   70.43%   70.46%   +0.03%     
==========================================
  Files         217      217              
  Lines       36129    36181      +52     
==========================================
+ Hits        25447    25496      +49     
- Misses       9166     9168       +2     
- Partials     1516     1517       +1     
```

</details>

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

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: StephenRobin
created_at: 2025-02-11T14:05:16Z
body: Hi @arkodg, thanks, hadn't noticed all those tests. I have added some. Coupled with the unit tests, I believe this should give  good coverage, let me know if you want me to expand any of them.
author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2025-02-12T23:49:41Z
body: cc @denniskniep 
author_association: CONTRIBUTOR
user: login: StephenRobin
created_at: 2025-02-24T14:03:52Z
body: Hi @arkodg Looks like feedback is positive - how do we progress this PR? Do you need anything further from me?
author_association: CONTRIBUTOR
user: login: zhaohuabing
created_at: 2025-03-10T05:05:18Z
body: Hi @StephenRobin Can we test this in the e2e tests?

A test function can be added to https://github.com/envoyproxy/gateway/blob/main/test/e2e/tests/oidc.go to test a request with JWT token will not be redirected to the keycloak when `passThroughAuthHeader` is set to true.

This is non-blocking, we can add E2E test later in a follow-up PR.

 
author_association: MEMBER
user: login: thiagogcm
created_at: 2025-03-19T18:13:17Z
body: Thanks for this! It is very helpful for me too
is it expected for next release?
author_association: NONE
user: login: zhaohuabing
created_at: 2025-04-10T13:20:22Z
body: ping @StephenRobin 
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-05-10T16:02:48Z
body: This pull request has been automatically marked as stale because it has not had activity in the last 30 days. Please feel free to give a status update now, ping for review, when it's ready. Thank you for your contributions!
author_association: CONTRIBUTOR
user: login: StephenRobin
created_at: 2025-05-13T16:15:50Z
body: Hi @zhaohuabing. Sorry for the delay here, I've been busy with other things. I have added the E2E test as requested. Let me know if there are any further barriers to getting this merged.
author_association: CONTRIBUTOR
user: login: zhaohuabing
created_at: 2025-05-14T00:50:51Z
body: @StephenRobin This PR looks good! I just have a minor comment on the API doc. 
author_association: MEMBER
user: login: arkodg
created_at: 2025-05-14T20:24:43Z
body: @StephenRobin @zhaohuabing I admit, I was the one pushing for a `bool` option, but there have been other users who raised issues around skipping OIDC if an API Key was present in the header (can't find the GH issue or slack thread)

Should we stick to a header based pass through approach ? 
author_association: CONTRIBUTOR
user: login: zhaohuabing
created_at: 2025-05-15T00:21:10Z
body: Hi @arkodg Is this the one that you were referring to? https://github.com/envoyproxy/gateway/pull/5455
author_association: MEMBER
user: login: arkodg
created_at: 2025-05-15T00:28:25Z
body: > Hi @arkodg Is this the one that you were referring to? #5455

no, not referring to that
author_association: CONTRIBUTOR
user: login: zhaohuabing
created_at: 2025-05-15T00:34:40Z
body: > > Hi @arkodg Is this the one that you were referring to? #5455
> 
> no, not referring to that

Oh I see, OIDC can be skipped if either a JWT token or an API key is present in the request header, and we may have other similar use cases.
Explicitly setting the skipped headers seems the most flexible way to handle this.
author_association: MEMBER
user: login: StephenRobin
created_at: 2025-05-15T01:09:35Z
body: > OIDC can be skipped if either a JWT token or an API key is present in the request header, and we may have other similar use cases. Explicitly setting the skipped headers seems the most flexible way to handle this.

Definitely the most flexible, but perhaps not the most intuitive. Especially if the full complexity of the underlying pass_through_matcher in Envoy is to be replicated here.

The name of the bool `passThroughAuthHeader` is not specific to JWT. This change could be merged now and a subsequent PR could change the implementation to also pass through headers listed in the extractFrom option of API Key Auth, just as it passes through headers listed in the extractFrom of the JWT Provider.
author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2025-05-15T01:25:58Z
body: sure then lets continue with this
author_association: CONTRIBUTOR
user: login: StephenRobin
created_at: 2025-05-15T10:00:28Z
body: Thanks to everyone who's helped me with this. What's the procedure for getting it merged - will one of the maintainers pick it up?
author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2025-05-15T21:51:12Z
body: @StephenRobin requested reviewers to take another look, once approved, this can be merged, also seeing some conflicts, can you rebase ?
author_association: CONTRIBUTOR
user: login: arkodg
created_at: 2025-05-16T19:25:37Z
body: @StephenRobin can you run `make testdata && make generate` and commit those changes ?


author_association: CONTRIBUTOR
user: login: itmwiw
created_at: 2025-06-25T20:53:03Z
body: Awesome feature! Do you have an estimated release date? I noticed it's not included in version 1.4.1 yet.
author_association: NONE
user: login: arkodg
created_at: 2025-06-25T20:55:22Z
body: @itmwiw should be part of v1.5 (end July)
author_association: CONTRIBUTOR