<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Aspnetcore on Russell ‛Russ’ Frith</title><link>https://russfrith.com/tags/aspnetcore/</link><description>Recent content in Aspnetcore on Russell ‛Russ’ Frith</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Thu, 22 Oct 2026 09:00:00 -0400</lastBuildDate><atom:link href="https://russfrith.com/tags/aspnetcore/index.xml" rel="self" type="application/rss+xml"/><item><title>ASP.NET Core Dropped Its Own Clock Wrapper. You Should Too.</title><link>https://russfrith.com/posts/aspnet-core-dropped-its-clock-wrapper/</link><pubDate>Thu, 22 Oct 2026 09:00:00 -0400</pubDate><guid>https://russfrith.com/posts/aspnet-core-dropped-its-clock-wrapper/</guid><description>(Unless It Means Something): why pass-through IClock wrappers are redundant now that TimeProvider is wired into ASP.NET Core Identity, and exactly how far that wiring actually goes as of .NET 11 RC1.</description><content:encoded><![CDATA[<aside
  style="
    margin: 20px 0;
    padding: 0 14px;
    border-inline-start: 3px solid var(--primary);
  "
>
  <p>.NET 11 is currently in Release Candidate 1 (shipped September 8, 2026), with general availability expected at .NET Conf in November 2026. Everything below was built and tested against the .NET 11.0.100-rc.1.26425.128 SDK, running inside the official <code>mcr.microsoft.com/dotnet/sdk:11.0.100-rc.1</code> Docker image. Some of this could still change before GA; see the <a href="https://github.com/russfrith/russfrith-blog/tree/master/companion-code/aspnet-core-dropped-its-clock-wrapper">companion code</a> for the runnable version of every test referenced here.</p>
</aside>

<p>Here&rsquo;s a test that doesn&rsquo;t sleep:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderDemo.Tests.LockoutTests.Lockout_clears_once_fake_time_advances_past_lockout_window</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[Fact]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task Lockout_clears_once_fake_time_advances_past_lockout_window()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> var fixture = <span style="color:#66d9ef">new</span> IdentityFixture();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> userManager = fixture.GetRequiredService&lt;UserManager&lt;AppUser&gt;&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> user = <span style="color:#66d9ef">new</span> AppUser { UserName = <span style="color:#e6db74">&#34;unlocks@example.com&#34;</span>, Email = <span style="color:#e6db74">&#34;unlocks@example.com&#34;</span> };
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> userManager.CreateAsync(user);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">var</span> i = <span style="color:#ae81ff">0</span>; i &lt; <span style="color:#ae81ff">3</span>; i++)
</span></span><span style="display:flex;"><span>    {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">await</span> userManager.AccessFailedAsync(user);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Assert.True(<span style="color:#66d9ef">await</span> userManager.IsLockedOutAsync(user));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// No Thread.Sleep: advance the fake clock past the 5-minute lockout window.</span>
</span></span><span style="display:flex;"><span>    fixture.TimeProvider.Advance(TimeSpan.FromMinutes(<span style="color:#ae81ff">6</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Assert.False(<span style="color:#66d9ef">await</span> userManager.IsLockedOutAsync(user));
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Lock a user out, advance a fake clock six minutes, assert they&rsquo;re unlocked. No <code>Thread.Sleep</code>, no wall-clock wait, no flaky CI run because the lockout window happened to be five minutes and the test needed six. That&rsquo;s <code>UserManager.IsLockedOutAsync</code> and <code>AccessFailedAsync</code>, ASP.NET Core Identity&rsquo;s actual lockout check, reading time from a <code>TimeProvider</code> I registered in the test&rsquo;s DI container.</p>
<p>I want to tell you this has worked since .NET 8, when <code>TimeProvider</code> first showed up in ASP.NET Core. It hasn&rsquo;t. I checked the previous three releases directly in the source, .NET 8, 9, and 10[<a href="#references">4</a>], and in all three, <code>IsLockedOutAsync</code> and <code>AccessFailedAsync</code> call <code>DateTimeOffset.UtcNow</code> directly. No hook, no DI, nothing to fake. The test above only passes on .NET 11 RC1, where that lockout code got a private <code>UtcNow()</code> helper that checks the DI container for a <code>TimeProvider</code> before falling back to the real clock. I went looking expecting the source to just confirm what I already believed, and it didn&rsquo;t.</p>
<p>That&rsquo;s the real news, and it&rsquo;s narrower than it sounds. We&rsquo;ll get back to exactly how narrow, after a detour through what <code>TimeProvider</code> gives you outside Identity entirely.</p>
<aside
  style="
    margin: 20px 0;
    padding: 0 14px;
    border-inline-start: 3px solid var(--primary);
  "
>
  <p>Many people did not start using <code>TimeProvider</code> when it shipped with .NET 8, and I may or may not be one of them. If an <code>IClock</code> wrapper already worked, there was no forcing function to rip it out for a BCL type that did the same job with extra ceremony. The forcing function, as it turns out, was never going to come from my own code. My <code>IClock</code> could wrap anything I&rsquo;d written myself, but it was never going to reach into <code>UserManager</code> and change what <code>DateTimeOffset.UtcNow</code> meant inside the framework.</p>
</aside>

<h2 id="why-everyone-wrote-one-of-these">Why everyone wrote one of these</h2>
<p>Before <code>TimeProvider</code>, there wasn&rsquo;t a shared, testable abstraction for &ldquo;now&rdquo; in the BCL. <code>DateTime.UtcNow</code> is a static property; you can&rsquo;t substitute it, can&rsquo;t advance it, can&rsquo;t make a test assert &ldquo;five minutes later&rdquo; without an actual five minutes or a mocking framework reaching for something it has no business touching. So everyone wrote the same ten lines: an <code>IClock</code> or <code>ISystemClock</code> or <code>IDateTimeProvider</code> interface, one real implementation, one fake. ASP.NET Core itself did this (<code>Microsoft.AspNetCore.Authentication.ISystemClock</code>) for exactly the same reason, years before anyone outside the framework team needed to care.</p>
<aside
  style="
    margin: 20px 0;
    padding: 0 14px;
    border-inline-start: 3px solid var(--primary);
  "
>
  <p><strong>A short, accurate timeline.</strong> ASP.NET Core marked <code>ISystemClock</code> (and <code>SystemClock</code>, and the authentication handler constructors that took one) <code>[Obsolete]</code> in <strong>.NET 8 Preview 5</strong>[<a href="#references">1</a>]. That&rsquo;s &ldquo;obsoleted,&rdquo; not &ldquo;removed.&rdquo; It still compiles, it still sits in the DI container, it just warns. The same breaking-change doc says Microsoft &ldquo;may&rdquo; remove it in a future version; as of the .NET 11 RC1 source I checked, it&rsquo;s still there. Don&rsquo;t read anything stronger into this than what actually happened: one framework-internal interface got a compiler warning, and a new BCL type, <code>System.TimeProvider</code>, became the thing ASP.NET Core actually reads time from in several, not all, of its time-dependent components.</p>
</aside>

<h2 id="timeprovider-the-general-case">TimeProvider, the general case</h2>
<p><code>TimeProvider</code> is the <strong>general-purpose clock abstraction</strong>: anywhere you&rsquo;d otherwise call <code>DateTime.UtcNow</code> or <code>DateTimeOffset.UtcNow</code>, from business logic to timers, <code>TimeProvider</code> is the injectable, fakeable replacement. It&rsquo;s worth knowing the whole surface because it&rsquo;s smaller than you&rsquo;d expect, and it quietly plugs into async APIs you already use.</p>
<p>Timestamps, the obvious part:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderDemo.Tests.TimeProviderSurfaceTests.GetUtcNow_reflects_the_starting_instant_and_subsequent_advances</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> start = DateTimeOffset.Parse(<span style="color:#e6db74">&#34;2026-06-01T12:00:00Z&#34;</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> timeProvider = <span style="color:#66d9ef">new</span> FakeTimeProvider(start);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Assert.Equal(start, timeProvider.GetUtcNow());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>timeProvider.Advance(TimeSpan.FromHours(<span style="color:#ae81ff">3</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Assert.Equal(start + TimeSpan.FromHours(<span style="color:#ae81ff">3</span>), timeProvider.GetUtcNow());
</span></span></code></pre></div><p>Less obvious: <code>Task.Delay</code>, <code>Task.WaitAsync</code>, <code>CancellationTokenSource</code>, and <code>PeriodicTimer</code> all grew <code>TimeProvider</code> overloads, specifically so tests could drive them without a real clock:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderDemo.Tests.TimeProviderSurfaceTests.Task_Delay_with_a_fake_time_provider_completes_once_advanced_past_the_delay</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> timeProvider = <span style="color:#66d9ef">new</span> FakeTimeProvider();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> delayTask = Task.Delay(TimeSpan.FromMinutes(<span style="color:#ae81ff">5</span>), timeProvider);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Assert.False(delayTask.IsCompleted);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>timeProvider.Advance(TimeSpan.FromMinutes(<span style="color:#ae81ff">5</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> delayTask.WaitAsync(TimeSpan.FromSeconds(<span style="color:#ae81ff">5</span>));
</span></span><span style="display:flex;"><span>Assert.True(delayTask.IsCompleted);
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderDemo.Tests.TimeProviderSurfaceTests.CancellationTokenSource_with_a_fake_time_provider_cancels_once_advanced_past_the_delay</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> timeProvider = <span style="color:#66d9ef">new</span> FakeTimeProvider();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">using</span> var cts = <span style="color:#66d9ef">new</span> CancellationTokenSource(TimeSpan.FromSeconds(<span style="color:#ae81ff">10</span>), timeProvider);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> tcs = <span style="color:#66d9ef">new</span> TaskCompletionSource();
</span></span><span style="display:flex;"><span>cts.Token.Register(() =&gt; tcs.TrySetResult());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>timeProvider.Advance(TimeSpan.FromSeconds(<span style="color:#ae81ff">10</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">await</span> tcs.Task.WaitAsync(TimeSpan.FromSeconds(<span style="color:#ae81ff">5</span>));
</span></span><span style="display:flex;"><span>Assert.True(cts.Token.IsCancellationRequested);
</span></span></code></pre></div><p>That&rsquo;s a retry policy, a timeout, a debounce, or a background poller, all testable in milliseconds of real wall-clock time instead of the seconds-to-minutes your production timeouts actually need.</p>
<p>That&rsquo;s <code>TimeProvider</code> in general. Now back to Identity, where this post started: how far does that general wiring actually reach?</p>
<h2 id="whats-actually-testable-by-version">What&rsquo;s actually testable, by version</h2>
<p>Here&rsquo;s what&rsquo;s actually testable with a fake clock, as of .NET 11 RC1, grouped by status. Some of these are backed by a <code>FakeTimeProvider</code>-driven test that ran green in Docker; the rest come from reading the source directly in .NET 8, 9, 10, and 11 RC1[<a href="#references">4</a>], since the behavior in question is either &ldquo;calls <code>TimeProvider.GetUtcNow()</code>&rdquo; or &ldquo;calls <code>DateTimeOffset.UtcNow</code>.&rdquo; I&rsquo;ve marked which is which below.</p>
<p><strong>Already worked since .NET 8, and still does on RC1:</strong></p>
<ul>
<li>Cookie / OAuth / OIDC / JWT auth expiration, because <code>AuthenticationSchemeOptions</code> got a settable <code>TimeProvider</code> property. (Source-verified; not covered by a test in the companion code.)</li>
<li>Security-stamp re-validation (cookie renew/reject), because <code>SecurityStampValidatorOptions.TimeProvider</code> did too, auto-populated from DI since 8.0 GA. (Tested.)</li>
<li>Identity bearer tokens from <code>MapIdentityApi</code>, for the same reason as cookie auth above. (Source-verified only; I tested this directly in an earlier pass with a full in-process web host, confirmed it, then cut that test because it added a lot of weight for a claim that reduces to the same mechanism as cookie auth above.)</li>
</ul>
<p><strong>New in .NET 11:</strong></p>
<ul>
<li>Identity lockout (<code>IsLockedOutAsync</code> / <code>AccessFailedAsync</code>). (Tested.) I was wrong when I first assumed this had worked since .NET 8, until I checked the actual source at each release and found the DI-aware <code>UtcNow()</code> helper only exists starting at .NET 11 RC1[<a href="#references">4</a>], not before.</li>
</ul>
<p><strong>Still a gap, even on RC1:</strong></p>
<ul>
<li>Password reset, email confirmation, and change-email tokens. (Tested.) <code>DataProtectorTokenProvider</code> (the class behind <code>GeneratePasswordResetTokenAsync</code>, <code>GenerateEmailConfirmationTokenAsync</code>, and <code>GenerateChangeEmailTokenAsync</code>) calls <code>DateTimeOffset.UtcNow</code> directly, with no <code>TimeProvider</code> hook anywhere in it.</li>
<li>TOTP 2FA codes (authenticator apps). (Source-verified only, for the same reason as bearer tokens above: confirming it meant reimplementing RFC 6238 from scratch, since Identity&rsquo;s own TOTP class is <code>internal</code>, and that reimplementation taught the reader nothing about <code>TimeProvider</code>.) No <code>TimeProvider</code> hook exists anywhere in the class that computes them.</li>
</ul>
<p>Here&rsquo;s the first of those gaps, proven rather than asserted:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderDemo.Tests.TokenExpiryGapTests.Password_reset_token_ignores_fake_time_advancing_past_its_lifespan</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[Fact]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task Password_reset_token_ignores_fake_time_advancing_past_its_lifespan()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> var fixture = <span style="color:#66d9ef">new</span> IdentityFixture();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> userManager = fixture.GetRequiredService&lt;UserManager&lt;AppUser&gt;&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> user = <span style="color:#66d9ef">new</span> AppUser { UserName = <span style="color:#e6db74">&#34;reset-token@example.com&#34;</span>, Email = <span style="color:#e6db74">&#34;reset-token@example.com&#34;</span> };
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> userManager.CreateAsync(user);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> token = <span style="color:#66d9ef">await</span> userManager.GeneratePasswordResetTokenAsync(user);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Default token lifespan is 1 day. Advance the FAKE clock 10 days.</span>
</span></span><span style="display:flex;"><span>    fixture.TimeProvider.Advance(TimeSpan.FromDays(<span style="color:#ae81ff">10</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> isValid = <span style="color:#66d9ef">await</span> userManager.VerifyUserTokenAsync(
</span></span><span style="display:flex;"><span>        user, TokenOptions.DefaultProvider, <span style="color:#e6db74">&#34;ResetPassword&#34;</span>, token);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Still valid: the provider reads the real wall clock, not our fake one.</span>
</span></span><span style="display:flex;"><span>    Assert.True(isValid);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>That assertion is <code>True</code> after advancing the fake clock ten days past the token&rsquo;s one-day lifespan. If <code>DataProtectorTokenProvider</code> honored <code>TimeProvider</code>, this would be <code>False</code>. It isn&rsquo;t, because it can&rsquo;t be: there&rsquo;s nothing in that class to fake. Same story for TOTP 2FA codes: <code>Rfc6238AuthenticationService</code>, the internal class behind authenticator-app codes, is static and reads <code>DateTimeOffset.UtcNow</code> with no parameter or hook of any kind.</p>
<p><strong>Reconciling this with the docs.</strong> The .NET 11 &ldquo;what&rsquo;s new&rdquo; page doesn&rsquo;t mention Identity or <code>TimeProvider</code> at all, and the ASP.NET Core 11 release notes have no Identity section as of RC1[<a href="#references">2</a>]. So there&rsquo;s no official claim to contradict here. I went looking for one and didn&rsquo;t find it. If you&rsquo;ve seen a doc or blog post claiming Identity now uses <code>TimeProvider</code> for &ldquo;all&rdquo; time-related operations, I&rsquo;d treat that as aspirational or describing a different, narrower change (lockout) rather than literal, and I&rsquo;d want to see which specific API it means before repeating it. What I can tell you, dated: as of .NET 11 RC1, lockout is testable with a fake clock, token expiration and TOTP are not, and I&rsquo;m not promising any of this changes by GA.</p>
<h2 id="the-gap-is-real-but-its-not-unworkable">The gap is real, but it&rsquo;s not unworkable</h2>
<p><code>DataProtectorTokenProvider</code> is what actually produces the string you see in a password reset link or an email confirmation callback: a payload (the user&rsquo;s ID, plus the timestamp it was created) encrypted with ASP.NET Core&rsquo;s data protection APIs, which Identity later decrypts and checks against <code>Options.TokenLifespan</code> to decide whether it&rsquo;s still good. That timestamp check is where <code>DateTimeOffset.UtcNow</code> gets called directly, and the class has no seam, no point where you can swap in different behavior without touching its own source, so there&rsquo;s nothing to inject a <code>TimeProvider</code> into.</p>
<p>I needed a password-reset-style token I could actually test, so I wrote one. <code>TimeProviderTokenProvider&lt;TUser&gt;</code> isn&rsquo;t a replacement for <code>DataProtectorTokenProvider</code>; it&rsquo;s registered under its own name as an additional provider, so anything already using the default password-reset or email-confirmation flow keeps working exactly as before. This is the wrapper that earns its keep, not because it makes testing easier in the abstract, but because it closes a specific, source-verified hole:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderDemo.Tests.TimeProviderTokenProviderTests.Token_expires_once_fake_time_advances_past_its_lifespan</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[Fact]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">async</span> Task Token_expires_once_fake_time_advances_past_its_lifespan()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">using</span> var fixture = <span style="color:#66d9ef">new</span> IdentityFixture();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> userManager = fixture.GetRequiredService&lt;UserManager&lt;AppUser&gt;&gt;();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> user = <span style="color:#66d9ef">new</span> AppUser { UserName = <span style="color:#e6db74">&#34;custom-token-expired@example.com&#34;</span>, Email = <span style="color:#e6db74">&#34;custom-token-expired@example.com&#34;</span> };
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">await</span> userManager.CreateAsync(user);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> token = <span style="color:#66d9ef">await</span> userManager.GenerateUserTokenAsync(
</span></span><span style="display:flex;"><span>        user, IdentityFixture.TimeProviderTokenProviderName, <span style="color:#e6db74">&#34;test-purpose&#34;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Default lifespan in TimeProviderTokenProvider is 3 hours.</span>
</span></span><span style="display:flex;"><span>    fixture.TimeProvider.Advance(TimeSpan.FromHours(<span style="color:#ae81ff">4</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> isValid = <span style="color:#66d9ef">await</span> userManager.VerifyUserTokenAsync(
</span></span><span style="display:flex;"><span>        user, IdentityFixture.TimeProviderTokenProviderName, <span style="color:#e6db74">&#34;test-purpose&#34;</span>, token);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Unlike the built-in provider, this one DOES honor the fake clock: it&#39;s expired.</span>
</span></span><span style="display:flex;"><span>    Assert.False(isValid);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>TimeProviderTokenProvider&lt;TUser&gt;</code> implements <code>IUserTwoFactorTokenProvider&lt;TUser&gt;</code> directly rather than subclassing the sealed-in-practice <code>DataProtectorTokenProvider</code>. It protects a payload (user ID plus an issue timestamp from <code>TimeProvider.GetUtcNow()</code>) with <code>IDataProtectionProvider</code>, and checks that timestamp against <code>TimeProvider.GetUtcNow()</code> again on validation. Register it with <code>.AddTokenProvider&lt;TimeProviderTokenProvider&lt;AppUser&gt;&gt;(name)</code> on the <code>IdentityBuilder</code>, and <code>GenerateUserTokenAsync</code>/<code>VerifyUserTokenAsync</code> route to it like any other provider. It&rsquo;s maybe sixty lines. I&rsquo;d rather maintain that than wait for a framework fix that may or may not land.</p>
<h2 id="side-by-side-the-wrapper-you-dont-need">Side by side: the wrapper you don&rsquo;t need</h2>
<p>Here&rsquo;s the same trial-subscription check, tested the old way and the new way:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderDemo.Tests.PassThroughVsTimeProviderTests.SubscriptionWithClock_expires_once_the_fake_clock_passes_the_trial_length</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> clock = <span style="color:#66d9ef">new</span> FakeClock { UtcNow = DateTimeOffset.Parse(<span style="color:#e6db74">&#34;2026-01-01T00:00:00Z&#34;</span>) };
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> subscription = <span style="color:#66d9ef">new</span> SubscriptionWithClock(clock, clock.UtcNow, TimeSpan.FromDays(<span style="color:#ae81ff">14</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>clock.UtcNow = clock.UtcNow.AddDays(<span style="color:#ae81ff">15</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Assert.True(subscription.IsTrialExpired());
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderDemo.Tests.PassThroughVsTimeProviderTests.SubscriptionWithTimeProvider_expires_once_fake_time_passes_the_trial_length</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> timeProvider = <span style="color:#66d9ef">new</span> FakeTimeProvider(DateTimeOffset.Parse(<span style="color:#e6db74">&#34;2026-01-01T00:00:00Z&#34;</span>));
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">var</span> subscription = <span style="color:#66d9ef">new</span> SubscriptionWithTimeProvider(timeProvider, timeProvider.GetUtcNow(), TimeSpan.FromDays(<span style="color:#ae81ff">14</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>timeProvider.Advance(TimeSpan.FromDays(<span style="color:#ae81ff">15</span>));
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Assert.True(subscription.IsTrialExpired());
</span></span></code></pre></div><p>Same assertion, same shape, but the second version didn&rsquo;t need me to write <code>FakeClock</code>. <code>FakeTimeProvider</code> is a NuGet package, maintained by the same team that maintains the thing it&rsquo;s faking, with <code>Advance</code>, <code>SetUtcNow</code>, <code>AutoAdvanceAmount</code>, and timer integration already built. My <code>IClock</code> gave me one property and nothing else.</p>
<h2 id="and-the-wrapper-that-does">And the wrapper that does</h2>
<p><code>BusinessCalendar</code> wraps <code>TimeProvider</code> for a reason <code>TimeProvider</code> can&rsquo;t do itself: it carries a tenant&rsquo;s time zone and a definition of &ldquo;business day.&rdquo;</p>
<p>The extension method it&rsquo;s built on is the smallest useful example of adding meaning on top of <code>TimeProvider</code> instead of just forwarding it:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderExtensions.cs</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">static</span> DateTimeOffset GetLocalNow(<span style="color:#66d9ef">this</span> TimeProvider timeProvider, TimeZoneInfo timeZone) =&gt;
</span></span><span style="display:flex;"><span>    TimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), timeZone);
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderDemo.Tests.BusinessCalendarTests.NowInTenantTime_converts_fake_utc_time_into_the_tenants_zone</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[Fact]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> NowInTenantTime_converts_fake_utc_time_into_the_tenants_zone()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 2026-06-15T02:30:00Z is 2026-06-14T22:30:00 Eastern (EDT, UTC-4) the evening before.</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> timeProvider = <span style="color:#66d9ef">new</span> FakeTimeProvider(DateTimeOffset.Parse(<span style="color:#e6db74">&#34;2026-06-15T02:30:00Z&#34;</span>));
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> calendar = <span style="color:#66d9ef">new</span> BusinessCalendar(timeProvider, Eastern);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> tenantNow = calendar.NowInTenantTime();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Assert.Equal(<span style="color:#66d9ef">new</span> DateTime(<span style="color:#ae81ff">2026</span>, <span style="color:#ae81ff">6</span>, <span style="color:#ae81ff">14</span>, <span style="color:#ae81ff">22</span>, <span style="color:#ae81ff">30</span>, <span style="color:#ae81ff">0</span>), tenantNow.DateTime);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>GetLocalNow</code> by itself is a one-liner. <code>BusinessCalendar</code> builds on it to answer a real domain question <code>TimeProvider</code> has no concept of, which day comes next for invoicing or scheduling purposes:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderDemo.Tests.BusinessCalendarTests.NextBusinessDay_skips_the_weekend</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">[Fact]</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> NextBusinessDay_skips_the_weekend()
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// Friday 2026-06-12, late in the Eastern day.</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> timeProvider = <span style="color:#66d9ef">new</span> FakeTimeProvider(DateTimeOffset.Parse(<span style="color:#e6db74">&#34;2026-06-12T23:00:00Z&#34;</span>));
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> calendar = <span style="color:#66d9ef">new</span> BusinessCalendar(timeProvider, Eastern);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">var</span> next = calendar.NextBusinessDay();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    Assert.Equal(DayOfWeek.Monday, next.DayOfWeek);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>TimeProvider.LocalTimeZone</code> is whatever zone the machine (or container) is set to. In most CI runners and server deployments, that&rsquo;s UTC, full stop. It has no idea what &ldquo;local&rdquo; means to a tenant in another time zone, and no idea Saturday and Sunday aren&rsquo;t business days for your invoicing logic. Run the verdict&rsquo;s own test on it: delete <code>BusinessCalendar</code> and inline <code>TimeProvider</code> everywhere it&rsquo;s used, and <code>NextBusinessDay</code> has nowhere to live, no tenant zone to convert into, no weekend definition to check against. That&rsquo;s a wrapper worth keeping.</p>
<h2 id="pros-cons-and-one-thing-the-analyzer-catches">Pros, cons, and one thing the analyzer catches</h2>
<p>The case for deleting pass-through wrappers is everything above: <code>FakeTimeProvider</code> is maintained for you, it plugs into <code>Task.Delay</code> and friends for free, and the framework itself increasingly reads from the same abstraction.</p>
<p>The honest downsides:</p>
<ul>
<li><strong><code>TimeProvider</code> is abstract with real surface area</strong>, not a one-member interface. A minimal subclass only has to override <code>GetUtcNow()</code>, but <code>LocalTimeZone</code>, <code>GetTimestamp()</code>, <code>TimestampFrequency</code>, and <code>CreateTimer()</code> all come along for free whether you think about them or not.</li>
<li><strong><code>LocalTimeZone</code> defaults to the machine&rsquo;s zone</strong>, confirmed by <code>Assert.Equal(TimeZoneInfo.Local, TimeProvider.System.LocalTimeZone)</code> passing. That&rsquo;s not a business concept, and code that assumes otherwise will behave differently on a developer&rsquo;s laptop than in a UTC container.</li>
<li><strong><code>PeriodicTimer</code> coalesces catch-up ticks.</strong> Advance a <code>FakeTimeProvider</code> by more than one period, and you get one tick, not one per elapsed period:</li>
</ul>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// TimeProviderDemo.Tests.TimeProviderSurfaceTests.PeriodicTimer_with_a_fake_time_provider_ticks_once_per_period_advanced (excerpt)</span>
</span></span><span style="display:flex;"><span>timeProvider.Advance(TimeSpan.FromMilliseconds(<span style="color:#ae81ff">500</span>)); <span style="color:#75715e">// less than one period: no tick yet</span>
</span></span><span style="display:flex;"><span>Assert.False(secondTick.IsCompleted);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>timeProvider.Advance(TimeSpan.FromSeconds(<span style="color:#ae81ff">2</span>)); <span style="color:#75715e">// more than one period: exactly one tick, not two</span>
</span></span><span style="display:flex;"><span>Assert.True(<span style="color:#66d9ef">await</span> secondTick);
</span></span></code></pre></div><p>And the pass-through wrapper itself doesn&rsquo;t fully disappear. If you have one left for something genuinely domain-shaped, you still need exactly one call to the real clock inside it, which means exactly one spot a banned-API analyzer has to tolerate:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-csharp" data-lang="csharp"><span style="display:flex;"><span><span style="color:#75715e">// SystemClock.cs: the one legitimate call site, suppressed deliberately</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">sealed</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">SystemClock</span> : IClock
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span><span style="color:#75715e">#pragma</span> warning disable RS0030
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> DateTimeOffset UtcNow =&gt; DateTimeOffset.UtcNow;
</span></span><span style="display:flex;"><span><span style="color:#75715e">#pragma</span> warning restore RS0030
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>Microsoft.CodeAnalysis.BannedApiAnalyzers</code>, pointed at a <code>BannedSymbols.txt</code> banning <code>DateTime.UtcNow</code> and <code>DateTimeOffset.UtcNow</code>, catches every other call site in the codebase, including, when I first wired it up, inside my own pass-through wrapper&rsquo;s implementation, which is exactly the kind of mistake it&rsquo;s there to catch everywhere except the one place it has to exist.</p>
<h2 id="verdict">Verdict</h2>
<p>Delete the pass-through wrapper. If all it ever did was forward <code>DateTimeOffset.UtcNow</code> so a test could fake it, <code>TimeProvider</code> and <code>FakeTimeProvider</code> do that job better, with more framework support, for free. Keep the wrapper that means something: a business calendar, a tenant&rsquo;s time zone, a token provider that closes a gap the framework hasn&rsquo;t closed yet. The test for which category a wrapper falls into is simple: if you deleted it and inlined <code>TimeProvider</code> everywhere it&rsquo;s used, would anything about your domain logic get harder to read or wrong to write? If no, delete it. If yes, that&rsquo;s the wrapper worth keeping.</p>
<h2 id="migration-checklist">Migration checklist</h2>
<ul>
<li>Replace <code>DateTime.UtcNow</code> / <code>DateTimeOffset.UtcNow</code> call sites with an injected <code>TimeProvider</code>, starting with anything already covered by a test.</li>
<li>Add <code>Microsoft.CodeAnalysis.BannedApiAnalyzers</code> with a <code>BannedSymbols.txt</code> banning both, so new call sites can&rsquo;t sneak back in.</li>
<li>Delete any <code>IClock</code>/<code>ISystemClock</code>-style interface whose only job was forwarding to the real clock for testability, unless it&rsquo;s also carrying real domain meaning (time zone, business calendar, a token provider&rsquo;s expiry logic), in which case keep it and build it on <code>TimeProvider</code> rather than <code>DateTimeOffset.UtcNow</code> directly.</li>
<li>If you&rsquo;re on Identity and need lockout testable with a fake clock, that requires .NET 11; confirm your target framework before promising it to a test author on an older version.</li>
<li>If you need password-reset, email-confirmation, or TOTP tokens testable with a fake clock on any current version, you&rsquo;ll need your own token provider. <code>TimeProviderTokenProvider&lt;TUser&gt;</code> in the companion code is a working starting point, not Identity&rsquo;s built-in one.</li>
<li>Don&rsquo;t assume <code>TimeProvider.LocalTimeZone</code> means anything about your users; wrap it if you need tenant-aware time zones.</li>
</ul>
<h2 id="references">References</h2>
<ol>
<li><a href="https://learn.microsoft.com/en-us/aspnet/core/breaking-changes/8/isystemclock-obsolete">Breaking change: ISystemClock is obsolete</a>: Microsoft Learn, ASP.NET Core breaking changes for .NET 8.</li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/overview">.NET 11, What&rsquo;s new</a> and the <a href="https://learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-11">ASP.NET Core 11 release notes</a>: checked as of RC1, no Identity/TimeProvider section present.</li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.identity.securitystampvalidatoroptions.timeprovider"><code>SecurityStampValidatorOptions.TimeProvider</code></a>: Microsoft Learn API reference.</li>
<li><a href="https://github.com/dotnet/aspnetcore"><code>dotnet/aspnetcore</code> source</a>: <code>UserManager.cs</code>, <code>SecurityStampValidator.cs</code>, <code>DataProtectorTokenProvider.cs</code>, and <code>Rfc6238AuthenticationService.cs</code>, compared at the <code>release/8.0</code>, <code>release/9.0</code>, <code>release/10.0</code>, and <code>v11.0.0-rc.1.26425.128</code> tags.</li>
<li><a href="https://visualstudiomagazine.com/articles/2026/09/14/net-11-rc1-gets-go-live-support-ahead-of-november-launch.aspx">.NET 11 RC1 release</a>: Visual Studio Magazine, on RC1&rsquo;s go-live support window and expected GA.</li>
</ol>
]]></content:encoded></item></channel></rss>