# OAuth Redirect Hell: A Developer's Debugging Guide

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
```

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

```javascript
// 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();
```

**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:

```javascript
// 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';
```

In nginx, forward the original protocol:

```nginx
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;
}
```

## 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:

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

Or use a proxy in development:

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

## 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.

```javascript
// 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();
```

**On the callback:**

```javascript
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...
});
```

**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.

```javascript
// 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');
});
```

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

```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();
}
```

## 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:

```json
// apple-app-site-association (hosted at https://myapp.com/.well-known/)
{
  "applinks": {
    "apps": [],
    "details": [{
      "appID": "TEAMID.com.mycompany.myapp",
      "paths": ["/auth/callback"]
    }]
  }
}
```

```xml
<!-- 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:..."]
  }
}]
```

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

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

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

## 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.

