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.
// 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:
// 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:
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:
// 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:
// 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.
// 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:
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.
// 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:
// 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:
// apple-app-site-association (hosted at https://myapp.com/.well-known/)
{
"applinks": {
"apps": [],
"details": [{
"appID": "TEAMID.com.mycompany.myapp",
"paths": ["/auth/callback"]
}]
}
}
<!-- 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:
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:
- Compare URIs character by character — trailing slashes, protocol, port, path
- Check the provider console — is the redirect URI registered?
- Inspect the actual request — use browser DevTools Network tab to see the exact redirect URL
- Verify state parameter — is it being generated, stored, and validated?
-
Check your proxy headers — is
X-Forwarded-Protobeing set correctly? - Test in an incognito window — cached cookies/sessions from previous attempts can confuse things
- 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
localhostin production apps. Use127.0.0.1instead 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
commontenant 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.
Top comments (0)