
Fixing Envoy Oauth2 redirect urls when installed in internal proxies
Envoy Proxy is a mature layer 7 proxy that has combined so many features over the past years. This maturity along with performance, solid documentation, and the wide set of features have made it a preferred option than other proxies like Nginx. And because of that, many cloud-native systems have been built on top of Envoy i.e. Envoy Gateway, and Istio. One of the features that fits very well with Envoy is authenticating ingress traffic with Oauth2 against an identity provider (IDP) i.e. Google. This means to offload Oauth from being an application logic into a layer that is hit first before your traffic reaches the application.
What if you have a multi-layered proxy architecture?
It is pretty common to have a gateway that directs public traffic to your internal services. And those internal services live behind an in-between proxy between the service and the gateway. The problem will pop up under two simultaneous conditions:
- You implement Oauth authentication at the internal proxy layer not the gateway
- Your gateway communicates with the internal proxy with internal hostnames, and terminates public hostnames at the gateway layer

Why is that an issue?
It happens because of how Oauth2 works. The main idea behind Oauth2 is to delegate user authentication to a trusted entity, hence redirection is required. You configure your Envoy to redirect unauthenticated traffic to the IDP. Fine! Now you have to tell the IDP how to communicate back to you once the user allows this authentication. But remember, the internal proxy that initiated the Oauth flow thinks that the client requested the service with an internal hostname.
That’s exactly what happened with Envoy. It depends on two variables to complete the Oauth flow. The two variable might seem to serve the same purpose when you first look at the implementation, but they don’t:
redirect_urithis is what the IDP is told to call back once it finishes the user authentication.original_urithis is what Envoy redirects to after a successful Oauth callback from the IDP
Previously, the original_uri was fetched from the client request http host header, which is an internal hostname now since Envoy uses internal hostnames.
That internal hostname was embedded into the Oauth call to the IDP, then fetched from the callback before redirecting the user to that url.
This caused the following issues:
- The user browser is redirected to an internal hostname its DNS resolvers doesn’t know about.
- The user is unconditionally redirected to whatever uri fetched from the callback request under the assumption that Envoy is the one embedding this uri in the first call. This is true, but it raises some security concerns.
Solution
The first issue had two parts to the solution,
- Instead of directly fetching the host header and embedding it in the IDP call, allow it to be a configurable value.
- Many proxies, including envoy, implement
X-Forwarded-Hostheader or similar where it saves the previous hop hostname. This is useful for tracing, especially if you do hostname rewrites. Now, if the public gateway implements this header, you can utilize it to construct theoriginal_uriback to the public url to look like:
Rendering the url this way makes much sense if your application is served with many public hostnames."%REQ(x-forwarded-proto)%://%REQ(x-forwarded-host)%/callback
The second issue was resolved by implementing an allow list (exact or *. wildcard) of hostnames that Envoy can accept upon a callback. This prevents redirecting the users to an intended domain.
# Example Oauth configuration with the new changes
# The example uses Github as an IDP
listeners:
- name: listener_0
address:
socket_address:
address: 0.0.0.0
port_value: 10000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
codec_type: AUTO
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
# usually this shouldn't be here,
# and it should be an application that receives the traffic after oauth
direct_response:
status: 200
body:
inline_string: "OAuth2 login succeeded. You are authenticated.\n"
http_filters:
- name: envoy.filters.http.oauth2
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.oauth2.v3.OAuth2
config:
# IDP configuration
token_endpoint:
cluster: github_oauth
uri: https://github.com/login/oauth/access_token
timeout: 5s
authorization_endpoint: https://github.com/login/oauth/authorize
# this can also be "%REQ(x-forwarded-proto)%://%REQ(x-forwarded-host)%/callback
redirect_uri: "http://example-app.com:10000/callback"
redirect_path_matcher:
path:
exact: /callback
signout_path:
path:
exact: /signout
credentials:
client_id: "<client-id>"
token_secret:
name: token
hmac_secret:
name: hmac
forward_bearer_token: true
# GitHub expects client_id/client_secret in the POST body.
auth_type: URL_ENCODED_BODY
# GitHub does not return `expires_in`; fall back to 1 hour.
default_expires_in: 3600s
auth_scopes:
- read:user
- user:email
# New: restrict the host of the formatted `redirect_uri`, the
# formatted `original_request_uri`, and the URL decoded from the
# OAuth2 `state` parameter to this allow list. Anything outside
# results in a 401.
allowed_redirect_domains:
- "example-app.com"
- "*.example-app.com"
# New: base URL (scheme + host) used to build the original
# request URL that is encoded into the `state` parameter. Falls
# back to `:scheme://:authority`
# this can be rendered as "%REQ(x-forwarded-proto)%://%REQ(x-forwarded-host)%:8080"
original_request_uri: "http://example-app.com:8080"
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
References: