URL Encoding in OAuth2 — Why Your Redirect URI Keeps Failing
Every OAuth2 integration has this moment.
You configure the redirect URI in the developer console. You double-check it. You test it locally and it works. Then staging fails. Or production fails. Or it works on one provider but not another.
The error message is almost always the same:
invalid redirect_uri
Or:
redirect_uri_mismatch
Or the spectacularly unhelpful:
Bad Request
The cause, more often than not, is URL encoding.
The redirect URI is a URL that contains encoded parameters. When you build the OAuth URL, the redirect URI itself becomes a parameter value that must be encoded. If the encoding is wrong at any point — frontend encoding, provider decoding, registration format — the redirect URI does not match what you registered.
This guide covers why OAuth redirects fail, how to encode them correctly, and how to diagnose encoding issues across major OAuth providers.
How OAuth2 Redirect URIs Work
In a standard OAuth2 authorization code flow, your application redirects the user to the provider's authorization endpoint with a redirect_uri parameter:
https://auth.provider.com/oauth/authorize?
response_type=code&
client_id=YOUR_CLIENT_ID&
redirect_uri=https://yourapp.com/callback&
state=ABC123
The provider authenticates the user and redirects them back to:
https://yourapp.com/callback?code=AUTH_CODE&state=ABC123
For this flow to work, the provider must agree that the redirect_uri in the request matches the one registered in the developer console.
The problem is that "match" means different things to different providers. Some compare exact strings. Some decode before comparing. Some compare specific components only. Some are case-sensitive about encoding. Some are not.
Where Encoding Breaks OAuth
The Redirect URI as a Parameter
The redirect_uri in the authorization request is itself a URL parameter value.
const redirectUri = "https://yourapp.com/callback";
// This is WRONG — the redirect URI is not encoded
const authUrl = `https://auth.com/oauth/authorize?redirect_uri=${redirectUri}`;
The result:
https://auth.com/oauth/authorize?redirect_uri=https://yourapp.com/callback
This works by accident for simple redirect URIs. But if your redirect URI contains any special characters — query parameters, fragments, ports — it breaks.
const redirectUri = "https://yourapp.com/callback?source=web&lang=en";
// Broken
const authUrl = `https://auth.com/oauth/authorize?redirect_uri=${redirectUri}`;
The result:
https://auth.com/oauth/authorize?redirect_uri=https://yourapp.com/callback?source=web&lang=en
The provider sees two query parameters: redirect_uri=https://yourapp.com/callback and source=web and lang=en. It tries to match redirect_uri=https://yourapp.com/callback against the registered URI. They do not match.
The Fix
const redirectUri = encodeURIComponent(
"https://yourapp.com/callback?source=web&lang=en"
);
const authUrl = `https://auth.com/oauth/authorize?redirect_uri=${redirectUri}`;
Result:
https://auth.com/oauth/authorize?redirect_uri=https%3A%2F%2Fyourapp.com%2Fcallback%3Fsource%3Dweb%26lang%3Den
Now redirect_uri is a single parameter. The provider decodes it and compares it against the registered URI.
Common OAuth Encoding Bugs
Bug 1: Not Encoding the Redirect URI
// Wrong
const url = `https://provider.com/auth?redirect_uri=${redirectUri}`;
// Correct
const url = `https://provider.com/auth?redirect_uri=${encodeURIComponent(redirectUri)}`;
Bug 2: Double Encoding the Redirect URI
// Wrong — encodes twice
const encoded = encodeURIComponent(encodeURIComponent(redirectUri));
// Provider receives: https%253A%252F%252Fyourapp.com%252Fcallback
// Provider decodes: https%3A%2F%2Fyourapp.com%2Fcallback
// Expected: https://yourapp.com/callback
Bug 3: Using the Wrong Encode Function
// encodeURI() does NOT encode ? & = — breaks OAuth
const authUrl = `...?redirect_uri=${encodeURI(redirectUri)}`;
// encodeURIComponent() encodes everything — correct
const authUrl = `...?redirect_uri=${encodeURIComponent(redirectUri)}`;
Bug 4: Encoding the State Parameter Incorrectly
The state parameter also needs encoding if it contains special characters:
const state = JSON.stringify({ source: "web", returnTo: "/dashboard" });
// Wrong
const url = `...&state=${state}`;
// Correct
const url = `...&state=${encodeURIComponent(state)}`;
Bug 5: Registered URI Format Mismatch
The URI registered in the developer console must match exactly.
Registered: https://yourapp.com/callback
Sent: https://Yourapp.com/Callback
↑ different case
Or:
Registered: https://yourapp.com/callback/
Sent: https://yourapp.com/callback
↑ trailing slash mismatch
Provider-Specific Behavior
Google OAuth2
Google is strict about redirect URI matching.
Google compares the scheme, host, port, and path exactly.
Query parameters in the redirect URI are NOT allowed by default.
If your redirect URI contains query parameters, register them as part of the URI:
Registered: https://yourapp.com/callback?source=web
Google encodes and decodes the URL before comparing. A trailing slash matters:
Registered: https://yourapp.com/callback
Sent: https://yourapp.com/callback/
→ MISMATCH
GitHub OAuth
GitHub is more lenient but still strict about the base URL:
GitHub compares the scheme, host, and path.
Query parameters in redirect_uri are allowed.
GitHub decodes the URL before comparison but is case-sensitive:
Registered: https://yourapp.com/callback
Sent: https://yourapp.com/Callback
→ MISMATCH
Stripe OAuth
Stripe uses exact string matching:
Stripe compares the URL as-is after decoding.
Trailing slashes, query parameters — everything must match exactly.
Microsoft / Azure AD
Azure AD allows some flexibility but is encoding-sensitive:
Azure AD compares the URL after normalizing case and trailing slashes.
But encoding mismatches in query parameters still cause failures.
The State Parameter
The state parameter is often overlooked in encoding discussions. It is a security-critical parameter that prevents CSRF attacks. If encoding corrupts it, the OAuth flow fails during the callback verification.
// Generate state with special characters
const state = Buffer.from(
JSON.stringify({ userId: 123, nonce: "abc+def/ghi" })
).toString("base64");
// state = eyJ1c2VySWQiOjEyMywibm9uY2UiOiJhYmMrZGVmL2doaSJ9
// This base64 string may contain + and /
// Encode it for the URL
const encodedState = encodeURIComponent(state);
When the provider redirects back, decode the state:
// Receive state from callback
const rawState = url.searchParams.get("state");
const decodedState = decodeURIComponent(rawState);
const json = JSON.parse(atob(decodedState));
Building OAuth URLs Correctly
Client-Side (Browser)
function buildOAuthUrl(providerConfig) {
const params = new URLSearchParams({
response_type: "code",
client_id: providerConfig.clientId,
redirect_uri: providerConfig.redirectUri,
scope: providerConfig.scope,
state: providerConfig.state
});
return `${providerConfig.authEndpoint}?${params.toString()}`;
}
// Usage
const url = buildOAuthUrl({
authEndpoint: "https://accounts.google.com/o/oauth2/auth",
clientId: "YOUR_CLIENT_ID",
redirectUri: "https://yourapp.com/callback",
scope: "openid profile email",
state: crypto.randomUUID()
});
Note: URLSearchParams encodes the values automatically. Use raw values for redirect_uri and state — it encodes them correctly.
Server-Side (Node.js)
const crypto = require("crypto");
function buildOAuthUrl(provider, config) {
const baseUrl = new URL(provider.authEndpoint);
baseUrl.searchParams.set("response_type", "code");
baseUrl.searchParams.set("client_id", config.clientId);
baseUrl.searchParams.set("redirect_uri", config.redirectUri);
baseUrl.searchParams.set("scope", config.scope);
const state = crypto.randomBytes(16).toString("hex");
baseUrl.searchParams.set("state", state);
return {
url: baseUrl.toString(),
state
};
}
Server-Side (Python)
import secrets
from urllib.parse import urlencode
def build_oauth_url(provider, config):
params = {
"response_type": "code",
"client_id": config["client_id"],
"redirect_uri": config["redirect_uri"],
"scope": config["scope"],
"state": secrets.token_urlsafe(32),
}
query_string = urlencode(params)
url = f"{provider['auth_endpoint']}?{query_string}"
return url
Diagnosing OAuth Encoding Issues
Step 1: Log the Full Authorization URL
Before redirecting, log the complete URL:
console.log("OAuth URL:", authUrl);
Inspect it manually. Does the redirect_uri parameter look correctly encoded?
Good: redirect_uri=https%3A%2F%2Fyourapp.com%2Fcallback
Bad: redirect_uri=https://yourapp.com/callback
Step 2: Compare Raw and Decoded
Use a URL decoder to check what the provider receives:
Encoded: https%3A%2F%2Fyourapp.com%2Fcallback%3Fsource%3Dweb
Decoded: https://yourapp.com/callback?source=web
Does the decoded value match exactly what you registered?
Step 3: Check the Callback URL
When the provider redirects back, log the callback URL:
// On your callback route
console.log("Callback URL:", req.url);
console.log("Query params:", req.query);
Check whether the provider received the expected code and state parameters.
Step 4: Test with a Simple Redirect URI
Temporarily use the simplest possible redirect URI — no query parameters, no port, no trailing slash:
https://yourapp.com/callback
If this works and your real URI does not, the issue is likely in the URI format or encoding.
Step 5: Use curl to Test
# Simulate the OAuth request
curl -v "https://provider.com/oauth/authorize?\
response_type=code&\
client_id=YOUR_CLIENT_ID&\
redirect_uri=https%3A%2F%2Fyourapp.com%2Fcallback&\
state=TEST_STATE"
Check the response. Some providers return detailed error messages when accessed directly.
Common Error Messages
| Error | Likely Cause |
|---|---|
redirect_uri_mismatch | URI does not match registered value |
invalid redirect_uri | Encoding error or format mismatch |
invalid_request | Missing or malformed parameters |
access_denied | User denied consent |
invalid_grant | Auth code expired or already used |
invalid_state | State parameter mismatch or encoding corruption |
Best Practices for OAuth Redirect URIs
Register the Exact URI
Register: https://yourapp.com/callback?source=web
Use: https://yourapp.com/callback?source=web
Trailing slashes, query parameters, ports — match exactly.
Always Encode the Redirect URI
const redirectUri = encodeURIComponent(
"https://yourapp.com/callback"
);
Use the URL Class to Build OAuth URLs
const url = new URL(provider.authEndpoint);
url.searchParams.set("redirect_uri", registeredUri);
// URLSearchParams encodes automatically
Never Manually Concatenate OAuth URLs
// Wrong
`${authUrl}?redirect_uri=${uri}&state=${state}`;
// Correct
new URL(authUrl);
url.searchParams.set("redirect_uri", uri);
Validate Redirect URIs on the Server
function validateRedirectUri(uri) {
const allowedUris = [
"https://yourapp.com/callback",
"https://yourapp.com/callback?source=web"
];
const decoded = decodeURIComponent(uri);
return allowedUris.includes(decoded);
}
Store State Securely
// On authorization redirect
const state = crypto.randomUUID();
sessionStore.set(state, { createdAt: Date.now() });
// On callback — validate and decode
const receivedState = url.searchParams.get("state");
const session = sessionStore.get(receivedState);
if (!session || Date.now() - session.createdAt > 300000) {
throw new Error("Invalid or expired state");
}
Testing OAuth Encoding End-to-End
describe("OAuth URL construction", () => {
const config = {
authEndpoint: "https://provider.com/auth",
clientId: "test-client",
redirectUri: "https://app.com/callback?source=test",
scope: "openid profile"
};
test("redirect_uri is properly encoded", () => {
const url = new URL(config.authEndpoint);
url.searchParams.set("redirect_uri", config.redirectUri);
expect(url.toString()).toContain(
encodeURIComponent(config.redirectUri)
);
});
test("state parameter does not contain unencoded special chars", () => {
const url = new URL(config.authEndpoint);
url.searchParams.set("state", "abc123");
const state = url.searchParams.get("state");
expect(decodeURIComponent(state)).toBe("abc123");
});
test("full OAuth URL is valid", () => {
const url = buildOAuthUrl(config);
const parsed = new URL(url);
expect(parsed.searchParams.get("redirect_uri")).toBe(
config.redirectUri
);
expect(parsed.searchParams.get("client_id")).toBe(
config.clientId
);
});
});
Related Resources
For more on the encoding concepts behind OAuth failures:
-
Double URL Encoding — How It Happens and Why It Breaks Your API Requests Double URL Encoding
-
How to Fix Invalid URL Encoding Errors in APIs Fixing Invalid URL Encoding
-
encodeURI vs encodeURIComponent JavaScript Encoding Guide
-
URL Encoding the Space Character — + vs %20 Space Encoding Guide
FAQ
Why does my OAuth redirect URI fail with redirect_uri_mismatch?
The most common cause is that the redirect URI in your request does not exactly match the one registered in the provider's developer console — including encoding, trailing slashes, query parameters, and case.
How do I encode an OAuth redirect URI correctly?
Use encodeURIComponent() on the redirect URI before adding it as a query parameter. Do not use encodeURI() — it leaves ?, &, and = unencoded.
Should I register my redirect URI with or without query parameters?
Register the exact URI you intend to use. If your callback URL has query parameters, include them in the registration.
Why does my OAuth flow work locally but not in production?
Local and production environments often have different redirect URIs. A common issue is forgetting to register the production URI in the provider's console, or encoding differences between environments.
Does the state parameter need URL encoding?
Yes. If the state value contains special characters (especially if using JSON or base64), encode it with encodeURIComponent().
Why do different OAuth providers handle redirect URIs differently?
Each provider implements the OAuth2 spec with minor variations. Some use exact string matching, some normalize before matching, some are case-sensitive, some are not. Check each provider's documentation.
How can I debug OAuth redirect URI issues?
Log the full authorization URL before redirecting, compare the raw encoded URI against the registered URI, and use the provider's error response to identify the mismatch.
Final Thoughts
OAuth2 redirect URI failures are almost always encoding problems dressed up as configuration problems. The error messages are rarely helpful, but the fix is usually simple: encode the redirect URI correctly, register it exactly as it appears after decoding, and let URL libraries handle the encoding instead of building URLs manually.
The hardest part is knowing where to look. Now you know: it is almost always the encoding.
When debugging OAuth encoding issues, the URL Encoder/Decoder tool helps compare what you registered against what your code is actually sending.