Skip to content

HTTP/2: Add automatic downgrade to HTTP/1.1 for Windows authentication#123827

Open
Copilot wants to merge 19 commits intomainfrom
copilot/fix-http2-auth-downgrade
Open

HTTP/2: Add automatic downgrade to HTTP/1.1 for Windows authentication#123827
Copilot wants to merge 19 commits intomainfrom
copilot/fix-http2-auth-downgrade

Conversation

Copy link
Contributor

Copilot AI commented Jan 31, 2026

Description

Session-based authentication (NTLM/Negotiate) requires persistent connections and fails over HTTP/2. When SocketsHttpHandler receives a 401 with these schemes on HTTP/2, it now automatically retries on HTTP/1.1 if the request is retryable.

Changes

Core retry mechanism:

  • Added RequestRetryType.RetryOnSessionAuthenticationChallenge to signal auth-driven downgrades
  • Http2Connection.SendAsync() detects session auth challenges (401 + NTLM/Negotiate) and checks version policy before attempting retry
  • HttpConnectionPool.SendWithVersionDetectionAndRetryAsync() catches and retries on HTTP/1.1

Safety constraints:

  • Only retries when ALL conditions are met:
    • Request has no content (request.Content == null)
    • Version policy allows downgrade (request.VersionPolicy == HttpVersionPolicy.RequestVersionOrLower)
  • Returns original 401 response when any retry condition is not met (preserves backward compatibility)
  • No race conditions or unsafe content access

Test coverage:

  • Validates successful downgrade from HTTP/2 to HTTP/1.1 for GET requests without content
  • Validates 401 behavior when version policy prevents downgrade (RequestVersionExact)
  • Validates 401 behavior when request has content (POST requests)

Example

var handler = new SocketsHttpHandler { Credentials = credentials };
var client = new HttpClient(handler);

// Works: GET request without content + RequestVersionOrLower
var request = new HttpRequestMessage(HttpMethod.Get, "https://server/api");
request.Version = HttpVersion.Version20;
request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower;

// Previously: 401 Unauthorized (no retry)
// Now: Automatic retry on HTTP/1.1, successful auth
var response = await client.SendAsync(request);

// Not retried: POST request with content
var postRequest = new HttpRequestMessage(HttpMethod.Post, "https://server/api");
postRequest.Content = new StringContent("data");
postRequest.Version = HttpVersion.Version20;
postRequest.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower;

// Returns: 401 Unauthorized (cannot safely retry with content)
var postResponse = await client.SendAsync(postRequest);

// Not retried: GET request with RequestVersionExact policy
var exactRequest = new HttpRequestMessage(HttpMethod.Get, "https://server/api");
exactRequest.Version = HttpVersion.Version20;
exactRequest.VersionPolicy = HttpVersionPolicy.RequestVersionExact;

// Returns: 401 Unauthorized (version policy doesn't allow downgrade)
var exactResponse = await client.SendAsync(exactRequest);

Limitations

This implementation only handles requests without content (e.g., GET, HEAD, DELETE without body) when the version policy allows downgrade. POST and PUT requests with content, or requests with RequestVersionExact policy, will receive a 401 response and must be handled by the application, as we cannot safely retry requests with content that may not be rewindable or when downgrade is not permitted.

Original prompt

This section details on the original issue you should resolve

<issue_title>HTTP2: SocketsHttpHandler doesn't downgrade to HTTP/1.1 during Windows authentication</issue_title>
<issue_description>When SocketsHttpHandler connects to a server using HTTP/2, it is unable to downgrade to HTTP/1.1 when the server requests Windows authentication (Negotiate or NTLM). The net result is that 401 is returned to the client without even trying to reconnect (with HTTP/1.1) and send credentials.

// Repro will be posted later
```</issue_description>

<agent_instructions>Add logic that allows the SocketsHttpHandler connection pool to attempt to retry requests when the HTTP/2 request failed due to a Windows auth challenge, and the user-specified version & policy allow for the upgrade to happen.
Keep in mind that not every request will be possible to retry (e.g. a POST where we've tried sending the body and are unable to rewind).</agent_instructions>

## Comments on the Issue (you are @copilot in this section)

<comments>
<comment_new><author>@karelz</author><body>
@wfurt can you please chat with @davidsh about design / ideas here?</body></comment_new>
<comment_new><author>@karelz</author><body>
Given we scoped HTTP/2 to gRPC scenarios, this can wait for post-3.0.</body></comment_new>
<comment_new><author>@Tratcher</author><body>
I noticed this while doing some auth testing. IE, Chrome, and Edge all have this fallback behavior. Also, the downgrade isn't just for the auth handshake, all subsequent requests are made on the HTTP/1.1 connection so they share the cached auth context.

WinHttpHandler is a bit different. The first HTTP/2 request challenges, falls back to HTTP/1.1, authenticates, and completes successfully. However, the next request is still made over HTTP/2, the client attempts the fallback again, and then it re-authenticates on the already authenticated connection. That could be a major performance hole to fall into.</body></comment_new>
<comment_new><author>@halter73</author><body>
This came up again. This time from a SignalR client user. See https://github.com/dotnet/aspnetcore/issues/45371. Prior to .NET 7, the SignalR client forced HTTP/1.1 but we started allowing HTTP/2 in .NET 7. We're considering backporting a fix for SignalR to force HTTP/1.1 if the customer sets `HttpConnectionOptions.UseDefaultCredentials` to true.

@karelz Even though we have a fix for SignalR, we can't help but think HttpClient should have avoided this for us. Do you think we could do something similar in .NET 8 at the SocketsHttpHandler level when UseDefaultCredentials is set? Of course, the handler could be smarter than SignalR and wait for a challenge before downgrading,</body></comment_new>
<comment_new><author>@karelz</author><body>
@wfurt @ManickaP @CarnaViire any thoughts on this? (marking for re-triage given new scenario being impacted)</body></comment_new>
<comment_new><author>@wfurt</author><body>
There are (at least) two distance parts IMHO. 
1) get parity with WinHttp - The existing retry logic will be problematic as this needs new connection (and possibly check for version policy). This can probably be done by refactoring the connection pool logic.
2) Browsers do have global cache and therefore they can remember state. `HttpClientHandler` is stateless and each request is processed independently. To fix that, we would need whole new concept in `SocketsHttpHandler`.  That may be useful for other reasons but it would be fundamental change IMHO.

While we _should_ do something I would keep it in `Future` for now given complexity and no up votes e.g. no real customer pain. The workaround is somewhat easy -> just set version to 1.1 for problematic sites. </body></comment_new>
<comment_new><author>@karelz</author><body>
Agreed with @wfurt. It seems it is still rather rare with a complex solution, so leaving it in Future sounds like the right option now.
@halter73 let us know if you disagree.</body></comment_new>
</comments>

✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.

Copilot AI changed the title [WIP] Fix SocketsHttpHandler downgrading to HTTP/1.1 for Windows authentication HTTP/2: Add automatic downgrade to HTTP/1.1 for Windows authentication Jan 31, 2026
Copilot AI requested a review from MihaZupan January 31, 2026 01:21
Copy link
Member

@MihaZupan MihaZupan left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@MihaZupan
Copy link
Member

The PR change as-is would only handle requests without a body, and automatically retry on 1.1.
This has two problems:

  • We'll do an additional roundtrip for every successful request
  • As soon as you try sending a POST request, you'll be in the same situation as before

Alternatively, we could disable HTTP/2 on the connection pool if we reach this case, so future requests would just start with 1.1. This also has problems:

  • If you're talking to an API that only needs session auth in an edge case, you might not love the overhead of ALL requests now avoiding HTTP/2. Maybe that's niche enough to ignore.
  • If your first request is POST, it'll still fail since we don't know whether we can retry ourselves.
    • Doesn't make it worse than it already is today. At least if you retry yourself it would start working after the first request.
    • We should be able to fix this in the future by adding some sort of TryReset API on the HttpContent (which I was thinking about for other scenarios already)

I'm leaning towards trying out the alternative approach.

Copilot AI and others added 18 commits March 17, 2026 16:52
Co-authored-by: MihaZupan <25307628+MihaZupan@users.noreply.github.com>
Co-authored-by: MihaZupan <25307628+MihaZupan@users.noreply.github.com>
Co-authored-by: MihaZupan <25307628+MihaZupan@users.noreply.github.com>
Co-authored-by: MihaZupan <25307628+MihaZupan@users.noreply.github.com>
Co-authored-by: MihaZupan <25307628+MihaZupan@users.noreply.github.com>
- Fix race condition by checking requestBodyTask.IsCompleted instead of flag
- Return 401 response when retry is not possible (preserve existing behavior)
- Remove unused _requestBodyStreamingStarted flag and helper method
- Add test for successful downgrade to HTTP/1.1

Co-authored-by: MihaZupan <25307628+MihaZupan@users.noreply.github.com>
- Only retry when request.Content == null (no race conditions)
- Remove cancellation call (not needed and not safe)
- Always return 401 when request has content (can't safely retry)
- Add test for POST with content that returns 401

Co-authored-by: MihaZupan <25307628+MihaZupan@users.noreply.github.com>
- Only throw retry exception when version policy allows downgrade
- Simplify connection pool logic since policy is pre-validated
- Ensures 401 response is always returned when retry not possible

Co-authored-by: MihaZupan <25307628+MihaZupan@users.noreply.github.com>
When an HTTP/2 request receives a session-based auth challenge (NTLM/Negotiate),
disable HTTP/2 on the connection pool so all future requests use HTTP/1.1 directly.
This avoids extra roundtrips for subsequent requests and ensures POST requests work
after the initial downgrade.

- Add DisableHttp2() method to HttpConnectionPool
- Catch RetryOnSessionAuthenticationChallenge in SendWithVersionDetectionAndRetryAsync
  and call DisableHttp2() before retrying
- Rewrite tests using HttpAgnosticLoopbackServer for proper HTTP/2→1.1 testing
- Add tests for: pool downgrade, subsequent requests, exact version policy,
  content requests, and non-auth HTTP/2 responses

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Instead of disabling HTTP/2 entirely (which would break HTTP/2-only requests),
add a _http2SessionAuthSeen flag that only affects requests with
RequestVersionOrLower policy. HTTP/2-only requests (RequestVersionExact,
RequestVersionOrHigher) continue to work on HTTP/2 as before.

Also update GetSslOptionsForRequest to avoid negotiating h2 ALPN for
downgradeable requests after session auth has been seen.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Make _http2SessionAuthSeen volatile for cross-thread visibility
- Always set the pool flag when a session auth challenge is detected,
  even for requests that can't be retried (e.g. POST with content).
  This ensures subsequent downgradeable requests benefit immediately.
- Move flag-setting from catch handler to Http2Connection.SendAsync
- Add test: POST triggers flag, subsequent GET uses HTTP/1.1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Instead of inspecting the request to decide ALPN, pass the intended
protocol version into ConnectAsync. This avoids the bug where a queued
HTTP/2 connection establishment could pick up a downgradeable request
and negotiate HTTP/1.1 ALPN, which would trigger HandleHttp11Downgrade
and disable HTTP/2 for the entire pool.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@MihaZupan MihaZupan force-pushed the copilot/fix-http2-auth-downgrade branch from f355c6e to bcd9d4b Compare March 18, 2026 17:17
@MihaZupan MihaZupan added this to the 11.0.0 milestone Mar 18, 2026
@MihaZupan MihaZupan marked this pull request as ready for review March 19, 2026 14:10
Copilot AI review requested due to automatic review settings March 19, 2026 14:10
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates SocketsHttpHandler to automatically fall back from HTTP/2 to HTTP/1.1 when an HTTP/2 response indicates a session-based Windows authentication challenge (NTLM/Negotiate), enabling successful authentication for retryable requests while preserving behavior when downgrade is not allowed.

Changes:

  • Added a new retry signal (RequestRetryType.RetryOnSessionAuthenticationChallenge) to represent auth-driven protocol fallback.
  • Detected NTLM/Negotiate challenges in Http2Connection.SendAsync() and triggered an HTTP/1.1 retry (when safe), while also marking the pool to prefer HTTP/1.1 for future downgradeable requests.
  • Updated connection establishment to explicitly control ALPN selection based on whether the connection is intended for HTTP/2, and added functional tests for downgrade behavior and constraints.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/libraries/System.Net.Http/tests/FunctionalTests/NtAuthTests.FakeServer.cs Adds functional tests verifying HTTP/2-to-HTTP/1.1 downgrade behavior and non-downgrade constraints.
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/Http2Connection.cs Detects session-auth challenges on HTTP/2 and signals retry/pool behavior updates.
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.cs Skips HTTP/2 after session-auth is seen (for downgradeable requests) and handles the new retry type by retrying on HTTP/1.1.
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http2.cs Adds pool flag tracking for session-auth challenges.
src/libraries/System.Net.Http/src/System/Net/Http/SocketsHttpHandler/ConnectionPool/HttpConnectionPool.Http1.cs Updates connection creation to pass the new “isForHttp2” signal into shared connect logic.
src/libraries/System.Net.Http/src/System/Net/Http/RequestRetryType.cs Adds a new retry enum value for session-auth downgrade retries.

You can also share your feedback on Copilot code review. Take the survey.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HTTP2: SocketsHttpHandler doesn't downgrade to HTTP/1.1 during Windows authentication

5 participants