← BLOG // SHEET_01

One OAuth Proxy for Multiple Environments

Simplifying OAuth configuration across development, staging, and production

Date
Read
~8 min
Sheet
01

I've been finding it a pain to keep logging into OAuth provider portals and updating callback URLs for different environments. Once you have more than a couple of people working this way, the list tends to get messy quite quickly. You end up with a pile of temporary entries from preview deploys, local tests, and bits of work that were finished days ago but never cleaned up properly.

It is also the sort of problem that pushes people into worse habits. If a shared staging environment already has a callback configured, that becomes the easiest place to test login flows, even when what you really want is to test the branch you are working on. If you are running a client locally, you either need to expose it somehow or keep finding ways to route around the provider configuration.

What I have found useful here is putting a small OAuth proxy in the middle. Instead of asking the provider to know about every environment that might need a callback, you give it a stable public callback on the proxy. The proxy receives the callback from the provider, works out where the request should really go, checks that the destination is allowed, and redirects the browser there. That means the provider only needs one registered callback, while the final destination can still be a local app, a preview deployment, or a more ordinary long-lived environment.

The basic flow

The application still starts the OAuth flow in the normal way. The only real difference is that its redirect_uri points at the proxy rather than directly at the application callback. The application also needs some way to tell the proxy where the callback should eventually end up. In the example here, that information is carried in the state parameter. The application encodes its own origin together with an intent ID, sends that to the provider, and the proxy decodes it again when the callback returns.

OAuth proxy sequenceUSERAPPLICATIONOAUTH PROVIDEROAUTH PROXYStart sign-in1Authorize request2Callback code3Redirect to app4Exchange code5
  • KEY: Proxy decodes state parameter to route callback to correct environment
  • Provider only knows one callback URL (the proxy), but final destination varies
  • Works with local dev, preview deploys, and production environments
OAuth flow with proxy: provider calls proxy, proxy redirects to application

Architecturally this is quite close to a small Backend For Frontend. The proxy is not trying to do very much, but it is adapting the provider's callback flow into something the frontend or client can use across lots of different environments without needing the provider to know about each one individually.

Why this helps with preview environments

The main win for me is that it takes provider configuration out of the normal preview environment workflow. If each preview deployment needs its own callback URL added to Google or another provider, then either you automate that somehow, which is often fiddly and provider-specific, or you do it manually, which is slow and annoying. In both cases you still have the clean-up problem afterwards. Temporary environments disappear, but the callback entries tend to linger.

With a proxy in place, the preview environment only needs a URL that the proxy is willing to redirect to. The provider callback stays fixed. That makes it much easier to treat login as a normal part of branch testing rather than something that only really works on staging because staging is the environment everyone could be bothered to configure properly. This also reduces contention between people. Engineers do not have to coordinate around one shared callback setup, and they are less likely to fall back to a shared environment just because it is the path of least resistance.

Why this helps with local development

The same setup is useful when the client is running on a workstation. Without a proxy, a local app usually means either exposing localhost through something like ngrok, testing against a remote environment instead, or doing more work in the provider settings. Tunnels are sometimes the right tool, but if the only reason you need one is that the provider has to reach your local callback URL, it starts to feel like a lot of ceremony around a fairly ordinary development task.

With the proxy, the provider still redirects back to the stable public callback, and the proxy then sends the browser on to something like http://localhost:3456/google/dev on the same machine. That gives you a proper end-to-end login flow against a client running locally, without needing the provider to know about your machine and without having to put a public tunnel in front of it. If you spend a lot of time working on client-side auth flows, that makes day-to-day iteration much easier.

A concrete example

I have put a small working example on GitHub at github.com/alexgeek/oauth-proxy-demo. It has two parts: a Cloudflare Worker in Rust acting as the proxy, and a small Google demo app showing what the application side looks like. The app builds a state value containing its own origin and an intent ID.

<application-origin>|<intent-id>
State parameter format

So a decoded value might look like this:

http://localhost:3456|550e8400-e29b-41d4-a716-446655440000
Example decoded state value

In the demo app, that part looks like this:

let intent_id = Uuid::new_v4().to_string();
let encoded_state =
    URL_SAFE_NO_PAD.encode(format!("{}|{}", state.app_url, intent_id));
Generating encoded state in Rust

When the app sends the user to Google, it uses the proxy callback as the redirect_uri:

let auth_url = format!(
    "https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&state={}&response_type=code&scope=openid%20email%20profile",
    urlencoding::encode(&state.google_client_id),
    urlencoding::encode(&format!("{}/google/dev", state.proxy_url)),
    urlencoding::encode(&encoded_state),
);
Building the authorization URL with the proxy redirect_uri

The provider only needs to know about the proxy URL. When the callback comes back, the proxy decodes the application origin from state and rebuilds the redirect to the actual application callback. On the proxy side, the logic is roughly:

let state_param = extract_state_param(&raw_query)
    .ok_or(AppError::MissingState)?;
let (app_url, intent_id) = decode_state(&state_param)?;

let parsed_url = Url::parse(&app_url)
    .map_err(|_| AppError::InvalidState)?;
let host_to_validate = parsed_url
    .host_str()
    .ok_or(AppError::InvalidState)?;

if !validate_host(host_to_validate, &config) {
    return Err(AppError::InvalidHost);
}
Proxy decodes state and validates the destination host

Security is important here. The proxy needs to be strict about where it is willing to redirect, and the application still needs to handle the rest of the OAuth flow properly. The demo project covers state validation and CSRF handling as well as redirect scheme validation in separate documentation. The demo app still validates the returned state against what it stored when the login started:

let valid_state = if let Some(sid) = session_id {
    let mut store = state_obj.session_store.lock().unwrap();
    store.remove(&sid).map(|(intent_id, _)| intent_id)
} else {
    None
};

if valid_state.is_none() || valid_state != Some(state.clone()) {
    return Err((
        StatusCode::BAD_REQUEST,
        "Invalid or missing state parameter".to_string(),
    ));
}
Application validates state to complete the OAuth flow

That way the proxy is responsible for routing the callback back to the right place, while the application is still responsible for actually completing the login.

NOTE

The provider gets one callback URL. Local development, preview environments, and other temporary clients still get to receive the callback where they need it.

Why this is easier to automate

Once the provider callback is fixed on the proxy, the moving part shifts into your own infrastructure, which is usually where you want it. If a preview environment is created, the deployment system only needs to produce a URL that the proxy accepts. If the environment is deleted, there is nothing to remove from the provider portal afterwards.

You are no longer trying to keep provider configuration in sync with every short-lived environment your platform creates. That tends to make the whole thing easier to reason about, and it avoids the gradual build-up of old callback entries that nobody quite trusts. For bigger teams this is usually where the value becomes most obvious. It is less manual work, less contention, and fewer odd corners where people end up sharing environments because that is the only place auth already works.

Desktop apps, CLIs, and TUIs

There is another benefit to the same pattern, which is that it also works quite neatly for desktop applications and command-line tools. A desktop app, CLI, or TUI can start the OAuth flow, briefly listen on a localhost port, and let the proxy redirect the callback there on the same machine once the provider has finished.

The provider still only knows about the proxy callback, and the local client only needs to be available long enough to receive the response. That makes it a practical way to support machine-local OAuth flows without having to treat each client installation as something the provider should know about directly.

Closing

This kind of proxy is useful because it takes one repetitive bit of configuration work and turns it into a stable piece of infrastructure. The provider gets one callback URL. Local development, preview environments, and other temporary clients still get to receive the callback where they need it. That makes local testing less awkward, makes ephemeral environments more practical to use properly, and removes one more reason for teams to fall back to a shared staging environment just because auth is already set up there.

/* END_OF_SHEET */
← Blog
Alexander Brook Perry © 2026
Ver: 22099bb