OAuth Proxy Server: Handling Dynamic Redirect URIs in Development Environments
by Satya Swaroop Mohapatra, Senior System Analyst

Why OAuth Breaks in Development Environments ?
Modern development workflows often involve dynamic URLs that change frequently. Whether you're using ngrok for local development, Vercel/Netlify preview deployments, or Fly.io PR review apps, you've likely encountered this frustrating scenario:
- You spin up a new preview environment with a unique URL.
- You try to test OAuth authentication (Slack, GitHub, Google etc.).
- Authentication fails because your new URL isn't whitelisted in the OAuth provider's settings.
OAuth providers require you to pre-register redirect URIs for security reasons. But constantly updating these URLs for every preview deployment or ngrok session isn't practical. Some providers limit the number of redirect URIs you can register, and manually managing them becomes a maintenance nightmare.
How a Proxy Server Solves This
An OAuth proxy server acts as a middleman with a stable, whitelisted URL. Here's how it works:
- Single Whitelisted URL: Register your proxy server's URL once with the OAuth provider.
- Request Interception: Your app redirects OAuth requests through the proxy.
- URL Rewriting: The proxy modifies the redirect_uri to point back to itself.
- Response Forwarding: After authentication, the proxy forwards the response back to your original app.
This approach lets you use OAuth with any dynamic URL while maintaining security and avoiding constant reconfiguration.
Building the Proxy: Rails App with Slack OAuth
Let's walk through a real implementation using Rails and Slack OAuth. This solution consists of three parts:
Part 1: The Proxy Server
First, create a standalone proxy server with a stable URL (deployed on Fly.io, Heroku etc.) This server acts as the middleman between your dynamic development app and the OAuth provider.
# app/controllers/proxies_controller.rb
class ProxiesController < ApplicationController
def show
original_location = Base64.urlsafe_decode64(params[:uri])
session[:original_location] = original_location
uri = URI.parse(original_location)
modified_query = CGI.parse(uri.query).tap do |hash|
hash["redirect_uri"] = [callback_proxy_url]
end
uri.query =
modified_query.map do |name, values|
values.map do |value|
"#{CGI.escape name}=#{CGI.escape value}"
end
end.flatten.join("&")
redirect_to uri.to_s, allow_other_host: true
end
def callback
location = session.delete(:original_location)
query_params = params.except(:controller, :action).permit!.to_hash
query_string = URI.encode_www_form(query_params)
query = URI(location).query
redirect_uri = CGI.parse(query)["redirect_uri"].first
query_string =
query_params.map do |name, value|
"#{CGI.escape name}=#{CGI.escape value}"
end.flatten.join("&")
redirect_to "#{redirect_uri}?#{query_string}", allow_other_host: true
end
end
How this works
The show
action handles incoming OAuth requests from your development app:
- Decode the URL: The original OAuth URL comes Base64-encoded in the uri parameter. We decode it to get the actual Slack/Google/GitHub authorization URL.
- Store for later: We save the original URL in the session. We'll need this information when handling the callback.
- Replace the redirect URI: This is the crucial step. We parse the query parameters and replace the redirect_uri (which points to your dynamic development URL) with our proxy's callback URL. This ensures the OAuth provider redirects back to our stable proxy server.
- Rebuild and redirect: We reconstruct the modified URL with all parameters properly escaped and redirect the user to the OAuth provider.
The callback
action handles the response from the OAuth provider:
- Retrieve stored data: We get the original OAuth URL from the session, which contains the original redirect_uri where your app expects the callback.
- Extract the original redirect_uri: We parse the stored URL to find where we need to send the user back to.
- Collect OAuth response: We gather all the parameters that the OAuth provider sent back (authorization code, state, etc.).
- Forward to original app: We redirect to your development app's callback URL with all the OAuth parameters intact.
The proxy server essentially "wraps" the OAuth flow. Your development app thinks it's talking directly to Slack, and Slack thinks it's only talking to your proxy. The proxy transparently passes messages between them while handling the redirect URI complexity. The beauty of this approach is that you only need to register your proxy's callback URL (https://your-proxy.com/proxy/callback) with the OAuth provider once, and it works for any number of development environments.
Part 2: Request Interception Middleware
In your main application, you need to intercept OAuth redirects and route them through your proxy. Here's a simplified middleware implementation focused on Slack:
module OmniAuth
module Strategies
class Proxy
PROXY_PATH = "https://example-proxy.fly.dev/proxy"
def initialize(app, _options={})
@app = app
end
def call(env)
status, headers, body = @app.call(env)
if ENV["OMNIAUTH_PROXY_ENABLED"].present? && status == 302
location = headers["Location"]
if location.match?("https://slack.com/openid/connect/authorize")
encoded_uri = Base64.urlsafe_encode64(location)
headers["Location"] = "#{PROXY_PATH}?uri=#{encoded_uri}"
end
end
[status, headers, body]
end
end
end
end
How this works
The middleware monitors your application's HTTP responses and intercepts OAuth redirects:
- Detect redirects: When your Rails app initiates OAuth authentication, it sends a 302 redirect to Slack's authorization endpoint. The middleware catches this response before it reaches the browser.
- Check for Slack OAuth: It looks for Slack's OAuth URL pattern (slack.com/openid/connect/authorize). When detected, it knows this is an OAuth flow that needs proxying.
- Redirect to proxy instead: Rather than sending the user directly to Slack, it encodes the entire Slack URL and redirects to your proxy server. The proxy will handle the actual communication with Slack.
The OMNIAUTH_PROXY_ENABLED
environment variable serves as a feature flag.
This flexibility is crucial because you don't want to route production traffic through a proxy unnecessarily—it adds latency and another potential point of failure.
While this example shows Slack integration, the pattern works identically for other OAuth providers. Instead of writing the middleware yourself, you can use the omniauth-proxy gem which provides ready-to-use support for multiple providers including Google, GitHub, Auth0, and more. To use the gem in your main application (where the dynamic preview URLs are generated), simply add:
# Gemfile
gem 'omniauth-proxy'
# config/application.rb
config.middleware.use OmniAuth::Strategies::Proxy
This is the approach I chose for our implementation. The gem handles URL patterns for various providers, encoding complexities, and edge cases you might encounter with different OAuth implementations. It's particularly useful if your application authenticates with multiple providers, as it centralizes the proxy logic rather than duplicating it for each service. The middleware runs in your main Rails application—the one generating preview URLs for pull requests, using ngrok for local development, or creating temporary staging environments. The proxy server remains separate and stable, while this middleware in your dynamic environments knows when and how to route OAuth requests through the proxy.
Part 3: OmniAuth Configuration
The final piece is configuring OmniAuth in your main application to work with the proxy. Here's the configuration:
# config/initializers/devise.rb (or your OmniAuth initializer)
config.omniauth :slack,
ENV['SLACK_CLIENT_ID'],
ENV['SLACK_CLIENT_SECRET'],
scope: 'openid,profile,email',
provider_ignores_state: ENV["OMNIAUTH_PROXY_ENABLED"].present?,
token_params: {
redirect_uri: ENV["OMNIAUTH_PROXY_ENABLED"].present? ?
ENV["OAUTH_PROXY_URL"] : nil
}
Understanding the Configuration
This configuration lives in your main Rails application (the one with dynamic URLs). If you're using Devise, it goes in config/initializers/devise.rb
.
For applications using OmniAuth directly without Devise, you'd place this in your OmniAuth initializer:
# For non-Devise apps: config/initializers/omniauth.rb
Rails.application.config.middleware.use OmniAuth::Builder do
provider :slack,
ENV['SLACK_CLIENT_ID'],
ENV['SLACK_CLIENT_SECRET'],
# ... same configuration as above
end
provider_ignores_state
: The OAuth state parameter is a security feature that prevents CSRF attacks. However, when routing through a proxy, the state validation can fail because the session context changes. This setting conditionally disables state validation only when the proxy is enabled.
token_params: { redirect_uri: ... }
: This is the most important configuration. During OAuth, there are two key moments when the redirect_uri is sent:
- During authorization (handled by our middleware and proxy).
- During token exchange (when converting the authorization code to an access token).
Without these configurations, the token exchange fails with a redirect_uri_mismatch
error because OmniAuth sends your app's URL instead of the proxy's URL. This setting ensures both requests use the same redirect_uri.
You'll need these environment variables in your main application for development/preview environment:
# .env
OMNIAUTH_PROXY_ENABLED=true
OAUTH_PROXY_URL=https://example-proxy.fly.dev/proxy/callback
Note: The OAuth 2.0 specification requires that the redirect_uri parameter matches exactly between the authorization and token exchange requests. Our proxy modifies the redirect_uri during authorization, so we must ensure the token exchange uses the same modified value. This configuration makes that happen automatically based on your environment.
The Complete OAuth Flow
Let's trace exactly what happens when a developer uses this setup:
1. Developer clicks "Sign in with Slack" in their ngrok/preview app (https://abc123.ngrok.io).
2. Rails app creates OAuth redirect to https://slack.com/openid/connect/authorize?redirect_uri=https://abc123.ngrok.io/users/auth/slack/callback.
3. Middleware intercepts this 302 redirect before it reaches the browser.
4. Middleware rewrites the Location header to: https://proxy.fly.dev/proxy?uri=[base64_encoded_slack_url].
5. Browser goes to proxy server instead of directly to Slack.
6. Proxy decodes the URL and modifies the redirect_uri to https://proxy.fly.dev/proxy/callback.
7. Proxy redirects browser to Slack with the modified redirect_uri.
8. User authorizes on Slack's interface.
9. Slack redirects back to proxy callback with authorization code.
10. Proxy forwards the code back to the original app: https://abc123.ngrok.io/users/auth/slack/callback?code=xxx&state=yyy.
11. App exchanges code for token, using the proxy's redirect_uri in the token_params (critical for matching).
12. Authentication complete - user is logged in!.
Important Notes:
- The proxy server should only be used for development/staging environments.
- Keep
OMNIAUTH_PROXY_ENABLED=true
unset or false in production. - Monitor proxy server logs for unusual activity.
In summary, An OAuth proxy server elegantly solves the redirect URI whitelist problem for dynamic development environments. While this example uses Rails and Slack, the pattern works with any framework and OAuth provider. The key is intercepting requests, managing redirect URIs correctly, and ensuring the token exchange uses matching URIs. By implementing this pattern, you can maintain a smooth development workflow without constantly updating OAuth provider configurations or managing dozens of whitelisted URLs.