← Back to Blog

The URL Encoding Mistake That Broke Our OAuth 2.0 Redirect URIs

Our OAuth 2.0 integration worked fine for simple redirect URIs. When we added a redirect URI with query parameters, login stopped working. The cause was a single encoding method choice that produces a + instead of %20 for spaces.

Pankaj Kumar
Senior Software Engineer — .NET, Blazor, ASP.NET Core
4+ years building production .NET and Blazor applications. Every DevToolsHub tool and article comes from real daily development work — not documentation summaries.
Published 28 Jul 2026· Last reviewed Jul 2026· 9 min read · About the author →
OAuth redirect URI encoding is one of those things where the docs say 'URL encode it' and don't mention that there are two different URL encoding conventions with different output. We found out the hard way.
Key takeaways
  • HttpUtility.UrlEncode and Uri.EscapeDataString produce different output — + vs %20 for spaces
  • OAuth 2.0 servers expect RFC 3986 percent-encoding (%20 for spaces), not HTML form encoding (+ for spaces)
  • The redirect_uri parameter must be URL-encoded as a value when included in the authorization URL
  • Always use Uri.EscapeDataString for values embedded in URLs — it produces correct RFC 3986 output
  • The + character in a URL query string decodes as a space in some HTTP stacks, causing silent OAuth failures
Table of Contents

The incident

We were integrating with a major identity provider (we can't name them, but they're used by millions of developers) using OAuth 2.0 authorization code flow. The basic flow worked: user clicks login, gets redirected to the IdP's authorization page, logs in, gets redirected back to our app with an authorization code, and we exchange that code for tokens. This worked correctly in our initial integration test.

Two weeks later, we needed to add a multi-tenant feature. The redirect URI needed to carry the tenant identifier as a query parameter, so our callback could route to the correct tenant: https://app.example.com/callback?tenant=acme-corp. We updated the redirect URI registration in the IdP's developer portal, updated our code, and tested. Login stopped working. The IdP returned a generic "redirect_uri mismatch" error with no further details.

What the code looked like

// Building the OAuth authorization URL — the original implementation
public string BuildAuthorizationUrl(string tenantSlug, string state)
{
    var redirectUri = $"https://app.example.com/callback?tenant={tenantSlug}";

    var queryParams = new Dictionary<string, string>
    {
        ["response_type"] = "code",
        ["client_id"]     = _config["OAuth:ClientId"]!,
        ["redirect_uri"]  = HttpUtility.UrlEncode(redirectUri), // <-- the bug
        ["scope"]         = "openid profile email",
        ["state"]         = state
    };

    var queryString = string.Join("&",
        queryParams.Select(kv => $"{kv.Key}={kv.Value}"));

    return $"https://idp.example.com/oauth/authorize?{queryString}";
}

The code looks reasonable. We're URL-encoding the redirect_uri value before embedding it in the authorization URL, which is correct in principle. The problem is that HttpUtility.UrlEncode produces HTML form encoding (the application/x-www-form-urlencoded format), not RFC 3986 percent-encoding. The critical difference: form encoding represents spaces as +, while RFC 3986 percent-encoding represents them as %20.

The specific failure

Our redirect URI https://app.example.com/callback?tenant=acme-corp contains no spaces — so in this particular case, that specific difference wouldn't matter. But it contains a colon, two forward slashes, a question mark, and an equals sign — all of which are reserved characters that must be percent-encoded when they appear as a value in a query parameter.

Running both encoding methods against our redirect URI reveals the difference:

var redirectUri = "https://app.example.com/callback?tenant=acme-corp";

// HttpUtility.UrlEncode — HTML form encoding
var formEncoded = HttpUtility.UrlEncode(redirectUri);
// Result: "https%3a%2f%2fapp.example.com%2fcallback%3ftenant%3dacme-corp"
// Note: lowercase hex digits (%3a, %2f) — some strict parsers reject lowercase

// Uri.EscapeDataString — RFC 3986 percent-encoding
var rfc3986Encoded = Uri.EscapeDataString(redirectUri);
// Result: "https%3A%2F%2Fapp.example.com%2Fcallback%3Ftenant%3Dacme-corp"
// Note: uppercase hex digits (%3A, %2F) — RFC 3986 standard

// The OAuth server registered:
// "https%3A%2F%2Fapp.example.com%2Fcallback%3Ftenant%3Dacme-corp"
// What we sent (HttpUtility.UrlEncode):
// "https%3a%2f%2fapp.example.com%2fcallback%3ftenant%3dacme-corp"
// Difference: uppercase vs lowercase hex — this IdP performed case-sensitive comparison

That was the actual failure mode: our IdP stored the registered redirect URI using uppercase hex encoding (RFC 3986 standard) and compared incoming redirect_uri parameters exactly. HttpUtility.UrlEncode produces lowercase hex (%3a, %2f), while Uri.EscapeDataString produces uppercase (%3A, %2F). Lowercase %3a did not match registered uppercase %3A in the IdP's comparison — "redirect_uri mismatch."

The fix and a complete comparison of .NET URL encoding methods

// The correct implementation — using Uri.EscapeDataString
public string BuildAuthorizationUrl(string tenantSlug, string state)
{
    var redirectUri = $"https://app.example.com/callback?tenant={tenantSlug}";

    var queryParams = new Dictionary<string, string>
    {
        ["response_type"] = "code",
        ["client_id"]     = _config["OAuth:ClientId"]!,
        ["redirect_uri"]  = Uri.EscapeDataString(redirectUri), // <-- RFC 3986 encoding
        ["scope"]         = "openid profile email",
        ["state"]         = Uri.EscapeDataString(state)        // also encode state
    };

    var queryString = string.Join("&",
        queryParams.Select(kv => $"{kv.Key}={kv.Value}"));

    return $"https://idp.example.com/oauth/authorize?{queryString}";
}

// Even better: use ASP.NET Core's QueryString builder which handles encoding correctly
public string BuildAuthorizationUrl(string tenantSlug, string state)
{
    var redirectUri = $"https://app.example.com/callback?tenant={tenantSlug}";

    var queryString = QueryString.Create(new[]
    {
        KeyValuePair.Create("response_type", "code"),
        KeyValuePair.Create("client_id",     _config["OAuth:ClientId"]!),
        KeyValuePair.Create("redirect_uri",  redirectUri),
        KeyValuePair.Create("scope",         "openid profile email"),
        KeyValuePair.Create("state",         state)
    });

    return $"https://idp.example.com/oauth/authorize{queryString}";
    // QueryString.Create uses Uri.EscapeDataString internally — correct output
}

The three URL encoding methods in .NET and when to use each

// Reference: what each .NET encoding method produces for common characters
var input = "hello world & more/info";

// 1. Uri.EscapeDataString — RFC 3986, uppercase hex, spaces as %20
//    USE FOR: query parameter values, path segments, OAuth parameters
Uri.EscapeDataString(input);
// Output: "hello%20world%20%26%20more%2Finfo"

// 2. HttpUtility.UrlEncode — HTML form encoding, lowercase hex, spaces as +
//    USE FOR: application/x-www-form-urlencoded form POST bodies only
//    DO NOT USE for URLs you're building programmatically
HttpUtility.UrlEncode(input);
// Output: "hello+world+%26+more%2finfo"

// 3. WebUtility.UrlEncode — same as HttpUtility.UrlEncode but available in .NET Standard
//    Same caveats: produces + for spaces, lowercase hex
WebUtility.UrlEncode(input);
// Output: "hello+world+%26+more%2finfo"

// 4. Uri.EscapeUriString — encodes a full URI, NOT individual values
//    Preserves ://,?,&,= characters — use ONLY when you have a complete URL
//    DO NOT use this for encoding a value to be embedded in a URL
Uri.EscapeUriString("https://example.com/search?q=hello world");
// Output: "https://example.com/search?q=hello%20world"
// (preserves :, /, ?, = but encodes the space in the value)

The subtle + vs %20 failure mode with OAuth state parameters

The case-sensitivity issue was specific to our IdP, but there's a more universally dangerous encoding problem with the OAuth state parameter. If your state contains spaces (or characters that form-encoding represents as +), and the IdP echoes the state back in the callback URL, some HTTP stacks will decode the + back to a space — but only if the + appears in a query string context. This can cause your CSRF validation to fail if you compare the incoming state against the stored state using string equality, because one has a space and the other has a +.

// Safe state parameter generation and validation
public string GenerateState(string returnUrl)
{
    // Generate a random 32-byte state token and encode as Base64URL (no + or /)
    var randomBytes = RandomNumberGenerator.GetBytes(32);
    var randomPart  = WebEncoders.Base64UrlEncode(randomBytes);

    // Include the return URL encoded safely
    var encodedReturnUrl = WebEncoders.Base64UrlEncode(
        Encoding.UTF8.GetBytes(returnUrl));

    // Combine with a separator that won't appear in Base64URL output
    return $"{randomPart}.{encodedReturnUrl}";
}

public bool ValidateState(string incomingState, string expectedState)
{
    // Use constant-time comparison to prevent timing attacks
    return CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(incomingState),
        Encoding.UTF8.GetBytes(expectedState));
}

The generalizable lesson

When you see "URL encode this value" in OAuth 2.0 documentation, it means RFC 3986 percent-encoding — uppercase hex digits, spaces as %20. It does not mean HTML form encoding. In .NET, the only correct method for encoding a value to be embedded in a URL is Uri.EscapeDataString. HttpUtility.UrlEncode and WebUtility.UrlEncode are for HTML form POST bodies.

Before registering a redirect URI with an OAuth provider, paste it into the URL Encoder and check what the encoded form looks like. Then compare it against what your code generates. If there's a mismatch — especially in case or in space encoding — that's your failure mode before you even write the integration test.

Try the free tool
URL Encoder / Decoder

Encode or decode URL strings online. Compare the output of different encoding approaches and verify that special characters like + vs %20 are handled correctly for your use case.

Open URL Encoder / Decoder