The redirect URI (also called callback URL) is where OAuth providers send users after authentication. It's one of the most security-critical parts of OAuth—a misconfigured redirect URI can let attackers steal access tokens and take over user accounts.
Key Takeaways
- 1Redirect URIs must be registered exactly—no wildcards in production
- 2Always use HTTPS for redirect URIs (except localhost for development)
- 3Validate the state parameter to prevent CSRF attacks
- 4Use PKCE for mobile apps and SPAs to prevent authorization code interception
- 5Never include sensitive data in redirect URI query parameters
How Redirect URIs Work
Before diving into security, let's understand the mechanics. This section walks through the OAuth flow step by step, showing exactly where the redirect URI appears and how tokens flow back to your application.
In the OAuth 2.0 authorization code flow, the redirect URI is used twice:
Authorization Request
Your app includes the redirect_uri in the authorization URL. The provider verifies it matches a registered URI.
Authorization Response
After the user approves, the provider redirects to your redirect_uri with the authorization code or tokens.
https://provider.com/oauth/authorize?client_id=abc123&redirect_uri=https://myapp.com/callback&response_type=code&scope=openid%20profile&state=xyz789https://myapp.com/callback?code=AUTH_CODE_HERE&state=xyz789Understanding the flow helps you see why redirect URI security is so critical. If an attacker can manipulate where tokens go, they own your users' accounts.
Why Redirect URI Security Matters
A misconfigured redirect URI is one of the most exploitable OAuth vulnerabilities. This section shows exactly how attackers exploit weak redirect URI validation and the real-world impact of these attacks.
The redirect URI receives the authorization code or access token. If an attacker can manipulate this URI, they can steal credentials:
# Legitimate authorization request
https://provider.com/oauth/authorize?
client_id=abc123&
redirect_uri=https://myapp.com/callback&
response_type=code
# Attacker's manipulated request
https://provider.com/oauth/authorize?
client_id=abc123&
redirect_uri=https://evil.com/steal& ← Attacker's site
response_type=code
# If the provider doesn't validate, the auth code goes to evil.comReal-World Impact
Redirect URI vulnerabilities have led to account takeovers at major companies. In 2020, a redirect URI bypass in a major social platform allowed attackers to steal access tokens from millions of users. Always validate redirect URIs exactly.
Now let's look at how to properly register and validate redirect URIs to prevent these attacks.
Registering Redirect URIs
Every OAuth provider requires URI registration, but the matching rules vary. This section covers exact matching, multiple URIs, and provider-specific behavior so you know exactly what to register and what to expect.
OAuth providers require you to pre-register allowed redirect URIs. The provider compares the redirect_uri parameter against this list before redirecting.
Exact Match (Required for Security)
Most providers require exact string matching—no wildcards, no pattern matching:
The table below shows how exact matching works. Seemingly minor differences like a trailing slash or case change will cause the request to fail:
| Registered URI | Request URI | Result |
|---|---|---|
https://myapp.com/callback | https://myapp.com/callback | ✓ Match |
https://myapp.com/callback | https://myapp.com/callback/ | ✗ Trailing slash |
https://myapp.com/callback | https://myapp.com/callback?foo=bar | ✗ Query string |
https://myapp.com/callback | https://MYAPP.com/callback | ✗ Case mismatch |
https://myapp.com/callback | http://myapp.com/callback | ✗ Wrong scheme |
Registering Multiple URIs
# Common pattern: Register separate URIs for each environment
https://myapp.com/callback # Production
https://staging.myapp.com/callback # Staging
http://localhost:3000/callback # Local development
http://localhost:8080/callback # Alternative local port
# For mobile apps
com.myapp://callback # Custom scheme
https://myapp.com/.well-known/callback # Universal linksProvider-Specific Behavior
| Provider | Matching | Wildcards | Notes |
|---|---|---|---|
| Exact | No | Allows localhost without HTTPS | |
| GitHub | Exact + path prefix | No | Subdirectories allowed |
| Microsoft | Exact | Limited | Wildcards in subdomains for some app types |
| Auth0 | Exact | Optional | Wildcards must be explicitly enabled |
| Okta | Exact | No | Strict validation |
| Exact | No | Domain must match App Domain |
With registration understood, let's cover the security best practices that protect your OAuth implementation.
Best Practices
These practices represent the minimum security standard for OAuth implementations. Skip any of these, and you create exploitable vulnerabilities. This section covers HTTPS requirements, dedicated endpoints, state validation, and PKCE—all essential for secure OAuth.
Always Use HTTPS
HTTPS is required for redirect URIs in production. The only exception is localhost for development:
# Production - HTTPS required
https://myapp.com/auth/callback ✓
http://myapp.com/auth/callback ✗ Never use HTTP
# Development - localhost can use HTTP
http://localhost:3000/callback ✓ OK for development
http://127.0.0.1:3000/callback ✓ OK for development
# Why HTTPS matters:
# - Prevents eavesdropping on the authorization code
# - Prevents man-in-the-middle attacks
# - Required by OAuth 2.0 spec for productionThe localhost exception exists because local traffic never leaves your machine—there's no network to eavesdrop on. But production traffic crosses the internet, where authorization codes could be intercepted by anyone on the network path without HTTPS encryption.
Use a Dedicated Callback Endpoint
// GOOD: Dedicated callback endpoint
// https://myapp.com/auth/callback
app.get('/auth/callback', async (req, res) => {
const { code, state, error } = req.query;
// Validate state parameter
if (!validateState(state, req.session.oauthState)) {
return res.status(400).send('Invalid state parameter');
}
if (error) {
return res.status(400).send(`OAuth error: ${error}`);
}
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code);
// Store tokens and redirect to app
req.session.tokens = tokens;
res.redirect('/dashboard');
});
// BAD: Using a generic endpoint
// https://myapp.com/?code=xxx - Mixes OAuth with regular trafficA dedicated endpoint is cleaner and more secure. You can apply specific rate limiting, logging, and error handling for OAuth flows. Using your homepage or a generic endpoint mixes OAuth traffic with regular requests, making both harder to secure and debug.
Always Validate the State Parameter
The state parameter prevents CSRF attacks. Generate it before the authorization request and validate it in the callback:
import crypto from 'crypto';
// Step 1: Generate state before authorization
function startOAuth(req, res) {
const state = crypto.randomBytes(32).toString('hex');
// Store state in session
req.session.oauthState = state;
const authUrl = new URL('https://provider.com/oauth/authorize');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('state', state);
res.redirect(authUrl.toString());
}
// Step 2: Validate state in callback
function handleCallback(req, res) {
const { code, state } = req.query;
const expectedState = req.session.oauthState;
// Constant-time comparison to prevent timing attacks
if (!expectedState || !crypto.timingSafeEqual(
Buffer.from(state),
Buffer.from(expectedState)
)) {
return res.status(400).send('Invalid state - possible CSRF attack');
}
// Clear used state
delete req.session.oauthState;
// Continue with code exchange...
}The state parameter prevents CSRF attacks where an attacker tricks your app into accepting their authorization code. Without state validation, an attacker could link their OAuth account to your user's session. The crypto.timingSafeEqual() comparison prevents timing attacks that could otherwise guess valid state values.
Use PKCE for Public Clients
PKCE (Proof Key for Code Exchange) protects against authorization code interception, especially important for mobile apps and SPAs:
import crypto from 'crypto';
// Generate PKCE values
function generatePKCE() {
// Code verifier: random 43-128 character string
const verifier = crypto.randomBytes(32)
.toString('base64url');
// Code challenge: SHA-256 hash of verifier
const challenge = crypto.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
// Step 1: Include challenge in authorization request
function startOAuthWithPKCE(req, res) {
const { verifier, challenge } = generatePKCE();
// Store verifier in session
req.session.codeVerifier = verifier;
const authUrl = new URL('https://provider.com/oauth/authorize');
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
res.redirect(authUrl.toString());
}
// Step 2: Include verifier in token exchange
async function exchangeCode(code, codeVerifier) {
const response = await fetch('https://provider.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier, // Provider verifies this matches challenge
}),
});
return response.json();
}PKCE works by creating a verifiable link between the authorization request and the token exchange. Even if an attacker intercepts the authorization code, they can't exchange it for tokens without the code verifier that only your application knows. This is why PKCE is required for public clients that can't securely store a client secret.
Best practices apply to all app types, but the specific implementation varies. Let's look at redirect URI patterns for each platform.
Redirect URIs by App Type
Different platforms have different security models and constraints. This section provides implementation patterns for web apps, SPAs, mobile apps, and desktop apps—copy and adapt these patterns for your specific platform.
Traditional Web Apps
# Server-rendered apps with backend
https://myapp.com/auth/callback
https://myapp.com/oauth/callback
# Key points:
# - HTTPS required
# - Backend exchanges code for tokens securely
# - Tokens stored in server session
# - Client secret stays on serverSingle-Page Applications
# SPAs typically use hash fragments or dedicated routes
https://myapp.com/callback
https://myapp.com/#/callback # Hash-based routing
# Key points:
# - No client secret (public client)
# - PKCE is required
# - Consider using Backend-for-Frontend (BFF) pattern
# - Tokens may be stored in memory only// React/Vue/Angular callback handling
async function handleOAuthCallback() {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
const state = params.get('state');
// Validate state from sessionStorage
const savedState = sessionStorage.getItem('oauth_state');
if (state !== savedState) {
throw new Error('Invalid state');
}
// Get code verifier
const codeVerifier = sessionStorage.getItem('code_verifier');
// Exchange code for tokens
const tokens = await fetch('/api/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, codeVerifier }),
}).then(r => r.json());
// Clean up
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('code_verifier');
// Store tokens and redirect
storeTokens(tokens);
window.location.href = '/dashboard';
}Mobile Apps
# Custom URL schemes (legacy, less secure)
com.myapp://oauth/callback
myapp://callback
# Universal Links / App Links (recommended)
https://myapp.com/.well-known/apple-app-site-association # iOS
https://myapp.com/.well-known/assetlinks.json # Android
# Redirect URI for universal links
https://myapp.com/auth/callback
# Key points:
# - Use Universal Links/App Links over custom schemes
# - PKCE is required (no client secret)
# - Handle deep linking properly
# - Validate that your app received the callback// iOS: Handle universal link callback
func application(_ application: UIApplication,
continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL,
url.path == "/auth/callback" else {
return false
}
// Extract code and state from URL
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let code = components?.queryItems?.first(where: { $0.name == "code" })?.value
let state = components?.queryItems?.first(where: { $0.name == "state" })?.value
// Validate state and exchange code
OAuthManager.shared.handleCallback(code: code, state: state)
return true
}Desktop Apps
# Loopback interface (recommended)
http://127.0.0.1:PORT/callback
http://localhost:PORT/callback
# Out-of-band (legacy, deprecated)
urn:ietf:wg:oauth:2.0:oob
# Key points:
# - Start local server to receive callback
# - Use random available port
# - PKCE required
# - Close server after receiving callback// Node.js desktop app: Local server for callback
import http from 'http';
import open from 'open';
async function oauthFlow() {
return new Promise((resolve, reject) => {
// Find available port
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (url.pathname === '/callback') {
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>Success! You can close this window.</h1>');
server.close();
resolve({ code, state });
}
});
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
const redirectUri = `http://127.0.0.1:${port}/callback`;
// Open browser for authorization
const authUrl = buildAuthUrl(redirectUri);
open(authUrl);
});
// Timeout after 5 minutes
setTimeout(() => {
server.close();
reject(new Error('OAuth timeout'));
}, 5 * 60 * 1000);
});
}Even with good implementation practices, vulnerabilities can exist in how URIs are registered or validated. Let's examine the most common OAuth redirect vulnerabilities.
Common Vulnerabilities
Understanding how attacks work helps you prevent them. This section covers three common vulnerabilities: open redirects, subdomain takeover, and path confusion. Each includes the attack mechanism and prevention strategies.
Open Redirect via Redirect URI
# Vulnerability: Provider doesn't validate redirect_uri properly
# Attacker crafts URL and tricks user into clicking
https://provider.com/oauth/authorize?
client_id=legitimate_app&
redirect_uri=https://evil.com/steal& ← Should be rejected
response_type=code
# If provider redirects, attacker receives auth code:
https://evil.com/steal?code=AUTH_CODE
# Prevention:
# - Register exact redirect URIs
# - Provider must validate against allowlist
# - Never use wildcards in productionSubdomain Takeover
# Vulnerability: Dangling DNS records for old subdomains
# Old redirect URI still registered:
https://old-feature.myapp.com/callback
# DNS points to unclaimed cloud resource (S3, Heroku, etc.)
# Attacker claims the resource and receives OAuth callbacks
# Prevention:
# - Audit registered redirect URIs regularly
# - Remove URIs for decommissioned services
# - Monitor for subdomain takeover vulnerabilitiesPath Confusion
# Vulnerability: Partial path matching
# Registered: https://myapp.com/callback
# Some providers might accept:
https://myapp.com/callback/../admin # Path traversal
https://myapp.com/callback%2f..%2fadmin # Encoded traversal
https://myapp.com/callbackevil # Prefix match
# Prevention:
# - Use providers with exact matching
# - Validate final redirect destination
# - Log and monitor OAuth callbacksWhen things go wrong, good debugging skills save hours of frustration. Let's look at common errors and how to diagnose them.
Debugging Redirect URI Issues
OAuth redirect errors are frustrating because the error messages are often vague. This table maps common errors to their likely causes and solutions.
| Error | Cause | Solution |
|---|---|---|
| redirect_uri_mismatch | URI doesn't match registered URIs | Check exact match including trailing slashes |
| invalid_redirect_uri | Malformed or blocked URI | Verify URI format and encoding |
| unauthorized_client | App not allowed to use this URI | Add URI to app registration |
| access_denied | User denied or invalid request | Check error_description parameter |
// Debug: Log the exact redirect URI being used
function startOAuth() {
const redirectUri = 'https://myapp.com/callback';
console.log('Redirect URI:', redirectUri);
console.log('URL encoded:', encodeURIComponent(redirectUri));
console.log('Length:', redirectUri.length);
// Check for invisible characters
console.log('Char codes:', [...redirectUri].map(c => c.charCodeAt(0)));
const authUrl = new URL('https://provider.com/oauth/authorize');
authUrl.searchParams.set('redirect_uri', redirectUri);
console.log('Full auth URL:', authUrl.toString());
}This debugging code logs everything that might differ between your expected and actual redirect URIs: the raw string, its encoded form, its length, and even individual character codes to catch invisible characters. When you get a redirect_uri_mismatch error, run this code and compare the output to what you registered.
Finally, let's consolidate everything into a checklist you can use when implementing OAuth.
Redirect URI Security Checklist
Use this checklist when implementing or auditing OAuth integrations. Each item represents a potential vulnerability if overlooked.
| Check | Description | Priority |
|---|---|---|
| HTTPS only | All production redirect URIs use HTTPS | Critical |
| Exact registration | No wildcards in redirect URIs | Critical |
| State validation | Generate and validate state parameter | Critical |
| PKCE for public clients | Use PKCE for SPAs and mobile apps | Critical |
| Dedicated endpoint | Use a dedicated callback route | High |
| Audit regularly | Remove old/unused redirect URIs | High |
| Error handling | Handle OAuth errors gracefully | Medium |
| Logging | Log callback attempts for security monitoring | Medium |
| Rate limiting | Limit callback endpoint requests | Medium |