.NET 11 RC1: Unions Go Stable and SignalR Connections Survive Token Expiry

.NET 11 RC1 release candidate go-live badge on a dark background

Microsoft just told you to ship it. .NET 11 RC1 landed on September 8 with a go-live support license. Their words, not mine: "you can confidently use this release for your production applications." So read this post differently to the preview posts. This is the stuff you're allowed to bet on.

Previews introduce features. Release candidates finalise them. And RC1 finalises the two things I've been tracking all year: C# 15 union types no longer need a preview flag, and the SignalR authentication refresh work that first landed in Preview 6 is now finished across all three layers. .NET client, TypeScript client, Blazor Server circuits.

The dates, so you can plan: RC1 shipped September 8, 2026. GA is November 10, 2026. .NET 11 is an STS release, supported through November 9, 2028. Full announcement on the official .NET blog, SDK at get.dot.net/11.

New to the series? I covered the foundations in my first look at .NET 11. This post is about what's now safe to build on, and what still isn't.

C# 15 Is Now the Default: Unions Without the Preview Flag

Delete <LangVersion>preview</LangVersion> from your csproj. That's the headline. C# 15 is now the default language version for projects targeting .NET 11. Stabilised in this batch: collection expression arguments, union types, non-virtual static interface members, closed class hierarchies, labeled break and continue, and extension indexers.

Unions are the one people have been asking for since forever. Here's the shape, straight from the release notes:

public record Success(string Message);
public record Failure(int ErrorCode);

public union Result(Success, Failure);

static string Describe(Result result) => result switch
{
    Success(var message) => message,
    Failure(var errorCode) => $"Error {errorCode}"
};

Look at that switch again. No discard arm. No _ => throw new UnreachableException(). The union declares a closed set of case types, so the compiler can prove the switch is exhaustive. Add a third case to Result next year and every switch that doesn't handle it becomes a compile error instead of a 3 a.m. production surprise. I've been paged for exactly that kind of surprise. Compile errors are cheaper.

I flagged unions as the feature to watch back in Preview 6, when they still needed the flag. Now they're stable, which changes things for library authors: you can ship union-based public APIs targeting .NET 11 today. Result, Option, state-machine states. The modelling patterns F# people have smugly enjoyed for years are finally first-class C#.

Two supporting details. Roslyn exposes ITypeSymbol.UnionCaseTypes for analyzer authors, so tooling can build on unions from day one. And System.Text.Json gains union-aware polymorphism, so your union-shaped DTOs serialise sensibly.

The counter-example: Unsafe Evolution stays in preview

The contrast tells you what "stable" actually means to this team. Unsafe Evolution, the new memory-safety rules effort, is independent of C# 15 and stays in preview for all of .NET 11. It still needs both flags:

<LangVersion>preview</LangVersion>
<Features>$(Features);updated-memory-safety-rules</Features>

RC1 did refine it: await now works inside an unsafe context, a new safe modifier can mark declarations as requires-unsafe-free (handy for generated LibraryImport methods), and unsafe on delegates, static constructors, destructors, and type declarations is now an error because it never established a meaningful unsafe context anyway.

My take: interesting direction, don't touch it in production yet. Microsoft is being deliberate about what graduates and what doesn't. That discipline is exactly why I trust the go-live license on everything else.

The Auth-Refresh Trio: Long-Lived Connections Survive Token Expiry

This is the centrepiece of the release, and it's really one story. One problem, fixed at three layers.

The problem: your access tokens live for 15 minutes. Your SignalR connections live for hours. Those two facts have been at war for a decade. When the token expired, your options were bad. Keep an authenticated connection running on a stale identity, or kill the connection and force the client to reconnect, renegotiate transport, and rebuild whatever state it was carrying. Every team running real-time systems in production has hand-rolled some reconnect-and-replay machinery to paper over this. I've built it at least three times myself, and I wasn't proud of any of them.

.NET 11 deletes that machinery. The client hands the server a fresh token over the existing connection, the server swaps the identity in place, and the wire never goes cold.

.NET 11 SignalR authentication refresh flow keeping the connection alive across token expiry

Layer 1: SignalR server and .NET client, finalised

The server side is opt-in per hub, and it comes with a security hook I genuinely like:

using System.Security.Claims;

app.MapHub<ClockHub>("/clock", options =>
{
    options.EnableAuthenticationRefresh = true;
    options.CloseOnAuthenticationExpiration = true;
    options.OnAuthenticationRefresh = context =>
    {
        var previousSubject = context.PreviousUser.FindFirstValue("sub")
            ?? context.PreviousUser.FindFirstValue(ClaimTypes.NameIdentifier);
        var newSubject = context.NewUser.FindFirstValue("sub")
            ?? context.NewUser.FindFirstValue(ClaimTypes.NameIdentifier);

        return Task.FromResult(
            previousSubject is not null &&
            string.Equals(previousSubject, newSubject, StringComparison.Ordinal));
    };
});

That sub-claim comparison in OnAuthenticationRefresh is the pattern to copy. A refresh should update claims for the same user, never swap one identity for another mid-connection. The server gets to reject any refresh that smells like an identity swap. Refresh is not re-login, and the API makes that distinction enforceable.

The .NET client is where the "75% easier" maths kicks in:

await using var connection = new HubConnectionBuilder()
    .WithUrl(serverUrl, options =>
        options.AccessTokenProvider = GetAccessTokenAsync)
    .WithAuthenticationRefresh(options =>
    {
        options.EnableAutoRefresh = true;
        options.RefreshBeforeExpiration = TimeSpan.FromMinutes(2);
    })
    .Build();

connection.AuthenticationRefreshed += context =>
{
    Console.WriteLine($"New token lifetime: {context.NewTokenLifetime}");
    return Task.CompletedTask;
};

connection.AuthenticationRefreshFailed += context =>
{
    Console.WriteLine(context.Exception.Message);
    return Task.CompletedTask;
};

await connection.StartAsync();

// Refresh immediately after acquiring a token with updated claims.
await connection.RefreshAuthenticationAsync();

Set EnableAutoRefresh, tell it how early to refresh with RefreshBeforeExpiration, and the client re-invokes your AccessTokenProvider before expiry. Need to push new claims right now, say after the user upgrades their subscription? Call RefreshAuthenticationAsync() manually. That's the whole integration.

Upgrading from Preview 7? Three breaking changes will bite you immediately:

  • The OnAuthenticationRefreshed / OnAuthenticationRefreshFailed callbacks moved off AuthenticationRefreshOptions and are now the HubConnection.AuthenticationRefreshed and HubConnection.AuthenticationRefreshFailed events.
  • Microsoft.AspNetCore.Http.Connections.AuthenticationRefreshContext moved to Microsoft.AspNetCore.Connections.Features.AuthenticationRefreshContext.
  • IConnectionUserRefreshFeature was renamed to IConnectionAuthenticationRefreshFeature.

Layer 2: TypeScript client parity

The same mental model, in the browser:

const connection = new signalR.HubConnectionBuilder()
  .withUrl("/clock", { accessTokenFactory: getAccessToken })
  .withAuthenticationRefresh({
    enableAutoRefresh: true,
    refreshBeforeExpirationInMilliseconds: 120_000,
  })
  .build();

connection.onAuthenticationRefreshed((context) => {
  console.log(`New token lifetime: ${context.newTokenLifetimeInSeconds}`);
});

connection.onAuthenticationRefreshFailed((context) => {
  console.error(context.error);
});

await connection.start();

// Refresh immediately after acquiring a token with updated claims.
await connection.refreshAuthentication();

Put the two client snippets side by side. WithAuthenticationRefresh / withAuthenticationRefresh. RefreshAuthenticationAsync / refreshAuthentication. Mirrored events. One pattern to learn, both ends of the wire. That's deliberate API design, and it's the difference between a one-page onboarding doc and a five-page one.

Layer 3: Blazor Server circuits, zero config

Here's the payoff. Interactive Server components now receive the refreshed ClaimsPrincipal without reconnecting the circuit, and the Blazor component hub and client enable authentication refresh automatically. No code sample for this section because there's nothing to configure.

When a refresh lands, Blazor updates the authentication state and raises AuthenticationStateChanged. Every component consuming AuthenticationStateProvider, including AuthorizeView, re-renders with the refreshed identity and claims.

Think about what that means for the user. An admin grants someone a new role mid-session. Before .NET 11: the user reloads the page, or logs out and back in because someone in support told them to. Now the UI just updates. New menu items appear. Gated panels unlock. No reconnect, no reload, no flicker.

This is my favourite kind of feature. Backend plumbing that directly produces better UX, and the person building the Blazor app didn't write a single line to get it.

OpenAPI: Deprecation Honesty and Environment-Aware Docs

Your API docs have been lying to consumers. You marked an endpoint [Obsolete] two years ago, your IDE screams at anyone who calls it internally, and your published OpenAPI document says nothing. External consumers keep building on the deprecated surface because the contract never told them otherwise.

RC1 fixes that automatically. [Obsolete] now maps to deprecated: true at all three levels: operations, schema types, and individual schema properties. The property-level support is the detail I like most. Here's the release notes sample, where a legacy record's Sku property gives way to StockKeepingUnit:

app.MapGet("/catalog/{id}", GetCatalogItem);

#pragma warning disable CS0618 // This example intentionally declares and maps obsolete APIs.
app.MapGet("/catalog/legacy/{id}", GetLegacyCatalogItem);

[Obsolete("Use /catalog/{id}.")]
static LegacyCatalogItem GetLegacyCatalogItem(int id) =>
    new(id, $"Product {id}", $"SKU-{id:D4}");

static CatalogItem GetCatalogItem(int id) =>
    new(id, $"Product {id}", $"SKU-{id:D4}");

public sealed record CatalogItem(
    int Id,
    string Name,
    string StockKeepingUnit);

[Obsolete("Use CatalogItem.")]
public sealed record LegacyCatalogItem(
    int Id,
    string Name,
    [property: Obsolete("Use StockKeepingUnit.")] string Sku);

#pragma warning restore CS0618

And the generated document, with zero custom transformers:

{
  "paths": {
    "/catalog/legacy/{id}": {
      "get": {
        "deprecated": true
      }
    }
  },
  "components": {
    "schemas": {
      "LegacyCatalogItem": {
        "deprecated": true,
        "properties": {
          "sku": {
            "deprecated": true
          }
        }
      }
    }
  }
}

Need to override the default for a specific API? IOpenApiOperationTransformer or IOpenApiSchemaTransformer still work. But the default is now honest.

The second OpenAPI improvement fixes a CI headache. If you generate documents at build time with Microsoft.Extensions.ApiDescription.Server, the new OpenApiGenerationEnvironment MSBuild property lets you run the generation under a specific hosting environment:

<PropertyGroup>
  <OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
  <OpenApiGenerationEnvironment>Development</OpenApiGenerationEnvironment>
</PropertyGroup>

It plays the same role as ASPNETCORE_ENVIRONMENT or DOTNET_ENVIRONMENT. If you have environment-gated endpoints or transformers, your CI-generated document finally matches what actually ships, instead of whatever the build agent happened to default to.

One more line worth writing: both of these are community contributions. The deprecation mapping from @fickleEfrit, the environment property from @ldsenow. The ASP.NET Core OSS pipeline is healthy, and it shows.

Negotiate Auth Gets TLS Channel Binding for Free

The best security fixes are the ones you get by doing nothing. Kestrel's Negotiate authentication handler now feeds the TLS endpoint channel binding token into the underlying Kerberos or NTLM exchange on HTTPS connections, and retains it across multi-round authentication. No configuration changes required.

Why it matters, in plain English (my framing, the release notes only state the mechanics): channel binding cryptographically ties the authentication exchange to the specific TLS session it's happening over. An attacker who intercepts and relays your Negotiate credentials to another connection now fails, because the binding token won't match the attacker's TLS channel. A whole class of credential-relay and man-in-the-middle attacks against Negotiate just got harder.

Fallback behaviour is sane: non-HTTPS connections, and HTTPS connections without an available channel binding token, keep the existing behaviour. You upgrade, you're safer, you did nothing. That's the whole section.

Experimental Blazor AI Components: Microsoft's Bet on Agentic UIs

The last item in RC1 is the most speculative, so let me be upfront about the maturity tier. The new Microsoft.AspNetCore.Components.AI package ships at 0.1.0-preview.1.26459.102 and stays prerelease for all of .NET 11. The go-live license does not cover this. Prototype yes, production no.

dotnet add package Microsoft.AspNetCore.Components.AI --version 0.1.0-preview.1.26459.102

What's in the box: building blocks for streaming chat, rich-text and tool rendering, human approval flows, and typed, shared, and predictive UI state. It works with any IChatClient from Microsoft.Extensions.AI, and for remote agents it speaks the AG-UI protocol via AGUIChatClient. Microsoft Agent Framework can expose an AIAgent through an ASP.NET Core AG-UI endpoint. The agentic .NET stack is starting to click together.

A full streaming chat UI is about twenty lines of Razor:

@using Microsoft.AspNetCore.Components.AI
@using Microsoft.Extensions.AI
@rendermode InteractiveServer
@implements IDisposable
@inject IChatClient ChatClient

<ChatPage Agent="_agent" Placeholder="Type a message...">
    <WelcomeContent>
        <p>Ask the agent a question.</p>
    </WelcomeContent>
</ChatPage>

@code {
    private UIAgent _agent = default!;

    protected override void OnInitialized()
    {
        _agent = new UIAgent(ChatClient);
    }

    public void Dispose() => _agent.Dispose();
}

ChatPage is the shell. It composes an AgentBoundary that cascades conversation state, a MessageList that handles streaming rendering plus typing/error/retry UI, and a MessageInput. UIAgent converts IChatClient streaming responses into observable content blocks, and a source generator turns [ToolBlock]-annotated classes into typed tool blocks you render with BlockRenderer<TBlock>.

The part that actually got my attention is human approval as a first-class UI primitive:

<BlockRenderer TBlock="FunctionApprovalBlock" Context="approval">
    <p>Allow <code>@approval.ToolName</code> to run?</p>
    <button @onclick="approval.Approve">Approve</button>
    <button @onclick="() => approval.Reject()">Reject</button>
</BlockRenderer>

The conversation pauses until the user decides. Alongside that sits predictive state: an agent can propose a change via SetPredictiveState, the UI previews it, and the user accepts or rejects it, with automatic rollback on failure or cancellation.

My opinion: this is the right design. Most agent UIs I've seen bolt on trust controls as an afterthought, a confirm dialog duct-taped over a tool call. Blazor is building approval and reversibility into the component model itself, which is exactly where they belong. If agentic UIs are going to earn user trust, "the human can always see, approve, and undo" has to be a primitive, not a pattern. Watch this package. Just don't ship it in 11.

The RC1 Verdict

The go-live license changes the calculus. This is not "try the preview and report bugs". It's supported for production, today, two months before GA.

My recommendation splits cleanly along the maturity tiers:

  • Adopt now: C# 15 unions and the SignalR/Blazor auth-refresh stack. If you run SignalR or Blazor Server in production, the auth-refresh trio alone justifies upgrading before GA. You're deleting reconnect-workaround code, not adding risk.
  • Enjoy for free: TLS channel binding for Negotiate and automatic circuit auth refresh. Zero effort, real wins.
  • Prototype only: Unsafe Evolution and the Blazor AI components. Both stay preview through all of .NET 11.

Your action items for this week: grab the SDK from get.dot.net/11, delete <LangVersion>preview</LangVersion> from your .NET 11 projects, and turn on EnableAuthenticationRefresh for one hub as a pilot. Measure how much reconnect-handling code you get to remove. I'd bet on 75% of it.

I'll cover GA in November when .NET 11 ships for real. Subscribe or follow to catch it. And if you're arriving mid-series, the earlier posts on the first .NET 11 preview and Preview 6's unions and auth-refresh debut show how far this release has come.