OAuth Redirect Hell: A Developer's Debugging Guide

# oauth# debugging# webdev# tutorial
OAuth Redirect Hell: A Developer's Debugging GuideAlan West

Every OAuth redirect problem I have encountered and how to fix them — from mismatched URIs to mobile deep link failures.

If you've ever implemented OAuth in an app, you've probably spent an evening staring at a redirect error wondering what went wrong. OAuth redirects are deceptively tricky — a single mismatched character can break the entire flow. Here's every redirect problem I've encountered (and fixed) over the years.

Problem 1: Mismatched Redirect URIs

Symptom: redirect_uri_mismatch error from the provider, usually right after the user authorizes.

Cause: The redirect URI in your authorization request doesn't exactly match what's registered in the provider's developer console. And I mean exactly — trailing slashes matter.

# Registered in Google Console:
https://myapp.com/auth/callback

# What your app sends:
https://myapp.com/auth/callback/  ← trailing slash = FAIL
Enter fullscreen mode Exit fullscreen mode

Fix: Copy-paste the URI from your provider console directly into your code. Don't type it from memory.

// Keep redirect URIs in a config, not scattered across your codebase
const OAUTH_CONFIG = {
  redirectUri: process.env.OAUTH_REDIRECT_URI || 'http://localhost:3000/auth/callback',
};

// Use the same config everywhere
const authUrl = `${provider.authEndpoint}?` + new URLSearchParams({
  client_id: OAUTH_CONFIG.clientId,
  redirect_uri: OAUTH_CONFIG.redirectUri, // Single source of truth
  response_type: 'code',
  scope: 'openid email profile',
}).toString();
Enter fullscreen mode Exit fullscreen mode

Pro tip: Register multiple redirect URIs — one for production, one for staging, one for local dev. Most providers support this.

Problem 2: HTTP vs HTTPS Mismatch

Symptom: Redirect works in development, breaks in production (or vice versa). The provider rejects with a generic "invalid redirect URI" error.

Cause: You registered https://myapp.com/callback but your app is generating http://myapp.com/callback because it's behind a reverse proxy that terminates TLS.

Fix: Make sure your app knows it's behind HTTPS. In Express:

// If behind a reverse proxy (nginx, cloudflare, etc.)
app.set('trust proxy', 1);

// Or explicitly set the redirect URI based on environment
const redirectUri = process.env.NODE_ENV === 'production'
  ? 'https://myapp.com/auth/callback'
  : 'http://localhost:3000/auth/callback';
Enter fullscreen mode Exit fullscreen mode

In nginx, forward the original protocol:

location / {
  proxy_pass http://localhost:3000;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header X-Forwarded-Host $host;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
Enter fullscreen mode Exit fullscreen mode

Problem 3: Port Differences in Development

Symptom: Works fine when running npm start on port 3000, breaks when you switch to Vite on port 5173.

Cause: Ports are part of the URI. localhost:3000/callback and localhost:5173/callback are completely different redirect URIs.

Fix: Register all your development ports, or standardize on one:

// vite.config.js — force a consistent port
export default defineConfig({
  server: {
    port: 3000,
    strictPort: true, // Fail if port is taken instead of auto-incrementing
  },
});
Enter fullscreen mode Exit fullscreen mode

Or use a proxy in development:

// vite.config.js
export default defineConfig({
  server: {
    proxy: {
      '/auth': {
        target: 'http://localhost:4000',
        changeOrigin: true,
      },
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Problem 4: Missing or Invalid State Parameter

Symptom: Authentication seems to work, but you get a CSRF error on the callback, or the provider returns an error about state.

Cause: The state parameter is your CSRF protection for OAuth. If you don't send one, or if it doesn't match on the callback, the flow breaks.

// WRONG: No state parameter
const authUrl = `${provider.authEndpoint}?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code`;

// RIGHT: Generate and store state
const state = crypto.randomUUID();
req.session.oauthState = state;

const authUrl = `${provider.authEndpoint}?` + new URLSearchParams({
  client_id: clientId,
  redirect_uri: redirectUri,
  response_type: 'code',
  state: state,
  scope: 'openid email profile',
}).toString();
Enter fullscreen mode Exit fullscreen mode

On the callback:

app.get('/auth/callback', (req, res) => {
  if (req.query.state !== req.session.oauthState) {
    return res.status(403).json({ error: 'State mismatch — possible CSRF attack' });
  }
  delete req.session.oauthState; // Use it once
  // Exchange code for token...
});
Enter fullscreen mode Exit fullscreen mode

Common gotcha: If you're using a load balancer with multiple server instances, sessions might not be shared. User starts OAuth on server A, callback hits server B, state isn't found. Use Redis-backed sessions or store state in a signed cookie.

Problem 5: CORS Issues on the Callback

Symptom: The OAuth provider redirects back to your app, but you see CORS errors in the console. Or the callback endpoint works in a browser but fails when called from JavaScript.

Cause: OAuth callbacks are full-page redirects — they should NOT be XHR/fetch requests. If you're trying to handle the callback via fetch(), that's the problem.

// WRONG: Don't do this
const response = await fetch('/auth/callback?code=abc&state=xyz');

// RIGHT: Let the browser handle the redirect naturally
// The callback URL should be a page (or API route that redirects to a page)
app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;
  const token = await exchangeCodeForToken(code);
  // Set session/cookie, then redirect to the app
  res.redirect('/dashboard');
});
Enter fullscreen mode Exit fullscreen mode

If you're building an SPA and need the token in JavaScript:

// Callback page loads, extracts params, sends to parent window or stores token
// callback.html
const params = new URLSearchParams(window.location.search);
const code = params.get('code');

// Post message to parent if using popup flow
if (window.opener) {
  window.opener.postMessage({ type: 'oauth-callback', code }, window.origin);
  window.close();
}
Enter fullscreen mode Exit fullscreen mode

Problem 6: Mobile Deep Link Failures

Symptom: OAuth works in the browser but breaks when your mobile app tries to handle the callback. The browser opens instead of your app, or the app opens but loses the auth code.

Cause: Custom URL schemes (myapp://callback) are unreliable. Universal Links (iOS) and App Links (Android) require server-side configuration.

Fix: Use universal/app links instead of custom schemes:

// apple-app-site-association (hosted at https://myapp.com/.well-known/)
{
  "applinks": {
    "apps": [],
    "details": [{
      "appID": "TEAMID.com.mycompany.myapp",
      "paths": ["/auth/callback"]
    }]
  }
}
Enter fullscreen mode Exit fullscreen mode
<!-- Android: assetlinks.json at https://myapp.com/.well-known/ -->
[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "com.mycompany.myapp",
    "sha256_cert_fingerprints": ["AA:BB:CC:..."]
  }
}]
Enter fullscreen mode Exit fullscreen mode

For React Native, use expo-auth-session or react-native-app-auth which handle the platform-specific details:

import * as AuthSession from 'expo-auth-session';

const redirectUri = AuthSession.makeRedirectUri({
  scheme: 'myapp',
  path: 'auth/callback',
});
// This generates the correct URI for each platform
Enter fullscreen mode Exit fullscreen mode

The Debugging Checklist

When OAuth redirects break, work through this list:

  1. Compare URIs character by character — trailing slashes, protocol, port, path
  2. Check the provider console — is the redirect URI registered?
  3. Inspect the actual request — use browser DevTools Network tab to see the exact redirect URL
  4. Verify state parameter — is it being generated, stored, and validated?
  5. Check your proxy headers — is X-Forwarded-Proto being set correctly?
  6. Test in an incognito window — cached cookies/sessions from previous attempts can confuse things
  7. Read the provider's error response — the body often has more detail than the error code

Quick Reference: Provider-Specific Gotchas

  • Google: Doesn't allow localhost in production apps. Use 127.0.0.1 instead for development, or set up a proper redirect URI.
  • GitHub: Doesn't support wildcard redirect URIs. Each environment needs its own OAuth app.
  • Apple: Requires a return URL on a registered domain. No localhost at all during testing — use a tunnel like ngrok.
  • Microsoft/Azure AD: The common tenant endpoint has different behavior than tenant-specific endpoints. Match what you registered.

OAuth redirect debugging is 90% attention to detail and 10% understanding the spec. Bookmark this guide for the next time you're staring at redirect_uri_mismatch at 11 PM.