URL Encoding in OAuth2 — Tracing the Redirect URI Through Registration, Request, and Callback

The error message is infuriatingly vague:

invalid_request: redirect_uri_mismatch

Or the slightly more helpful:

The redirect URI is not registered for this client

You check your registered URI in the OAuth provider dashboard. It matches your callback URL character-for-character. You refresh. Try again. Same error.

Most OAuth2 redirect URI failures are encoding problems. The URI you registered does not match the URI the provider sees because somewhere between configuration, request, and callback, the encoding transformed it.

This guide traces URL encoding through the full OAuth2 authorization code flow, showing exactly where encoding matters and why it causes the failures it does. If you want to inspect how your URIs are actually encoded, the URL Encoder/Decoder shows the percent-encoded representation for any input.


The OAuth2 Redirect Pipeline

The redirect URI passes through four distinct stages, each with its own encoding rules:

  1. Registration: You register a redirect URI with the provider
  2. Authorization request: You send the URI as a query parameter
  3. Provider interpretation: The provider decodes and compares values
  4. Browser processing: The browser navigates to the redirect URL

An encoding mismatch at any stage causes redirect_uri_mismatch.


Stage 1: URI Registration

When you register a redirect URI, the provider stores it exactly as submitted. If your dashboard shows:

https://myapp.com/auth/callback?source=web

But your application actually redirects to:

https://myapp.com/auth/callback?source=web&client=mobile

The mismatch happens because the registered URI has source=web as a static query parameter, while the actual redirect dynamically adds parameters.

Common Registration Encoding Mistakes

Trailing slashes: https://myapp.com/callback/ vs https://myapp.com/callback are different URIs.

Query parameter order: ?a=1&b=2 vs ?b=2&a=1 are different strings.

Case sensitivity: The scheme and host are case-insensitive, but the path and query are case-sensitive. https://MyApp.com/Callback is not the same as https://myapp.com/callback.

URL encoding in registration: Some providers normalize registered URIs; others store them literally. If you register https://myapp.com/callback?redirect=https%3A%2F%2Fexample.com, the provider might decode it before comparison.


Stage 2: The Authorization Request

The authorization request is a front-channel redirect from the user's browser to the OAuth provider:

https://auth.provider.com/authorize?
  response_type=code&
  client_id=abc123&
  redirect_uri=https%3A%2F%2Fmyapp.com%2Fauth%2Fcallback&
  state=xyz789&
  scope=openid%20profile

The redirect_uri value here is percent-encoded because it is a query parameter value. If your redirect URI contains special characters — spaces, ampersands, hash symbols — they must be encoded:

// Correct: encode the entire URI as a query parameter value
const params = new URLSearchParams({
  response_type: 'code',
  client_id: 'abc123',
  redirect_uri: 'https://myapp.com/auth/callback?source=web',
  state: crypto.randomUUID(),
  scope: 'openid profile',
});

const authUrl = `https://auth.provider.com/authorize?${params.toString()}`;
// authUrl uses URLSearchParams which auto-encodes all values

This is the most common mistake. Developers build the URL manually:

// WRONG: manual string interpolation skips encoding
const authUrl = `https://auth.provider.com/authorize?response_type=code&redirect_uri=${redirectUri}`;

If redirectUri contains an ampersand (like https://myapp.com/callback?source=web), the &source=web part is interpreted as separate query parameters, not part of the redirect URI value.


Stage 3: Provider Comparison

The provider receives the authorization request and decodes the query parameters. It then compares the decoded redirect_uri parameter against the registered URI.

The comparison logic varies between providers:

  • Exact match: The decoded parameter must be byte-for-byte identical to the registered URI
  • Prefix match: The decoded parameter must start with the registered URI (Google uses this)
  • Pattern match: The registered URI can contain wildcards (Auth0 supports this)

Encoding-Discrepancy Failures

If your application sends:

redirect_uri=https://myapp.com/callback?redirect=https%3A%2F%2Fother.com

The provider decodes this to:

redirect_uri = https://myapp.com/callback?redirect=https://other.com

If the registered URI is https://myapp.com/callback?redirect=https%3A%2F%2Fother.com (with the encoding preserved), comparison fails because the provider decoded the parameter before comparing.

The fix is to register the URI in its decoded form and let the provider's comparison logic handle encoding, or double-encode the nested URL if the provider stores the registered URI literally.


Stage 4: Browser Callback

After authorization, the provider redirects the browser to:

https://myapp.com/callback?code=AUTH_CODE_123&state=xyz789

The browser interprets this URL as-is. If your redirect URI registered as:

https://myapp.com/callback?return_url=https://myapp.com/dashboard

The provider redirects to:

https://myapp.com/callback?code=...&state=...&return_url=https://myapp.com/dashboard

This works because the provider appends query parameters to the existing ones. But if your return_url value contains special characters:

https://myapp.com/callback?return_url=https://myapp.com/path?page=1&lang=en

The &lang=en becomes a separate parameter in the provider's redirect unless the provider percent-encodes the appended value. Not all providers do this consistently.


Encoding Scenarios and Solutions

Scenario A: Static Redirect URI

Problem: redirect_uri_mismatch despite identical URIs.

Check: Trailing slash, case sensitivity, query parameter order.

Fix: Copy the exact URI from the provider dashboard into your application configuration. Do not retype it.

Scenario B: Redirect URI with Query Parameters

Problem: Your redirect URI includes ?source=web&utm_campaign=signup but the provider fails to match.

Check: The provider may decode the parameter value before comparison, turning %26 into & and breaking the URI structure.

Fix: Register the decoded form https://myapp.com/callback?source=web&utm_campaign=signup if the provider supports query parameters in redirect URIs. Some providers explicitly disallow this — check their documentation.

Scenario C: Dynamically Constructed Redirect URI

Problem: Your application constructs the redirect URI at runtime with URLSearchParams or manual string building, but the encoding is inconsistent.

Fix: Use URL and URLSearchParams consistently at every layer:

function buildRedirectUri(basePath, params) {
  const url = new URL(basePath, 'https://myapp.com');
  Object.entries(params).forEach(([key, value]) => {
    url.searchParams.set(key, value);
  });
  return url.toString();
}

function buildAuthRequest(redirectUri) {
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'abc123',
    redirect_uri: redirectUri,
    state: crypto.randomUUID(),
  });
  return `https://auth.provider.com/authorize?${params.toString()}`;
}

Using URL and URLSearchParams ensures encoding is consistent and spec-compliant at every stage.

Scenario D: Nested URLs Inside Redirect URIs

Problem: Your redirect URI contains another URL as a query parameter value:

https://myapp.com/callback?next=https://myapp.com/dashboard?theme=dark

Check: The inner ?theme=dark is interpreted as separate query parameters by naive parsing.

Fix: Encode the inner URL:

const innerUrl = 'https://myapp.com/dashboard?theme=dark';
const redirectUri = `https://myapp.com/callback?next=${encodeURIComponent(innerUrl)}`;

Result: https://myapp.com/callback?next=https%3A%2F%2Fmyapp.com%2Fdashboard%3Ftheme%3Ddark


Testing Your Encoding

Before debugging a redirect_uri_mismatch for hours, verify what bytes the provider actually receives:

  1. Log the constructed authorization URL
  2. Copy it into the URL Encoder/Decoder
  3. Inspect the decoded query parameters
  4. Verify the redirect_uri parameter decodes to exactly your registered URI
// Debugging helper
function debugRedirectUri(registeredUri, constructedUrl) {
  const parsed = new URL(constructedUrl);
  const sentUri = parsed.searchParams.get('redirect_uri');

  console.log('Registered:', registeredUri);
  console.log('Sent (encoded):', sentUri);
  console.log('Sent (decoded):', decodeURIComponent(sentUri));
  console.log('Match:', registeredUri === decodeURIComponent(sentUri));
}

Related Resources

For more on the encoding issues that cause OAuth redirect failures:


FAQ

Why does my OAuth redirect work in development but fail in production?

Development often uses localhost or 127.0.0.1 without query parameters. Production redirects commonly include source tracking, environment identifiers, or dynamic return paths that introduce encoding-sensitive characters.

Should I URL-encode the redirect URI in the authorization request?

Yes. The redirect URI is a query parameter value and must be percent-encoded. Use URLSearchParams or encodeURIComponent() to encode it properly.

Why do some providers accept trailing slashes and others don't?

Providers differ in their comparison logic. Some normalize before comparison; others compare byte-for-byte. Always check the provider's documentation and test with their exact expected format.

Can I register multiple redirect URIs?

Most providers support multiple URIs. Register all variants you use — with and without trailing slash, with and without specific query parameters — to avoid mismatch errors.

What is double encoding and how does it cause OAuth failures?

Double encoding happens when an already encoded value is encoded again. %20 becomes %2520. If the provider expects a decoded value but receives a double-encoded one, comparison fails. This commonly occurs when developers manually encode a value that gets encoded again by URLSearchParams.


Final Thoughts

OAuth2 redirect URI failures are almost never about authentication logic. They are about byte-level string comparison after multiple layers of encoding, decoding, and transport. The URI you registered, the URI your application constructed, and the URI the provider decoded must all be identical at the byte level.

The most reliable approach is to minimize complexity. Use a simple redirect URI without query parameters whenever possible. Append state and return paths as separate mechanisms — the OAuth state parameter for cross-request integrity, and a short-lived session store for return paths.

When encoding issues do arise, trace the URI through each stage of the pipeline. The URL Encoder/Decoder shows exact percent-encoded representations. The Base64 Encoder & Decoder helps when nonce or state values use Base64 encoding. And the Regex Tester is useful for validating URI patterns before they reach production.