From 5448f369a6147214a1fd62d23c12b5c6adc1041c Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Wed, 3 Jun 2026 17:48:33 +0200 Subject: [PATCH 01/33] WIP: Device Bound Session Credentials (DBSC) for cookie auth Implements the DBSC protocol (W3C spec) in the cookie authentication handler. Uses a stateless two-cookie pattern: - Long-lived cookie: auth ticket + embedded public key (refresh token equivalent) - Short-lived cookie (__dbsc suffix): device-bound credential (access token equivalent) Includes: - Registration/refresh middleware - ES256/RS256 JWT validation - Stateless challenge generation via DataProtection - Sample app with HTTP logging for testing - 16 unit/integration tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../samples/DbscSample/DbscSample.csproj | 32 ++ .../Cookies/samples/DbscSample/Program.cs | 215 ++++++++ .../DbscSample/Properties/launchSettings.json | 8 + .../src/CookieAuthenticationHandler.cs | 18 + .../src/CookieAuthenticationOptions.cs | 18 + .../DeviceBoundSessionChallengeGenerator.cs | 81 +++ .../src/DeviceBoundSessionConfiguration.cs | 132 +++++ .../src/DeviceBoundSessionExtensions.cs | 40 ++ .../src/DeviceBoundSessionJsonContext.cs | 11 + .../src/DeviceBoundSessionJwtValidator.cs | 290 ++++++++++ .../src/DeviceBoundSessionMiddleware.cs | 385 +++++++++++++ .../Cookies/src/DeviceBoundSessionOptions.cs | 112 ++++ .../src/DeviceBoundSessionScopeRule.cs | 28 + .../Cookies/src/IDeviceBoundSessionStore.cs | 50 ++ ...t.AspNetCore.Authentication.Cookies.csproj | 4 + .../Cookies/src/PublicAPI.Unshipped.txt | 76 +++ .../test/DeviceBoundSessionTests.cs | 521 ++++++++++++++++++ 17 files changed, 2021 insertions(+) create mode 100644 src/Security/Authentication/Cookies/samples/DbscSample/DbscSample.csproj create mode 100644 src/Security/Authentication/Cookies/samples/DbscSample/Program.cs create mode 100644 src/Security/Authentication/Cookies/samples/DbscSample/Properties/launchSettings.json create mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionChallengeGenerator.cs create mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionConfiguration.cs create mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionExtensions.cs create mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionJsonContext.cs create mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionJwtValidator.cs create mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionMiddleware.cs create mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionOptions.cs create mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionScopeRule.cs create mode 100644 src/Security/Authentication/Cookies/src/IDeviceBoundSessionStore.cs create mode 100644 src/Security/Authentication/test/DeviceBoundSessionTests.cs diff --git a/src/Security/Authentication/Cookies/samples/DbscSample/DbscSample.csproj b/src/Security/Authentication/Cookies/samples/DbscSample/DbscSample.csproj new file mode 100644 index 000000000000..908af9896fbb --- /dev/null +++ b/src/Security/Authentication/Cookies/samples/DbscSample/DbscSample.csproj @@ -0,0 +1,32 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Security/Authentication/Cookies/samples/DbscSample/Program.cs b/src/Security/Authentication/Cookies/samples/DbscSample/Program.cs new file mode 100644 index 000000000000..dfaefd59640d --- /dev/null +++ b/src/Security/Authentication/Cookies/samples/DbscSample/Program.cs @@ -0,0 +1,215 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpLogging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace DbscSample; + +public static class Program +{ + public static Task Main(string[] args) + { + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseKestrel(options => + { + options.ListenLocalhost(7298, listenOptions => + { + listenOptions.UseHttps(); + }); + }) + .UseStartup(); + }) + .ConfigureLogging(factory => + { + factory.AddConsole(); + factory.AddFilter("Default", LogLevel.Information); + factory.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information); + factory.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); + }) + .Build(); + + return host.RunAsync(); + } +} + +public class Startup +{ + public void ConfigureServices(IServiceCollection services) + { + services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) + .AddCookie(options => + { + options.LoginPath = "/login"; + options.ExpireTimeSpan = TimeSpan.FromDays(7); + options.DeviceBoundSession = new DeviceBoundSessionOptions + { + Enabled = true, + // Short expiration for testing (refreshes happen every 30s) + ShortLivedCookieExpiration = TimeSpan.FromSeconds(30), + }; + }); + + services.AddHttpLogging(logging => + { + logging.LoggingFields = HttpLoggingFields.All; + logging.RequestHeaders.Add("Sec-Session-Id"); + logging.RequestHeaders.Add("Sec-Secure-Session-Id"); + logging.RequestHeaders.Add("Secure-Session-Response"); + logging.ResponseHeaders.Add("Secure-Session-Registration"); + logging.ResponseHeaders.Add("Secure-Session-Challenge"); + logging.ResponseHeaders.Add("Set-Cookie"); + logging.RequestBodyLogLimit = 4096; + logging.ResponseBodyLogLimit = 4096; + logging.CombineLogs = true; + }); + + services.AddRouting(); + } + + public void Configure(IApplicationBuilder app) + { + app.UseHttpLogging(); + app.UseRouting(); + app.UseAuthentication(); + app.UseDeviceBoundSessions(); + + // DEBUG: dump authenticated ticket properties into response headers + app.Use(async (context, next) => + { + context.Response.OnStarting(() => + { + if (context.User.Identity?.IsAuthenticated == true) + { + context.Response.Headers["X-Debug-Principal"] = context.User.Identity.Name ?? "(no name)"; + + // Get the auth ticket from the feature + var authFeature = context.Features.Get(); + var ticket = authFeature?.AuthenticateResult?.Ticket; + if (ticket is not null) + { + context.Response.Headers["X-Debug-Scheme"] = ticket.AuthenticationScheme; + foreach (var kvp in ticket.Properties.Items) + { + var headerKey = kvp.Key.Replace(".", "-").Replace("/", "_"); + context.Response.Headers[$"X-Debug-Prop-{headerKey}"] = kvp.Value ?? "(null)"; + } + } + else + { + context.Response.Headers["X-Debug-Ticket"] = "authenticated-but-no-ticket-feature"; + } + } + else + { + context.Response.Headers["X-Debug-Auth"] = "not-authenticated"; + } + return Task.CompletedTask; + }); + + await next(); + }); + + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/login", async context => + { + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync(""" + + + DBSC Sample - Login + +

Device Bound Session Credentials - Test App

+
+ + +
+ + + """); + }); + + endpoints.MapPost("/login", async context => + { + var form = await context.Request.ReadFormAsync(); + var username = form["username"].ToString(); + + if (string.IsNullOrEmpty(username)) + { + context.Response.Redirect("/login"); + return; + } + + var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme); + identity.AddClaim(new Claim(ClaimTypes.Name, username)); + identity.AddClaim(new Claim(ClaimTypes.Email, $"{username}@example.com")); + + await context.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity), + new AuthenticationProperties { IsPersistent = true }); + + context.Response.Redirect("/"); + }); + + endpoints.MapGet("/", async context => + { + if (context.User.Identity?.IsAuthenticated != true) + { + context.Response.Redirect("/login"); + return; + } + + var userName = context.User.Identity!.Name; + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync( + "DBSC Sample" + + $"

Welcome, {userName}!

" + + "

Authenticated with Device Bound Session Credentials.

" + + "

How to test:

    " + + "
  1. Open Chrome DevTools (F12) → Network tab
  2. " + + "
  3. Check the sign-in response for Secure-Session-Registration header
  4. " + + "
  5. Watch for POST to /.well-known/dbsc/registration
  6. " + + "
  7. Wait 30s, then make a request to observe refresh at /.well-known/dbsc/refresh
  8. " + + "
" + + "

API endpoint | Sign Out

" + + "

Auto-refresh (every 10s):

" +
+                    "");
+            });
+
+            endpoints.MapGet("/api/time", async context =>
+            {
+                if (context.User.Identity?.IsAuthenticated != true)
+                {
+                    context.Response.StatusCode = 401;
+                    await context.Response.WriteAsync("Unauthorized");
+                    return;
+                }
+                await context.Response.WriteAsync($"Server time: {DateTime.UtcNow:O} | User: {context.User.Identity!.Name}");
+            });
+
+            endpoints.MapGet("/signout", async context =>
+            {
+                await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
+                context.Response.Redirect("/login");
+            });
+        });
+    }
+}
diff --git a/src/Security/Authentication/Cookies/samples/DbscSample/Properties/launchSettings.json b/src/Security/Authentication/Cookies/samples/DbscSample/Properties/launchSettings.json
new file mode 100644
index 000000000000..71c0d45b5478
--- /dev/null
+++ b/src/Security/Authentication/Cookies/samples/DbscSample/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+  "profiles": {
+    "DbscSample": {
+      "commandName": "Project",
+      "applicationUrl": "https://localhost:7298"
+    }
+  }
+}
diff --git a/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs b/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs
index 0e9ad0883808..21f1fa382058 100644
--- a/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs
+++ b/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs
@@ -370,6 +370,16 @@ protected override async Task HandleSignInAsync(ClaimsPrincipal user, Authentica
 
         await Events.SignedIn(signedInContext);
 
+        // Emit DBSC registration header if enabled
+        if (Options.DeviceBoundSession is { Enabled: true } dbscOptions)
+        {
+            var algorithms = string.Join(" ", dbscOptions.SupportedAlgorithms);
+            var registrationPath = dbscOptions.RegistrationPath.Value;
+            var challenge = GenerateDbscRegistrationChallenge();
+            Response.Headers.Append("Secure-Session-Registration",
+                $"({algorithms});path=\"{registrationPath}\";challenge=\"{challenge}\"");
+        }
+
         // Only honor the ReturnUrl query string parameter on the login path
         var shouldHonorReturnUrlParameter = Options.LoginPath.HasValue && OriginalPath == Options.LoginPath;
         await ApplyHeaders(shouldRedirect: true, shouldHonorReturnUrlParameter, signedInContext.Properties);
@@ -490,4 +500,12 @@ protected override async Task HandleChallengeAsync(AuthenticationProperties prop
         var binding = Context.Features.Get()?.GetProvidedTokenBindingId();
         return binding == null ? null : Convert.ToBase64String(binding);
     }
+
+    private static string GenerateDbscRegistrationChallenge()
+    {
+        return Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
+            .Replace('+', '-')
+            .Replace('/', '_')
+            .TrimEnd('=');
+    }
 }
diff --git a/src/Security/Authentication/Cookies/src/CookieAuthenticationOptions.cs b/src/Security/Authentication/Cookies/src/CookieAuthenticationOptions.cs
index 1655b90552a0..31fb89ef6276 100644
--- a/src/Security/Authentication/Cookies/src/CookieAuthenticationOptions.cs
+++ b/src/Security/Authentication/Cookies/src/CookieAuthenticationOptions.cs
@@ -132,4 +132,22 @@ public CookieBuilder Cookie
     /// 
     /// 
     public TimeSpan ExpireTimeSpan { get; set; }
+
+    /// 
+    /// Gets or sets the Device Bound Session Credentials (DBSC) options.
+    /// When enabled, session cookies are bound to a device using cryptographic key pairs,
+    /// preventing session cookie theft and exfiltration.
+    /// 
+    /// 
+    /// 
+    /// DBSC uses a two-cookie pattern: a long-lived cookie (containing the auth ticket and public key)
+    /// and a short-lived cookie (the DBSC-bound credential). When the short-lived cookie expires,
+    /// the browser must prove possession of the device-bound private key to obtain a new one.
+    /// 
+    /// 
+    /// This feature requires browser support (currently Chrome 135+). Browsers that do not support DBSC
+    /// will simply ignore the Secure-Session-Registration header and continue using the long-lived cookie.
+    /// 
+    /// 
+    public DeviceBoundSessionOptions? DeviceBoundSession { get; set; }
 }
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionChallengeGenerator.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionChallengeGenerator.cs
new file mode 100644
index 000000000000..28e37f9b5151
--- /dev/null
+++ b/src/Security/Authentication/Cookies/src/DeviceBoundSessionChallengeGenerator.cs
@@ -0,0 +1,81 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.DataProtection;
+
+namespace Microsoft.AspNetCore.Authentication.Cookies;
+
+/// 
+/// Generates and validates self-contained DBSC challenges using Data Protection.
+/// Challenges are stateless — the server can validate them without storing them.
+/// 
+internal sealed class DeviceBoundSessionChallengeGenerator
+{
+    private readonly IDataProtector _protector;
+    private readonly TimeProvider _timeProvider;
+
+    public DeviceBoundSessionChallengeGenerator(IDataProtectionProvider dataProtectionProvider, TimeProvider? timeProvider = null)
+    {
+        _protector = dataProtectionProvider.CreateProtector("Microsoft.AspNetCore.Authentication.Cookies.DBSC.Challenge");
+        _timeProvider = timeProvider ?? TimeProvider.System;
+    }
+
+    /// 
+    /// Generates a self-contained challenge string for the given session.
+    /// 
+    /// The session identifier.
+    /// A data-protected challenge string.
+    public string GenerateChallenge(string sessionId)
+    {
+        var nonce = RandomNumberGenerator.GetBytes(16);
+        var timestamp = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
+
+        // Format: timestamp|nonce_base64|sessionId
+        var payload = $"{timestamp}|{Convert.ToBase64String(nonce)}|{sessionId}";
+        return _protector.Protect(payload);
+    }
+
+    /// 
+    /// Validates a challenge string and checks that it was issued recently and for the correct session.
+    /// 
+    /// The challenge string to validate.
+    /// The expected session identifier.
+    /// The maximum age of the challenge.
+    /// true if the challenge is valid and fresh; otherwise, false.
+    public bool ValidateChallenge(string challenge, string expectedSessionId, TimeSpan maxAge)
+    {
+        string payload;
+        try
+        {
+            payload = _protector.Unprotect(challenge);
+        }
+        catch (CryptographicException)
+        {
+            return false;
+        }
+
+        var parts = payload.Split('|', 3);
+        if (parts.Length != 3)
+        {
+            return false;
+        }
+
+        if (!long.TryParse(parts[0], out var timestamp))
+        {
+            return false;
+        }
+
+        // Check freshness
+        var issued = DateTimeOffset.FromUnixTimeSeconds(timestamp);
+        var now = _timeProvider.GetUtcNow();
+        if (now - issued > maxAge)
+        {
+            return false;
+        }
+
+        // Check session ID
+        var sessionId = parts[2];
+        return string.Equals(sessionId, expectedSessionId, StringComparison.Ordinal);
+    }
+}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionConfiguration.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionConfiguration.cs
new file mode 100644
index 000000000000..8997a880d852
--- /dev/null
+++ b/src/Security/Authentication/Cookies/src/DeviceBoundSessionConfiguration.cs
@@ -0,0 +1,132 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.AspNetCore.Authentication.Cookies;
+
+/// 
+/// Represents the JSON session configuration returned by the DBSC registration and refresh endpoints.
+/// This instructs the browser on how to manage the device-bound session.
+/// 
+public sealed class DeviceBoundSessionConfiguration
+{
+    /// 
+    /// Gets or sets the unique identifier for the session.
+    /// 
+    [JsonPropertyName("session_identifier")]
+    public required string SessionIdentifier { get; set; }
+
+    /// 
+    /// Gets or sets the URL for future refresh requests.
+    /// Can be relative to the registration/refresh URL.
+    /// 
+    [JsonPropertyName("refresh_url")]
+    public string? RefreshUrl { get; set; }
+
+    /// 
+    /// Gets or sets whether the session should continue.
+    /// Set to false to terminate the session.
+    /// 
+    [JsonPropertyName("continue")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+    public bool? Continue { get; set; }
+
+    /// 
+    /// Gets or sets the session scope configuration.
+    /// 
+    [JsonPropertyName("scope")]
+    public required DeviceBoundSessionScopeConfiguration Scope { get; set; }
+
+    /// 
+    /// Gets or sets the list of credentials (cookies) protected by this session.
+    /// 
+    [JsonPropertyName("credentials")]
+    public required IList Credentials { get; set; }
+
+    /// 
+    /// Gets or sets the hosts allowed to initiate DBSC refreshes.
+    /// 
+    [JsonPropertyName("allowed_refresh_initiators")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+    public IList? AllowedRefreshInitiators { get; set; }
+}
+
+/// 
+/// Represents the scope configuration for a device-bound session.
+/// 
+public sealed class DeviceBoundSessionScopeConfiguration
+{
+    /// 
+    /// Gets or sets the origin the session applies to.
+    /// If not set, the origin of the registration/refresh URL is used.
+    /// 
+    [JsonPropertyName("origin")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+    public string? Origin { get; set; }
+
+    /// 
+    /// Gets or sets whether the session applies to the entire site (all subdomains)
+    /// or just the origin.
+    /// 
+    [JsonPropertyName("include_site")]
+    public bool IncludeSite { get; set; }
+
+    /// 
+    /// Gets or sets the scope specification rules.
+    /// 
+    [JsonPropertyName("scope_specification")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+    public IList? ScopeSpecification { get; set; }
+}
+
+/// 
+/// Represents a scope rule in the session configuration JSON.
+/// 
+public sealed class DeviceBoundSessionScopeRuleConfiguration
+{
+    /// 
+    /// Gets or sets the type: "include" or "exclude".
+    /// 
+    [JsonPropertyName("type")]
+    public required string Type { get; set; }
+
+    /// 
+    /// Gets or sets the domain pattern.
+    /// 
+    [JsonPropertyName("domain")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+    public string? Domain { get; set; }
+
+    /// 
+    /// Gets or sets the path prefix.
+    /// 
+    [JsonPropertyName("path")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+    public string? Path { get; set; }
+}
+
+/// 
+/// Represents a credential (cookie) protected by the device-bound session.
+/// 
+public sealed class DeviceBoundSessionCredentialConfiguration
+{
+    /// 
+    /// Gets or sets the credential type. Must be "cookie".
+    /// 
+    [JsonPropertyName("type")]
+    public string Type { get; set; } = "cookie";
+
+    /// 
+    /// Gets or sets the name of the bound cookie.
+    /// 
+    [JsonPropertyName("name")]
+    public required string Name { get; set; }
+
+    /// 
+    /// Gets or sets the expected attributes of the cookie (e.g., "Domain=example.com; Secure; SameSite=Lax").
+    /// 
+    [JsonPropertyName("attributes")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+    public string? Attributes { get; set; }
+}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionExtensions.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionExtensions.cs
new file mode 100644
index 000000000000..a63693540ba5
--- /dev/null
+++ b/src/Security/Authentication/Cookies/src/DeviceBoundSessionExtensions.cs
@@ -0,0 +1,40 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Builder;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+/// 
+/// Extension methods for configuring Device Bound Session Credentials (DBSC).
+/// 
+public static class DeviceBoundSessionExtensions
+{
+    /// 
+    /// Adds the Device Bound Session Credentials middleware to the application pipeline.
+    /// This middleware handles DBSC registration and refresh requests.
+    /// 
+    /// 
+    /// 
+    /// This middleware should be added after UseAuthentication() in the pipeline
+    /// so that the authentication cookie is available for DBSC operations.
+    /// 
+    /// 
+    /// 
+    /// var app = builder.Build();
+    /// app.UseAuthentication();
+    /// app.UseDeviceBoundSessions();
+    /// app.UseAuthorization();
+    /// 
+    /// 
+    /// 
+    /// The .
+    /// The  for chaining.
+    public static IApplicationBuilder UseDeviceBoundSessions(this IApplicationBuilder app)
+    {
+        ArgumentNullException.ThrowIfNull(app);
+
+        return app.UseMiddleware();
+    }
+}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionJsonContext.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionJsonContext.cs
new file mode 100644
index 000000000000..d89ea29ad1f3
--- /dev/null
+++ b/src/Security/Authentication/Cookies/src/DeviceBoundSessionJsonContext.cs
@@ -0,0 +1,11 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.AspNetCore.Authentication.Cookies;
+
+[JsonSerializable(typeof(DeviceBoundSessionConfiguration))]
+internal sealed partial class DeviceBoundSessionJsonContext : JsonSerializerContext
+{
+}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionJwtValidator.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionJwtValidator.cs
new file mode 100644
index 000000000000..2ad881b07a5e
--- /dev/null
+++ b/src/Security/Authentication/Cookies/src/DeviceBoundSessionJwtValidator.cs
@@ -0,0 +1,290 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace Microsoft.AspNetCore.Authentication.Cookies;
+
+/// 
+/// Validates DBSC proof JWTs (typ: "dbsc+jwt") signed with ES256 or RS256.
+/// 
+internal static class DeviceBoundSessionJwtValidator
+{
+    /// 
+    /// Validates a DBSC proof JWT and extracts its claims.
+    /// 
+    /// The raw JWT string.
+    /// The JWK JSON of the public key to validate against. If null, extracts from JWT header.
+    /// The expected challenge value (jti claim).
+    /// The parsed result, or null if validation fails.
+    public static DeviceBoundSessionJwtResult? Validate(string jwt, string? publicKeyJwk, string? expectedChallenge)
+    {
+        var parts = jwt.Split('.');
+        if (parts.Length != 3)
+        {
+            return null;
+        }
+
+        var headerJson = Base64UrlDecode(parts[0]);
+        var payloadJson = Base64UrlDecode(parts[1]);
+        var signature = Base64UrlDecodeBytes(parts[2]);
+
+        if (headerJson is null || payloadJson is null || signature is null)
+        {
+            return null;
+        }
+
+        // Parse header
+        JsonElement header;
+        try
+        {
+            header = JsonDocument.Parse(headerJson).RootElement;
+        }
+        catch (JsonException)
+        {
+            return null;
+        }
+
+        // Validate typ
+        if (!header.TryGetProperty("typ", out var typElement) ||
+            !string.Equals(typElement.GetString(), "dbsc+jwt", StringComparison.OrdinalIgnoreCase))
+        {
+            return null;
+        }
+
+        // Get algorithm
+        if (!header.TryGetProperty("alg", out var algElement))
+        {
+            return null;
+        }
+        var algorithm = algElement.GetString();
+
+        // Get JWK - from parameter or from header (registration)
+        string? jwkJson = publicKeyJwk;
+        if (jwkJson is null)
+        {
+            if (!header.TryGetProperty("jwk", out var jwkElement))
+            {
+                return null;
+            }
+            jwkJson = jwkElement.GetRawText();
+        }
+
+        // Validate signature
+        var signedData = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
+        if (!VerifySignature(algorithm, jwkJson, signedData, signature))
+        {
+            return null;
+        }
+
+        // Parse payload
+        JsonElement payload;
+        try
+        {
+            payload = JsonDocument.Parse(payloadJson).RootElement;
+        }
+        catch (JsonException)
+        {
+            return null;
+        }
+
+        // Validate jti (challenge)
+        string? jti = null;
+        if (payload.TryGetProperty("jti", out var jtiElement))
+        {
+            jti = jtiElement.GetString();
+        }
+
+        if (expectedChallenge is not null && !string.Equals(jti, expectedChallenge, StringComparison.Ordinal))
+        {
+            return null;
+        }
+
+        // Extract authorization claim if present
+        string? authorization = null;
+        if (payload.TryGetProperty("authorization", out var authElement))
+        {
+            authorization = authElement.GetString();
+        }
+
+        return new DeviceBoundSessionJwtResult
+        {
+            Algorithm = algorithm,
+            PublicKeyJwk = jwkJson,
+            Challenge = jti,
+            Authorization = authorization
+        };
+    }
+
+    /// 
+    /// Extracts the JWK from a DBSC registration JWT header without full validation.
+    /// Used during registration when we don't yet have a stored key.
+    /// 
+    public static string? ExtractPublicKeyJwk(string jwt)
+    {
+        var parts = jwt.Split('.');
+        if (parts.Length != 3)
+        {
+            return null;
+        }
+
+        var headerJson = Base64UrlDecode(parts[0]);
+        if (headerJson is null)
+        {
+            return null;
+        }
+
+        try
+        {
+            var header = JsonDocument.Parse(headerJson).RootElement;
+            if (header.TryGetProperty("jwk", out var jwkElement))
+            {
+                return jwkElement.GetRawText();
+            }
+        }
+        catch (JsonException)
+        {
+            // Ignore parse errors
+        }
+
+        return null;
+    }
+
+    private static bool VerifySignature(string? algorithm, string jwkJson, byte[] signedData, byte[] signature)
+    {
+        return algorithm switch
+        {
+            "ES256" => VerifyES256(jwkJson, signedData, signature),
+            "RS256" => VerifyRS256(jwkJson, signedData, signature),
+            _ => false
+        };
+    }
+
+    private static bool VerifyES256(string jwkJson, byte[] signedData, byte[] signature)
+    {
+        try
+        {
+            using var doc = JsonDocument.Parse(jwkJson);
+            var jwk = doc.RootElement;
+
+            if (!jwk.TryGetProperty("kty", out var kty) || kty.GetString() != "EC")
+            {
+                return false;
+            }
+            if (!jwk.TryGetProperty("crv", out var crv) || crv.GetString() != "P-256")
+            {
+                return false;
+            }
+            if (!jwk.TryGetProperty("x", out var xProp) || !jwk.TryGetProperty("y", out var yProp))
+            {
+                return false;
+            }
+
+            var x = Base64UrlDecodeBytes(xProp.GetString()!);
+            var y = Base64UrlDecodeBytes(yProp.GetString()!);
+            if (x is null || y is null)
+            {
+                return false;
+            }
+
+            var parameters = new ECParameters
+            {
+                Curve = ECCurve.NamedCurves.nistP256,
+                Q = new ECPoint { X = x, Y = y }
+            };
+
+            using var ecdsa = ECDsa.Create(parameters);
+            return ecdsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
+                || ecdsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256);
+        }
+        catch (CryptographicException)
+        {
+            return false;
+        }
+        catch (JsonException)
+        {
+            return false;
+        }
+    }
+
+    private static bool VerifyRS256(string jwkJson, byte[] signedData, byte[] signature)
+    {
+        try
+        {
+            using var doc = JsonDocument.Parse(jwkJson);
+            var jwk = doc.RootElement;
+
+            if (!jwk.TryGetProperty("kty", out var kty) || kty.GetString() != "RSA")
+            {
+                return false;
+            }
+            if (!jwk.TryGetProperty("n", out var nProp) || !jwk.TryGetProperty("e", out var eProp))
+            {
+                return false;
+            }
+
+            var n = Base64UrlDecodeBytes(nProp.GetString()!);
+            var e = Base64UrlDecodeBytes(eProp.GetString()!);
+            if (n is null || e is null)
+            {
+                return false;
+            }
+
+            var parameters = new RSAParameters
+            {
+                Modulus = n,
+                Exponent = e
+            };
+
+            using var rsa = RSA.Create(parameters);
+            return rsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+        }
+        catch (CryptographicException)
+        {
+            return false;
+        }
+        catch (JsonException)
+        {
+            return false;
+        }
+    }
+
+    private static string? Base64UrlDecode(string input)
+    {
+        var bytes = Base64UrlDecodeBytes(input);
+        return bytes is null ? null : Encoding.UTF8.GetString(bytes);
+    }
+
+    private static byte[]? Base64UrlDecodeBytes(string input)
+    {
+        // Replace URL-safe characters and add padding
+        var base64 = input.Replace('-', '+').Replace('_', '/');
+        switch (base64.Length % 4)
+        {
+            case 2: base64 += "=="; break;
+            case 3: base64 += "="; break;
+        }
+
+        try
+        {
+            return Convert.FromBase64String(base64);
+        }
+        catch (FormatException)
+        {
+            return null;
+        }
+    }
+}
+
+/// 
+/// Result of validating a DBSC proof JWT.
+/// 
+internal sealed class DeviceBoundSessionJwtResult
+{
+    public required string? Algorithm { get; init; }
+    public required string PublicKeyJwk { get; init; }
+    public string? Challenge { get; init; }
+    public string? Authorization { get; init; }
+}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionMiddleware.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionMiddleware.cs
new file mode 100644
index 000000000000..cb3fd496a8ba
--- /dev/null
+++ b/src/Security/Authentication/Cookies/src/DeviceBoundSessionMiddleware.cs
@@ -0,0 +1,385 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text.Json;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Authentication.Cookies;
+
+/// 
+/// Middleware that handles DBSC registration and refresh requests.
+/// 
+internal sealed class DeviceBoundSessionMiddleware
+{
+    private readonly RequestDelegate _next;
+    private readonly ILogger _logger;
+
+    public DeviceBoundSessionMiddleware(RequestDelegate next, ILogger logger)
+    {
+        _next = next;
+        _logger = logger;
+    }
+
+    public async Task InvokeAsync(HttpContext context)
+    {
+        // Check all configured cookie authentication schemes for DBSC paths
+        var authOptions = context.RequestServices.GetService>();
+        var schemes = context.RequestServices.GetService();
+
+        if (authOptions is null || schemes is null)
+        {
+            await _next(context);
+            return;
+        }
+
+        var allSchemes = await schemes.GetAllSchemesAsync();
+        foreach (var scheme in allSchemes)
+        {
+            if (scheme.HandlerType != typeof(CookieAuthenticationHandler))
+            {
+                continue;
+            }
+
+            var options = authOptions.Get(scheme.Name);
+            var dbscOptions = options.DeviceBoundSession;
+            if (dbscOptions is null || !dbscOptions.Enabled)
+            {
+                continue;
+            }
+
+            if (context.Request.Path.Equals(dbscOptions.RegistrationPath) && HttpMethods.IsPost(context.Request.Method))
+            {
+                await HandleRegistrationAsync(context, options, dbscOptions);
+                return;
+            }
+
+            if (context.Request.Path.Equals(dbscOptions.RefreshPath) && HttpMethods.IsPost(context.Request.Method))
+            {
+                await HandleRefreshAsync(context, options, dbscOptions);
+                return;
+            }
+        }
+
+        await _next(context);
+    }
+
+    private async Task HandleRegistrationAsync(
+        HttpContext context,
+        CookieAuthenticationOptions options,
+        DeviceBoundSessionOptions dbscOptions)
+    {
+        // Extract the JWT from the Secure-Session-Response header
+        var responseHeader = context.Request.Headers["Secure-Session-Response"].ToString();
+        if (string.IsNullOrEmpty(responseHeader))
+        {
+            // Also try reading from body for compatibility
+            context.Response.StatusCode = StatusCodes.Status400BadRequest;
+            return;
+        }
+
+        // Remove quotes if present (structured header format)
+        responseHeader = responseHeader.Trim('"');
+
+        // Validate the JWT and extract the public key
+        var result = DeviceBoundSessionJwtValidator.Validate(responseHeader, publicKeyJwk: null, expectedChallenge: null);
+        if (result is null)
+        {
+            _logger.LogWarning("DBSC registration: invalid JWT proof.");
+            context.Response.StatusCode = StatusCodes.Status400BadRequest;
+            return;
+        }
+
+        // Read the existing auth cookie to get the current session
+        var cookie = options.CookieManager.GetRequestCookie(context, options.Cookie.Name!);
+        if (string.IsNullOrEmpty(cookie))
+        {
+            _logger.LogWarning("DBSC registration: no auth cookie present.");
+            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+            return;
+        }
+
+        var ticket = options.TicketDataFormat.Unprotect(cookie, GetTlsTokenBinding(context));
+        if (ticket is null)
+        {
+            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+            return;
+        }
+
+        // Generate session ID
+        var sessionId = GenerateSessionId();
+
+        // Store public key in ticket properties (will be re-protected into the long-lived cookie)
+        ticket.Properties.Items["DbscPublicKeyJwk"] = result.PublicKeyJwk;
+        ticket.Properties.Items["DbscSessionId"] = sessionId;
+        ticket.Properties.Items["DbscAlgorithm"] = result.Algorithm;
+
+        // Re-protect and set the long-lived cookie with embedded public key
+        var cookieValue = options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding(context));
+        var cookieOptions = options.Cookie.Build(context);
+        if (ticket.Properties.IsPersistent && ticket.Properties.ExpiresUtc.HasValue)
+        {
+            cookieOptions.Expires = ticket.Properties.ExpiresUtc.Value.ToUniversalTime();
+        }
+
+        options.CookieManager.AppendResponseCookie(context, options.Cookie.Name!, cookieValue, cookieOptions);
+
+        // Set the short-lived cookie
+        var shortLivedCookieName = dbscOptions.ShortLivedCookieName ?? $"{options.Cookie.Name}__dbsc";
+        var shortLivedCookieOptions = options.Cookie.Build(context);
+        shortLivedCookieOptions.Expires = DateTimeOffset.UtcNow.Add(dbscOptions.ShortLivedCookieExpiration);
+        shortLivedCookieOptions.MaxAge = dbscOptions.ShortLivedCookieExpiration;
+
+        var shortLivedValue = GenerateShortLivedCookieValue(sessionId);
+        options.CookieManager.AppendResponseCookie(context, shortLivedCookieName, shortLivedValue, shortLivedCookieOptions);
+
+        // Optionally store in server-side store
+        var store = context.RequestServices.GetService();
+        if (store is not null)
+        {
+            await store.StoreAsync(sessionId, result.PublicKeyJwk, context.RequestAborted);
+        }
+
+        // Build and return session configuration
+        var config = BuildSessionConfiguration(context, options, dbscOptions, sessionId, shortLivedCookieName);
+
+        context.Response.StatusCode = StatusCodes.Status200OK;
+        context.Response.ContentType = "application/json";
+
+        // Include a challenge for the next refresh
+        var challengeGenerator = GetChallengeGenerator(context);
+        var challenge = challengeGenerator.GenerateChallenge(sessionId);
+        context.Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionId}\"";
+
+        await JsonSerializer.SerializeAsync(context.Response.Body, config, DeviceBoundSessionJsonContext.Default.DeviceBoundSessionConfiguration, context.RequestAborted);
+    }
+
+    private async Task HandleRefreshAsync(
+        HttpContext context,
+        CookieAuthenticationOptions options,
+        DeviceBoundSessionOptions dbscOptions)
+    {
+        // Read session ID from header
+        var sessionIdHeader = context.Request.Headers["Sec-Secure-Session-Id"].ToString();
+        if (string.IsNullOrEmpty(sessionIdHeader))
+        {
+            context.Response.StatusCode = StatusCodes.Status400BadRequest;
+            return;
+        }
+
+        // Remove quotes if present
+        sessionIdHeader = sessionIdHeader.Trim('"');
+
+        // Check for server-side revocation
+        var store = context.RequestServices.GetService();
+        if (store is not null && await store.IsRevokedAsync(sessionIdHeader, context.RequestAborted))
+        {
+            // Return 403 without challenge to terminate the session
+            context.Response.StatusCode = StatusCodes.Status403Forbidden;
+            return;
+        }
+
+        // Read the long-lived cookie to get the public key
+        var cookie = options.CookieManager.GetRequestCookie(context, options.Cookie.Name!);
+        if (string.IsNullOrEmpty(cookie))
+        {
+            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+            return;
+        }
+
+        var ticket = options.TicketDataFormat.Unprotect(cookie, GetTlsTokenBinding(context));
+        if (ticket is null)
+        {
+            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
+            return;
+        }
+
+        // Verify the session ID matches
+        if (!ticket.Properties.Items.TryGetValue("DbscSessionId", out var storedSessionId) ||
+            !string.Equals(storedSessionId, sessionIdHeader, StringComparison.Ordinal))
+        {
+            context.Response.StatusCode = StatusCodes.Status403Forbidden;
+            return;
+        }
+
+        // Check for the proof JWT
+        var proofHeader = context.Request.Headers["Secure-Session-Response"].ToString();
+        if (string.IsNullOrEmpty(proofHeader))
+        {
+            // No proof yet — issue a challenge
+            var challengeGenerator = GetChallengeGenerator(context);
+            var challenge = challengeGenerator.GenerateChallenge(sessionIdHeader);
+
+            context.Response.StatusCode = StatusCodes.Status403Forbidden;
+            context.Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionIdHeader}\"";
+            return;
+        }
+
+        // Remove quotes if present
+        proofHeader = proofHeader.Trim('"');
+
+        // Get the public key from the ticket
+        if (!ticket.Properties.Items.TryGetValue("DbscPublicKeyJwk", out var publicKeyJwk) ||
+            publicKeyJwk is null)
+        {
+            context.Response.StatusCode = StatusCodes.Status403Forbidden;
+            return;
+        }
+
+        // Validate the JWT proof
+        // The jti claim should match a challenge we issued — we validate it's a valid challenge
+        var jwtResult = DeviceBoundSessionJwtValidator.Validate(proofHeader, publicKeyJwk, expectedChallenge: null);
+        if (jwtResult is null)
+        {
+            _logger.LogWarning("DBSC refresh: invalid JWT signature for session {SessionId}.", sessionIdHeader);
+            context.Response.StatusCode = StatusCodes.Status403Forbidden;
+            return;
+        }
+
+        // Validate the challenge (jti) is one we issued and is fresh
+        if (jwtResult.Challenge is not null)
+        {
+            var challengeGenerator = GetChallengeGenerator(context);
+            if (!challengeGenerator.ValidateChallenge(jwtResult.Challenge, sessionIdHeader, dbscOptions.ChallengeMaxAge))
+            {
+                _logger.LogWarning("DBSC refresh: stale or invalid challenge for session {SessionId}.", sessionIdHeader);
+                context.Response.StatusCode = StatusCodes.Status403Forbidden;
+                return;
+            }
+        }
+
+        // Success — issue a new short-lived cookie
+        var shortLivedCookieName = dbscOptions.ShortLivedCookieName ?? $"{options.Cookie.Name}__dbsc";
+        var shortLivedCookieOptions = options.Cookie.Build(context);
+        shortLivedCookieOptions.Expires = DateTimeOffset.UtcNow.Add(dbscOptions.ShortLivedCookieExpiration);
+        shortLivedCookieOptions.MaxAge = dbscOptions.ShortLivedCookieExpiration;
+
+        var shortLivedValue = GenerateShortLivedCookieValue(sessionIdHeader);
+        options.CookieManager.AppendResponseCookie(context, shortLivedCookieName, shortLivedValue, shortLivedCookieOptions);
+
+        // Return session configuration with new challenge
+        var config = BuildSessionConfiguration(context, options, dbscOptions, sessionIdHeader, shortLivedCookieName);
+        var nextChallengeGenerator = GetChallengeGenerator(context);
+        var nextChallenge = nextChallengeGenerator.GenerateChallenge(sessionIdHeader);
+
+        context.Response.StatusCode = StatusCodes.Status200OK;
+        context.Response.ContentType = "application/json";
+        context.Response.Headers["Secure-Session-Challenge"] = $"\"{nextChallenge}\";id=\"{sessionIdHeader}\"";
+
+        await JsonSerializer.SerializeAsync(context.Response.Body, config, DeviceBoundSessionJsonContext.Default.DeviceBoundSessionConfiguration, context.RequestAborted);
+    }
+
+    private static DeviceBoundSessionConfiguration BuildSessionConfiguration(
+        HttpContext context,
+        CookieAuthenticationOptions options,
+        DeviceBoundSessionOptions dbscOptions,
+        string sessionId,
+        string shortLivedCookieName)
+    {
+        var request = context.Request;
+        var origin = $"{request.Scheme}://{request.Host}";
+
+        var scopeRules = dbscOptions.ScopeSpecifications.Count > 0
+            ? dbscOptions.ScopeSpecifications.Select(r => new DeviceBoundSessionScopeRuleConfiguration
+            {
+                Type = r.Type,
+                Domain = r.Domain,
+                Path = r.Path
+            }).ToList()
+            : null;
+
+        // Build cookie attributes string
+        var cookieBuilder = options.Cookie.Build(context);
+        var attributes = BuildCookieAttributesString(cookieBuilder);
+
+        return new DeviceBoundSessionConfiguration
+        {
+            SessionIdentifier = sessionId,
+            RefreshUrl = dbscOptions.RefreshPath.Value,
+            Scope = new DeviceBoundSessionScopeConfiguration
+            {
+                Origin = origin,
+                IncludeSite = dbscOptions.IncludeSite,
+                ScopeSpecification = scopeRules
+            },
+            Credentials = new List
+            {
+                new DeviceBoundSessionCredentialConfiguration
+                {
+                    Type = "cookie",
+                    Name = shortLivedCookieName,
+                    Attributes = attributes
+                }
+            },
+            AllowedRefreshInitiators = dbscOptions.AllowedRefreshInitiators.Count > 0
+                ? dbscOptions.AllowedRefreshInitiators.ToList()
+                : null
+        };
+    }
+
+    private static string BuildCookieAttributesString(CookieOptions cookieOptions)
+    {
+        var parts = new List();
+
+        if (!string.IsNullOrEmpty(cookieOptions.Domain))
+        {
+            parts.Add($"Domain={cookieOptions.Domain}");
+        }
+
+        if (!string.IsNullOrEmpty(cookieOptions.Path))
+        {
+            parts.Add($"Path={cookieOptions.Path}");
+        }
+
+        if (cookieOptions.Secure)
+        {
+            parts.Add("Secure");
+        }
+
+        if (cookieOptions.HttpOnly)
+        {
+            parts.Add("HttpOnly");
+        }
+
+        if (cookieOptions.SameSite != SameSiteMode.Unspecified)
+        {
+            parts.Add($"SameSite={cookieOptions.SameSite}");
+        }
+
+        return string.Join("; ", parts);
+    }
+
+    private static string GenerateSessionId()
+    {
+        return Convert.ToBase64String(RandomNumberGenerator.GetBytes(24))
+            .Replace('+', '-')
+            .Replace('/', '_')
+            .TrimEnd('=');
+    }
+
+    private static string GenerateShortLivedCookieValue(string sessionId)
+    {
+        // The short-lived cookie value is a simple marker that proves it was recently issued.
+        // It doesn't need to contain sensitive data — the long-lived cookie has the auth ticket.
+        var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(8));
+        return $"{sessionId}:{nonce}";
+    }
+
+    private static string? GetTlsTokenBinding(HttpContext context)
+    {
+        var binding = context.Features.Get()?.GetProvidedTokenBindingId();
+        return binding is null ? null : Convert.ToBase64String(binding);
+    }
+
+    private static DeviceBoundSessionChallengeGenerator GetChallengeGenerator(HttpContext context)
+    {
+        var dataProtection = context.RequestServices.GetRequiredService();
+        var timeProvider = context.RequestServices.GetService();
+        return new DeviceBoundSessionChallengeGenerator(dataProtection, timeProvider);
+    }
+}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionOptions.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionOptions.cs
new file mode 100644
index 000000000000..ba7abcae6454
--- /dev/null
+++ b/src/Security/Authentication/Cookies/src/DeviceBoundSessionOptions.cs
@@ -0,0 +1,112 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Http;
+
+namespace Microsoft.AspNetCore.Authentication.Cookies;
+
+/// 
+/// Configuration options for Device Bound Session Credentials (DBSC).
+/// DBSC binds session cookies to a device using cryptographic key pairs,
+/// preventing session cookie theft and exfiltration.
+/// 
+/// 
+/// 
+/// builder.Services.AddAuthentication()
+///     .AddCookie(options =>
+///     {
+///         options.DeviceBoundSession = new DeviceBoundSessionOptions
+///         {
+///             Enabled = true,
+///             RegistrationPath = new PathString("/.well-known/dbsc/registration"),
+///             RefreshPath = new PathString("/.well-known/dbsc/refresh"),
+///         };
+///     });
+/// 
+/// 
+public class DeviceBoundSessionOptions
+{
+    /// 
+    /// Gets or sets whether Device Bound Session Credentials are enabled.
+    /// Default is false.
+    /// 
+    public bool Enabled { get; set; }
+
+    /// 
+    /// Gets or sets the path for the DBSC registration endpoint.
+    /// The browser POSTs the public key to this path after receiving the
+    /// Secure-Session-Registration header.
+    /// 
+    /// 
+    /// This path must be same-site with the cookie's domain.
+    /// 
+    public PathString RegistrationPath { get; set; } = new PathString("/.well-known/dbsc/registration");
+
+    /// 
+    /// Gets or sets the path for the DBSC refresh endpoint.
+    /// The browser POSTs to this path when the short-lived cookie expires
+    /// to prove possession of the private key.
+    /// 
+    /// 
+    /// This path must be same-site with the cookie's domain.
+    /// 
+    public PathString RefreshPath { get; set; } = new PathString("/.well-known/dbsc/refresh");
+
+    /// 
+    /// Gets or sets the expiration time for the short-lived (bound) cookie.
+    /// When this cookie expires, the browser must prove possession of the
+    /// device-bound private key to obtain a new one.
+    /// 
+    /// 
+    /// Default is 10 minutes. Shorter values increase security but also increase
+    /// refresh frequency (and TPM/network load).
+    /// 
+    public TimeSpan ShortLivedCookieExpiration { get; set; } = TimeSpan.FromMinutes(10);
+
+    /// 
+    /// Gets or sets the name of the short-lived (bound) cookie.
+    /// This is the cookie that DBSC monitors for expiration.
+    /// 
+    /// 
+    /// If not set, defaults to the authentication cookie name with a __dbsc suffix.
+    /// 
+    public string? ShortLivedCookieName { get; set; }
+
+    /// 
+    /// Gets or sets the supported signing algorithms for DBSC key pairs.
+    /// Default is ES256 and RS256.
+    /// 
+    /// 
+    /// The algorithms are listed in order of preference. The browser will select
+    /// the first algorithm it supports from this list.
+    /// 
+    public IList SupportedAlgorithms { get; set; } = new List { "ES256", "RS256" };
+
+    /// 
+    /// Gets or sets whether the session scope should include the entire site
+    /// (all subdomains) or just the origin.
+    /// 
+    /// 
+    /// When true, the DBSC session applies to all subdomains of the registrable domain.
+    /// When false (default), it applies only to the exact origin.
+    /// 
+    public bool IncludeSite { get; set; }
+
+    /// 
+    /// Gets or sets the scope specifications for the session.
+    /// These define include/exclude rules for specific domain/path patterns.
+    /// 
+    public IList ScopeSpecifications { get; set; } = new List();
+
+    /// 
+    /// Gets or sets the hosts allowed to initiate DBSC refreshes from
+    /// cross-origin contexts.
+    /// 
+    public IList AllowedRefreshInitiators { get; set; } = new List();
+
+    /// 
+    /// Gets or sets the maximum age for a challenge before it is considered stale.
+    /// Default is 5 minutes.
+    /// 
+    public TimeSpan ChallengeMaxAge { get; set; } = TimeSpan.FromMinutes(5);
+}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionScopeRule.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionScopeRule.cs
new file mode 100644
index 000000000000..c7da36edeebc
--- /dev/null
+++ b/src/Security/Authentication/Cookies/src/DeviceBoundSessionScopeRule.cs
@@ -0,0 +1,28 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Authentication.Cookies;
+
+/// 
+/// Represents a scope specification rule for Device Bound Session Credentials.
+/// Scope rules define which URL patterns are included or excluded from the DBSC session.
+/// 
+public class DeviceBoundSessionScopeRule
+{
+    /// 
+    /// Gets or sets the type of scope rule: "include" or "exclude".
+    /// 
+    public required string Type { get; set; }
+
+    /// 
+    /// Gets or sets the domain pattern for the rule.
+    /// Supports wildcards (e.g., "*.example.com"). Defaults to "*" (all domains).
+    /// 
+    public string Domain { get; set; } = "*";
+
+    /// 
+    /// Gets or sets the path prefix for the rule.
+    /// Defaults to "/" (all paths).
+    /// 
+    public string Path { get; set; } = "/";
+}
diff --git a/src/Security/Authentication/Cookies/src/IDeviceBoundSessionStore.cs b/src/Security/Authentication/Cookies/src/IDeviceBoundSessionStore.cs
new file mode 100644
index 000000000000..5f06b217a2a1
--- /dev/null
+++ b/src/Security/Authentication/Cookies/src/IDeviceBoundSessionStore.cs
@@ -0,0 +1,50 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Authentication.Cookies;
+
+/// 
+/// An optional interface for server-side storage of Device Bound Session data.
+/// This is not required for basic DBSC operation (the public key is stored in the long-lived cookie),
+/// but can be used for session revocation and tracking scenarios.
+/// 
+/// 
+/// 
+/// By default, DBSC operates statelessly: the public key is embedded in the data-protected
+/// long-lived cookie, and challenges are self-contained. This interface enables additional
+/// server-side capabilities:
+/// 
+/// 
+/// Session revocation: explicitly revoke a bound session before cookie expiry.
+/// Audit: track active device-bound sessions per user.
+/// Key rotation: force re-registration by revoking the current session.
+/// 
+/// 
+public interface IDeviceBoundSessionStore
+{
+    /// 
+    /// Stores a device-bound session record, associating the session identifier with metadata.
+    /// 
+    /// The unique session identifier.
+    /// The JSON Web Key (JWK) of the device's public key.
+    /// The cancellation token.
+    /// A task representing the asynchronous operation.
+    Task StoreAsync(string sessionId, string publicKeyJwk, CancellationToken cancellationToken = default);
+
+    /// 
+    /// Checks whether a device-bound session has been revoked.
+    /// 
+    /// The session identifier to check.
+    /// The cancellation token.
+    /// true if the session has been revoked; otherwise, false.
+    Task IsRevokedAsync(string sessionId, CancellationToken cancellationToken = default);
+
+    /// 
+    /// Revokes a device-bound session. After revocation, refresh attempts will be rejected
+    /// and the browser session will be terminated.
+    /// 
+    /// The session identifier to revoke.
+    /// The cancellation token.
+    /// A task representing the asynchronous operation.
+    Task RevokeAsync(string sessionId, CancellationToken cancellationToken = default);
+}
diff --git a/src/Security/Authentication/Cookies/src/Microsoft.AspNetCore.Authentication.Cookies.csproj b/src/Security/Authentication/Cookies/src/Microsoft.AspNetCore.Authentication.Cookies.csproj
index e80ad93f2755..a76fb8acfcaa 100644
--- a/src/Security/Authentication/Cookies/src/Microsoft.AspNetCore.Authentication.Cookies.csproj
+++ b/src/Security/Authentication/Cookies/src/Microsoft.AspNetCore.Authentication.Cookies.csproj
@@ -11,6 +11,10 @@
     true
   
 
+  
+    
+  
+
   
     
   
diff --git a/src/Security/Authentication/Cookies/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/Cookies/src/PublicAPI.Unshipped.txt
index 7dc5c58110bf..30027afe1393 100644
--- a/src/Security/Authentication/Cookies/src/PublicAPI.Unshipped.txt
+++ b/src/Security/Authentication/Cookies/src/PublicAPI.Unshipped.txt
@@ -1 +1,77 @@
 #nullable enable
+Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions.DeviceBoundSession.get -> Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions?
+Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions.DeviceBoundSession.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.DeviceBoundSessionOptions() -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.AllowedRefreshInitiators.get -> System.Collections.Generic.IList!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.AllowedRefreshInitiators.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ChallengeMaxAge.get -> System.TimeSpan
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ChallengeMaxAge.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.Enabled.get -> bool
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.Enabled.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.IncludeSite.get -> bool
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.IncludeSite.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.RegistrationPath.get -> Microsoft.AspNetCore.Http.PathString
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.RegistrationPath.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.RefreshPath.get -> Microsoft.AspNetCore.Http.PathString
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.RefreshPath.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ScopeSpecifications.get -> System.Collections.Generic.IList!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ScopeSpecifications.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ShortLivedCookieExpiration.get -> System.TimeSpan
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ShortLivedCookieExpiration.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ShortLivedCookieName.get -> string?
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ShortLivedCookieName.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.SupportedAlgorithms.get -> System.Collections.Generic.IList!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.SupportedAlgorithms.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.DeviceBoundSessionScopeRule() -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Domain.get -> string!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Domain.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Path.get -> string!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Path.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Type.get -> string!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Type.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.DeviceBoundSessionConfiguration() -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.AllowedRefreshInitiators.get -> System.Collections.Generic.IList?
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.AllowedRefreshInitiators.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Continue.get -> bool?
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Continue.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Credentials.get -> System.Collections.Generic.IList!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Credentials.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.RefreshUrl.get -> string?
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.RefreshUrl.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Scope.get -> Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Scope.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.SessionIdentifier.get -> string!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.SessionIdentifier.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.DeviceBoundSessionScopeConfiguration() -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.IncludeSite.get -> bool
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.IncludeSite.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.Origin.get -> string?
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.Origin.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.ScopeSpecification.get -> System.Collections.Generic.IList?
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.ScopeSpecification.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.DeviceBoundSessionScopeRuleConfiguration() -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Domain.get -> string?
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Domain.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Path.get -> string?
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Path.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Type.get -> string!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Type.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.DeviceBoundSessionCredentialConfiguration() -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Attributes.get -> string?
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Attributes.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Name.get -> string!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Name.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Type.get -> string!
+Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Type.set -> void
+Microsoft.AspNetCore.Authentication.Cookies.IDeviceBoundSessionStore
+Microsoft.AspNetCore.Authentication.Cookies.IDeviceBoundSessionStore.IsRevokedAsync(string! sessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Authentication.Cookies.IDeviceBoundSessionStore.RevokeAsync(string! sessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Authentication.Cookies.IDeviceBoundSessionStore.StoreAsync(string! sessionId, string! publicKeyJwk, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions
+static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.UseDeviceBoundSessions(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app) -> Microsoft.AspNetCore.Builder.IApplicationBuilder!
diff --git a/src/Security/Authentication/test/DeviceBoundSessionTests.cs b/src/Security/Authentication/test/DeviceBoundSessionTests.cs
new file mode 100644
index 000000000000..8d329e913bb3
--- /dev/null
+++ b/src/Security/Authentication/test/DeviceBoundSessionTests.cs
@@ -0,0 +1,521 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Net;
+using System.Net.Http;
+using System.Security.Claims;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace Microsoft.AspNetCore.Authentication.Cookies;
+
+public class DeviceBoundSessionTests
+{
+    [Fact]
+    public async Task SignIn_WithDbscEnabled_EmitsRegistrationHeader()
+    {
+        using var host = await CreateDbscHost();
+        using var server = host.GetTestServer();
+
+        var response = await server.CreateClient().GetAsync("http://example.com/signin");
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+        Assert.True(response.Headers.Contains("Secure-Session-Registration"));
+        var headerValue = response.Headers.GetValues("Secure-Session-Registration").Single();
+        Assert.Contains("ES256", headerValue);
+        Assert.Contains("RS256", headerValue);
+        Assert.Contains("path=\"/.well-known/dbsc/registration\"", headerValue);
+        Assert.Contains("challenge=", headerValue);
+    }
+
+    [Fact]
+    public async Task SignIn_WithoutDbscEnabled_DoesNotEmitRegistrationHeader()
+    {
+        using var host = await CreateHost(options => { });
+        using var server = host.GetTestServer();
+
+        var response = await server.CreateClient().GetAsync("http://example.com/signin");
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+        Assert.False(response.Headers.Contains("Secure-Session-Registration"));
+    }
+
+    [Fact]
+    public async Task Registration_WithoutAuthCookie_Returns401()
+    {
+        using var host = await CreateDbscHost();
+        using var server = host.GetTestServer();
+
+        // Without a Secure-Session-Response header, returns 400 (missing header)
+        // With a header but no cookie, returns 401
+        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/registration");
+        request.Headers.Add("Secure-Session-Response", "\"invalid.jwt.here\"");
+        var response = await server.CreateClient().SendAsync(request);
+
+        // JWT is invalid so we get 400 first (can't parse)
+        // The handler checks JWT validity before cookie
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Registration_WithoutJwtHeader_Returns400()
+    {
+        using var host = await CreateDbscHost();
+        using var server = host.GetTestServer();
+        var client = server.CreateClient();
+
+        // First sign in to get a cookie
+        var signInResponse = await client.GetAsync("http://example.com/signin");
+        var cookies = GetCookies(signInResponse);
+
+        // POST registration without Secure-Session-Response header
+        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/registration");
+        request.Headers.Add("Cookie", cookies);
+        var response = await client.SendAsync(request);
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Registration_WithValidJwt_ReturnsSessionConfig()
+    {
+        using var host = await CreateDbscHost();
+        using var server = host.GetTestServer();
+        var client = server.CreateClient();
+
+        // Sign in
+        var signInResponse = await client.GetAsync("http://example.com/signin");
+        var cookies = GetCookies(signInResponse);
+
+        // Create a valid DBSC JWT with EC key
+        var (jwt, _) = CreateRegistrationJwt("test-challenge");
+
+        // POST registration
+        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/registration");
+        request.Headers.Add("Cookie", cookies);
+        request.Headers.Add("Secure-Session-Response", $"\"{jwt}\"");
+        var response = await client.SendAsync(request);
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+        var content = await response.Content.ReadAsStringAsync();
+        var config = JsonDocument.Parse(content).RootElement;
+
+        Assert.True(config.TryGetProperty("session_identifier", out var sessionId));
+        Assert.False(string.IsNullOrEmpty(sessionId.GetString()));
+        Assert.True(config.TryGetProperty("refresh_url", out var refreshUrl));
+        Assert.Equal("/.well-known/dbsc/refresh", refreshUrl.GetString());
+        Assert.True(config.TryGetProperty("scope", out _));
+        Assert.True(config.TryGetProperty("credentials", out var credentials));
+        Assert.Equal("cookie", credentials[0].GetProperty("type").GetString());
+
+        // Should also set the short-lived cookie
+        Assert.True(response.Headers.Contains("Secure-Session-Challenge"));
+    }
+
+    [Fact]
+    public async Task Refresh_WithoutSessionId_Returns400()
+    {
+        using var host = await CreateDbscHost();
+        using var server = host.GetTestServer();
+
+        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/refresh");
+        var response = await server.CreateClient().SendAsync(request);
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Refresh_WithoutProof_ReturnsChallenge()
+    {
+        using var host = await CreateDbscHost();
+        using var server = host.GetTestServer();
+        var client = server.CreateClient();
+
+        // Sign in and register
+        var (cookies, sessionId) = await SignInAndRegister(client);
+
+        // POST refresh without proof
+        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/refresh");
+        request.Headers.Add("Cookie", cookies);
+        request.Headers.Add("Sec-Secure-Session-Id", $"\"{sessionId}\"");
+        var response = await client.SendAsync(request);
+
+        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+        Assert.True(response.Headers.Contains("Secure-Session-Challenge"));
+        var challengeHeader = response.Headers.GetValues("Secure-Session-Challenge").Single();
+        Assert.Contains($"id=\"{sessionId}\"", challengeHeader);
+    }
+
+    [Fact]
+    public async Task Refresh_WithValidProof_IssuesNewCookie()
+    {
+        using var host = await CreateDbscHost();
+        using var server = host.GetTestServer();
+        var client = server.CreateClient();
+
+        // Sign in and register
+        var (cookies, sessionId, ecDsa) = await SignInAndRegisterWithKey(client);
+
+        // Get a challenge
+        var challengeRequest = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/refresh");
+        challengeRequest.Headers.Add("Cookie", cookies);
+        challengeRequest.Headers.Add("Sec-Secure-Session-Id", $"\"{sessionId}\"");
+        var challengeResponse = await client.SendAsync(challengeRequest);
+
+        Assert.Equal(HttpStatusCode.Forbidden, challengeResponse.StatusCode);
+        var challengeHeader = challengeResponse.Headers.GetValues("Secure-Session-Challenge").Single();
+        // Extract challenge value between first pair of quotes
+        var challenge = ExtractChallengeValue(challengeHeader);
+
+        // Sign the challenge
+        var proofJwt = CreateRefreshProofJwt(challenge, ecDsa);
+
+        // POST refresh with proof
+        var refreshRequest = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/refresh");
+        refreshRequest.Headers.Add("Cookie", cookies);
+        refreshRequest.Headers.Add("Sec-Secure-Session-Id", $"\"{sessionId}\"");
+        refreshRequest.Headers.Add("Secure-Session-Response", $"\"{proofJwt}\"");
+        var refreshResponse = await client.SendAsync(refreshRequest);
+
+        Assert.Equal(HttpStatusCode.OK, refreshResponse.StatusCode);
+
+        // Should contain new session config
+        var content = await refreshResponse.Content.ReadAsStringAsync();
+        var config = JsonDocument.Parse(content).RootElement;
+        Assert.Equal(sessionId, config.GetProperty("session_identifier").GetString());
+
+        // Should have Set-Cookie for short-lived cookie
+        Assert.True(refreshResponse.Headers.Contains("Secure-Session-Challenge"));
+    }
+
+    [Fact]
+    public void ChallengeGenerator_ValidChallenge_Validates()
+    {
+        var services = new ServiceCollection();
+        services.AddDataProtection();
+        var sp = services.BuildServiceProvider();
+        var dp = sp.GetRequiredService();
+
+        var generator = new DeviceBoundSessionChallengeGenerator(dp);
+        var challenge = generator.GenerateChallenge("session-123");
+
+        Assert.True(generator.ValidateChallenge(challenge, "session-123", TimeSpan.FromMinutes(5)));
+    }
+
+    [Fact]
+    public void ChallengeGenerator_WrongSessionId_Rejects()
+    {
+        var services = new ServiceCollection();
+        services.AddDataProtection();
+        var sp = services.BuildServiceProvider();
+        var dp = sp.GetRequiredService();
+
+        var generator = new DeviceBoundSessionChallengeGenerator(dp);
+        var challenge = generator.GenerateChallenge("session-123");
+
+        Assert.False(generator.ValidateChallenge(challenge, "session-456", TimeSpan.FromMinutes(5)));
+    }
+
+    [Fact]
+    public void ChallengeGenerator_ExpiredChallenge_Rejects()
+    {
+        var services = new ServiceCollection();
+        services.AddDataProtection();
+        var sp = services.BuildServiceProvider();
+        var dp = sp.GetRequiredService();
+
+        var generator = new DeviceBoundSessionChallengeGenerator(dp);
+        var challenge = generator.GenerateChallenge("session-123");
+
+        // Zero-second max age means it's immediately expired
+        Assert.False(generator.ValidateChallenge(challenge, "session-123", TimeSpan.Zero));
+    }
+
+    [Fact]
+    public void JwtValidator_ValidES256Jwt_Validates()
+    {
+        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+        var jwt = CreateTestJwt(ecdsa, "test-challenge");
+        var jwk = ExportPublicKeyAsJwk(ecdsa);
+
+        var result = DeviceBoundSessionJwtValidator.Validate(jwt, jwk, "test-challenge");
+
+        Assert.NotNull(result);
+        Assert.Equal("ES256", result.Algorithm);
+        Assert.Equal("test-challenge", result.Challenge);
+    }
+
+    [Fact]
+    public void JwtValidator_WrongChallenge_ReturnsNull()
+    {
+        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+        var jwt = CreateTestJwt(ecdsa, "actual-challenge");
+        var jwk = ExportPublicKeyAsJwk(ecdsa);
+
+        var result = DeviceBoundSessionJwtValidator.Validate(jwt, jwk, "expected-challenge");
+
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public void JwtValidator_TamperedSignature_ReturnsNull()
+    {
+        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+        var jwt = CreateTestJwt(ecdsa, "test-challenge");
+        var jwk = ExportPublicKeyAsJwk(ecdsa);
+
+        // Tamper with signature
+        var parts = jwt.Split('.');
+        var tamperedJwt = $"{parts[0]}.{parts[1]}.AAAA{parts[2][4..]}";
+
+        var result = DeviceBoundSessionJwtValidator.Validate(tamperedJwt, jwk, "test-challenge");
+
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public void JwtValidator_WrongKey_ReturnsNull()
+    {
+        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+        using var otherEcdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+        var jwt = CreateTestJwt(ecdsa, "test-challenge");
+        var wrongJwk = ExportPublicKeyAsJwk(otherEcdsa);
+
+        var result = DeviceBoundSessionJwtValidator.Validate(jwt, wrongJwk, "test-challenge");
+
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public void JwtValidator_ExtractPublicKeyJwk_ExtractsFromHeader()
+    {
+        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+        var jwt = CreateRegistrationJwtWithKey(ecdsa, "challenge");
+
+        var jwk = DeviceBoundSessionJwtValidator.ExtractPublicKeyJwk(jwt);
+
+        Assert.NotNull(jwk);
+        Assert.Contains("\"kty\":\"EC\"", jwk);
+        Assert.Contains("\"crv\":\"P-256\"", jwk);
+    }
+
+    // Helper methods
+
+    private static Task CreateDbscHost()
+    {
+        return CreateHost(options =>
+        {
+            options.DeviceBoundSession = new DeviceBoundSessionOptions
+            {
+                Enabled = true
+            };
+        });
+    }
+
+    private static async Task CreateHost(Action configureOptions)
+    {
+        var host = new HostBuilder()
+            .ConfigureWebHost(builder =>
+                builder.UseTestServer()
+                    .ConfigureServices(services =>
+                    {
+                        services.AddDataProtection();
+                        services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
+                            .AddCookie(o =>
+                            {
+                                configureOptions(o);
+                            });
+                    })
+                    .Configure(app =>
+                    {
+                        app.UseAuthentication();
+                        app.UseDeviceBoundSessions();
+                        app.Use(async (context, next) =>
+                        {
+                            if (context.Request.Path == new PathString("/signin"))
+                            {
+                                var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
+                                identity.AddClaim(new Claim(ClaimTypes.Name, "Alice"));
+                                await context.SignInAsync(
+                                    CookieAuthenticationDefaults.AuthenticationScheme,
+                                    new ClaimsPrincipal(identity),
+                                    new AuthenticationProperties { IsPersistent = true });
+                            }
+                            else
+                            {
+                                await next(context);
+                            }
+                        });
+                    }))
+            .Build();
+
+        await host.StartAsync();
+        return host;
+    }
+
+    private static string GetCookies(HttpResponseMessage response)
+    {
+        if (!response.Headers.TryGetValues("Set-Cookie", out var setCookies))
+        {
+            return string.Empty;
+        }
+
+        var cookieParts = setCookies.Select(c => c.Split(';')[0]);
+        return string.Join("; ", cookieParts);
+    }
+
+    private async Task<(string cookies, string sessionId)> SignInAndRegister(HttpClient client)
+    {
+        var (cookies, sessionId, _) = await SignInAndRegisterWithKey(client);
+        return (cookies, sessionId);
+    }
+
+    private async Task<(string cookies, string sessionId, ECDsa key)> SignInAndRegisterWithKey(HttpClient client)
+    {
+        // Sign in
+        var signInResponse = await client.GetAsync("http://example.com/signin");
+        var cookies = GetCookies(signInResponse);
+
+        // Create registration JWT
+        var (jwt, ecDsa) = CreateRegistrationJwt("test-challenge");
+
+        // Register
+        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/registration");
+        request.Headers.Add("Cookie", cookies);
+        request.Headers.Add("Secure-Session-Response", $"\"{jwt}\"");
+        var response = await client.SendAsync(request);
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+        // Update cookies with registration response
+        var updatedCookies = GetCookies(response);
+        if (!string.IsNullOrEmpty(updatedCookies))
+        {
+            cookies = MergeCookies(cookies, updatedCookies);
+        }
+
+        var content = await response.Content.ReadAsStringAsync();
+        var config = JsonDocument.Parse(content).RootElement;
+        var sessionId = config.GetProperty("session_identifier").GetString()!;
+
+        return (cookies, sessionId, ecDsa);
+    }
+
+    private static string MergeCookies(string existing, string updates)
+    {
+        var cookieDict = new Dictionary();
+        foreach (var cookie in existing.Split("; ", StringSplitOptions.RemoveEmptyEntries))
+        {
+            var eqIdx = cookie.IndexOf('=');
+            if (eqIdx > 0)
+            {
+                cookieDict[cookie[..eqIdx]] = cookie[(eqIdx + 1)..];
+            }
+        }
+        foreach (var cookie in updates.Split("; ", StringSplitOptions.RemoveEmptyEntries))
+        {
+            var eqIdx = cookie.IndexOf('=');
+            if (eqIdx > 0)
+            {
+                cookieDict[cookie[..eqIdx]] = cookie[(eqIdx + 1)..];
+            }
+        }
+        return string.Join("; ", cookieDict.Select(kvp => $"{kvp.Key}={kvp.Value}"));
+    }
+
+    private static (string jwt, ECDsa key) CreateRegistrationJwt(string challenge)
+    {
+        var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+        var jwt = CreateRegistrationJwtWithKey(ecdsa, challenge);
+        return (jwt, ecdsa);
+    }
+
+    private static string CreateRegistrationJwtWithKey(ECDsa ecdsa, string challenge)
+    {
+        var jwk = ExportPublicKeyAsJwk(ecdsa);
+        var header = JsonSerializer.Serialize(new
+        {
+            alg = "ES256",
+            typ = "dbsc+jwt",
+            jwk = JsonDocument.Parse(jwk).RootElement
+        });
+        var payload = JsonSerializer.Serialize(new { jti = challenge });
+
+        var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(header));
+        var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payload));
+        var signingInput = $"{headerB64}.{payloadB64}";
+        var signature = ecdsa.SignData(Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256);
+
+        return $"{signingInput}.{Base64UrlEncode(signature)}";
+    }
+
+    private static string CreateTestJwt(ECDsa ecdsa, string challenge)
+    {
+        var header = JsonSerializer.Serialize(new
+        {
+            alg = "ES256",
+            typ = "dbsc+jwt"
+        });
+        var payload = JsonSerializer.Serialize(new { jti = challenge });
+
+        var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(header));
+        var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payload));
+        var signingInput = $"{headerB64}.{payloadB64}";
+        var signature = ecdsa.SignData(Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256);
+
+        return $"{signingInput}.{Base64UrlEncode(signature)}";
+    }
+
+    private static string CreateRefreshProofJwt(string challenge, ECDsa ecdsa)
+    {
+        var header = JsonSerializer.Serialize(new
+        {
+            alg = "ES256",
+            typ = "dbsc+jwt"
+        });
+        var payload = JsonSerializer.Serialize(new { jti = challenge });
+
+        var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(header));
+        var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payload));
+        var signingInput = $"{headerB64}.{payloadB64}";
+        var signature = ecdsa.SignData(Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256);
+
+        return $"{signingInput}.{Base64UrlEncode(signature)}";
+    }
+
+    private static string ExportPublicKeyAsJwk(ECDsa ecdsa)
+    {
+        var parameters = ecdsa.ExportParameters(false);
+        return JsonSerializer.Serialize(new
+        {
+            kty = "EC",
+            crv = "P-256",
+            x = Base64UrlEncode(parameters.Q.X!),
+            y = Base64UrlEncode(parameters.Q.Y!)
+        });
+    }
+
+    private static string ExtractChallengeValue(string challengeHeader)
+    {
+        // Format: "challenge_value";id="session_id"
+        var firstQuote = challengeHeader.IndexOf('"');
+        var secondQuote = challengeHeader.IndexOf('"', firstQuote + 1);
+        return challengeHeader[(firstQuote + 1)..secondQuote];
+    }
+
+    private static string Base64UrlEncode(byte[] data)
+    {
+        return Convert.ToBase64String(data)
+            .Replace('+', '-')
+            .Replace('/', '_')
+            .TrimEnd('=');
+    }
+}

From a33d431730e6096bbf759d7873fc66eb3c200585 Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 19:02:35 +0200
Subject: [PATCH 02/33] WIP: Add DeviceBoundSessions handler project (v2
 architecture)

New separate authentication handler project following the OAuth/OIDC delegation
pattern. The DBSC handler manages the registration/refresh protocol and delegates
cookie stamping to separate cookie authentication schemes.

Architecture:
- Policy scheme routes auth: tries Session cookie first, falls back to source
- Registration handler reads source scheme, stamps Refresh + Session cookies
- Refresh handler reads Refresh cookie (path-scoped), stamps fresh Session cookie
- Source (long-lived) cookie deleted after registration

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../src/DeviceBoundSessionConfiguration.cs    | 118 ++++++
 .../src/DeviceBoundSessionCookieEvents.cs     |  45 +++
 .../src/DeviceBoundSessionDefaults.cs         |  35 ++
 .../src/DeviceBoundSessionExtensions.cs       | 110 ++++++
 .../src/DeviceBoundSessionHandler.cs          | 336 ++++++++++++++++++
 .../src/DeviceBoundSessionJwtValidator.cs     | 290 +++++++++++++++
 .../src/DeviceBoundSessionOptions.cs          |  70 ++++
 .../src/DeviceBoundSessionScopeRule.cs        |  25 ++
 ....Authentication.DeviceBoundSessions.csproj |  30 ++
 .../src/PublicAPI.Shipped.txt                 |   1 +
 .../src/PublicAPI.Unshipped.txt               |   1 +
 11 files changed, 1061 insertions(+)
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConfiguration.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRule.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Shipped.txt
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConfiguration.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConfiguration.cs
new file mode 100644
index 000000000000..edc2bf530554
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConfiguration.cs
@@ -0,0 +1,118 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Represents the DBSC session configuration returned to the browser.
+/// 
+public sealed class DeviceBoundSessionConfiguration
+{
+    /// 
+    /// Gets or sets the session identifier.
+    /// 
+    [JsonPropertyName("session_identifier")]
+    public string SessionIdentifier { get; set; } = default!;
+
+    /// 
+    /// Gets or sets the refresh URL.
+    /// 
+    [JsonPropertyName("refresh_url")]
+    public string? RefreshUrl { get; set; }
+
+    /// 
+    /// Gets or sets the session scope.
+    /// 
+    [JsonPropertyName("scope")]
+    public DeviceBoundSessionScopeConfiguration? Scope { get; set; }
+
+    /// 
+    /// Gets or sets the session credentials.
+    /// 
+    [JsonPropertyName("credentials")]
+    public List? Credentials { get; set; }
+}
+
+/// 
+/// Represents the scope of a DBSC session.
+/// 
+public sealed class DeviceBoundSessionScopeConfiguration
+{
+    /// 
+    /// Gets or sets the origin for the session scope.
+    /// 
+    [JsonPropertyName("origin")]
+    public string? Origin { get; set; }
+
+    /// 
+    /// Gets or sets whether the session applies to the entire site.
+    /// 
+    [JsonPropertyName("include_site")]
+    public bool IncludeSite { get; set; }
+
+    /// 
+    /// Gets or sets the scope specification rules.
+    /// 
+    [JsonPropertyName("scope_specification")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+    public List? ScopeSpecification { get; set; }
+}
+
+/// 
+/// Represents a scope specification rule in the DBSC session configuration.
+/// 
+public sealed class DeviceBoundSessionScopeRuleConfiguration
+{
+    /// 
+    /// Gets or sets the type ("include" or "exclude").
+    /// 
+    [JsonPropertyName("type")]
+    public string Type { get; set; } = default!;
+
+    /// 
+    /// Gets or sets the domain pattern.
+    /// 
+    [JsonPropertyName("domain")]
+    public string Domain { get; set; } = default!;
+
+    /// 
+    /// Gets or sets the path.
+    /// 
+    [JsonPropertyName("path")]
+    public string Path { get; set; } = default!;
+}
+
+/// 
+/// Represents a credential in the DBSC session configuration.
+/// 
+public sealed class DeviceBoundSessionCredentialConfiguration
+{
+    /// 
+    /// Gets or sets the credential type (always "cookie").
+    /// 
+    [JsonPropertyName("type")]
+    public string Type { get; set; } = "cookie";
+
+    /// 
+    /// Gets or sets the cookie name.
+    /// 
+    [JsonPropertyName("name")]
+    public string Name { get; set; } = default!;
+
+    /// 
+    /// Gets or sets the cookie attributes string.
+    /// 
+    [JsonPropertyName("attributes")]
+    public string Attributes { get; set; } = default!;
+}
+
+/// 
+/// Source-generated JSON serialization context for DBSC configuration types.
+/// 
+[JsonSerializable(typeof(DeviceBoundSessionConfiguration))]
+[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
+internal sealed partial class DeviceBoundSessionJsonContext : JsonSerializerContext
+{
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
new file mode 100644
index 000000000000..4567da6b54f6
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
@@ -0,0 +1,45 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Cookie authentication events that emit the Secure-Session-Registration header on sign-in.
+/// Wire this into the source cookie scheme to trigger DBSC registration.
+/// 
+public class DeviceBoundSessionCookieEvents : CookieAuthenticationEvents
+{
+    private readonly DeviceBoundSessionOptions _dbscOptions;
+
+    /// 
+    /// Initializes a new instance of .
+    /// 
+    /// The DBSC options.
+    public DeviceBoundSessionCookieEvents(DeviceBoundSessionOptions dbscOptions)
+    {
+        _dbscOptions = dbscOptions;
+    }
+
+    /// 
+    public override Task SigningIn(CookieSigningInContext context)
+    {
+        EmitRegistrationHeader(context.HttpContext);
+        return base.SigningIn(context);
+    }
+
+    private void EmitRegistrationHeader(HttpContext httpContext)
+    {
+        var dataProtectionProvider = httpContext.RequestServices.GetRequiredService();
+        var protector = dataProtectionProvider.CreateProtector("Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.v1");
+
+        var challenge = protector.Protect($"{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}|{Guid.NewGuid()}|registration");
+
+        var headerValue = $"(ES256 RS256);path=\"{_dbscOptions.RegistrationPath.Value}\";challenge=\"{challenge}\"";
+        httpContext.Response.Headers.Append("Secure-Session-Registration", headerValue);
+    }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs
new file mode 100644
index 000000000000..54425e606ae9
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs
@@ -0,0 +1,35 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Default values for the Device Bound Session Credentials authentication scheme.
+/// 
+public static class DeviceBoundSessionDefaults
+{
+    /// 
+    /// The default authentication scheme name.
+    /// 
+    public const string AuthenticationScheme = "DeviceBoundSession";
+
+    /// 
+    /// The default registration path.
+    /// 
+    public const string RegistrationPath = "/.well-known/dbsc/registration";
+
+    /// 
+    /// The default refresh path.
+    /// 
+    public const string RefreshPath = "/.well-known/dbsc/refresh";
+
+    /// 
+    /// The default cookie name suffix for the session cookie.
+    /// 
+    public const string SessionCookieSuffix = "__dbsc";
+
+    /// 
+    /// The default cookie name suffix for the refresh cookie.
+    /// 
+    public const string RefreshCookieSuffix = "__dbsc_refresh";
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
new file mode 100644
index 000000000000..880698b4cd66
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
@@ -0,0 +1,110 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+using Microsoft.AspNetCore.Http;
+using DbscOptions = Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+/// 
+/// Extension methods to configure Device Bound Session Credentials authentication.
+/// 
+public static class DeviceBoundSessionExtensions
+{
+    /// 
+    /// Adds Device Bound Session Credentials (DBSC) authentication that wraps an existing cookie scheme.
+    /// This sets up: a policy scheme (default auth), a refresh cookie scheme (path-scoped stash),
+    /// a session cookie scheme (short-lived), and the DBSC protocol handler.
+    /// 
+    /// The authentication builder.
+    /// The existing cookie authentication scheme to protect with DBSC (e.g., "Identity.Application").
+    /// Optional action to configure DBSC options.
+    /// The authentication builder for chaining.
+    public static AuthenticationBuilder AddDeviceBoundSession(
+        this AuthenticationBuilder builder,
+        string sourceScheme,
+        Action? configureOptions = null)
+    {
+        return AddDeviceBoundSession(builder, DeviceBoundSessionDefaults.AuthenticationScheme, sourceScheme, configureOptions);
+    }
+
+    /// 
+    /// Adds Device Bound Session Credentials (DBSC) authentication that wraps an existing cookie scheme.
+    /// 
+    /// The authentication builder.
+    /// The DBSC authentication scheme name.
+    /// The existing cookie authentication scheme to protect with DBSC.
+    /// Optional action to configure DBSC options.
+    /// The authentication builder for chaining.
+    public static AuthenticationBuilder AddDeviceBoundSession(
+        this AuthenticationBuilder builder,
+        string authenticationScheme,
+        string sourceScheme,
+        Action? configureOptions = null)
+    {
+        var refreshScheme = $"{sourceScheme}.Dbsc.Refresh";
+        var sessionScheme = $"{sourceScheme}.Dbsc.Session";
+        var policyScheme = $"{sourceScheme}.Dbsc";
+
+        // Add the refresh cookie scheme (path-scoped to /.well-known/dbsc/)
+        builder.AddCookie(refreshScheme, o =>
+        {
+            o.Cookie.Name = $".AspNetCore.{sourceScheme}.Dbsc.Refresh";
+            o.Cookie.Path = "/.well-known/dbsc";
+            o.Cookie.HttpOnly = true;
+            o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
+            o.Cookie.SameSite = SameSiteMode.Lax;
+            // Long-lived — matches original session lifetime
+            o.ExpireTimeSpan = TimeSpan.FromDays(7);
+            o.SlidingExpiration = false;
+        });
+
+        // Add the session cookie scheme (short-lived, path=/)
+        builder.AddCookie(sessionScheme, o =>
+        {
+            o.Cookie.Name = $".AspNetCore.{sourceScheme}.Dbsc.Session";
+            o.Cookie.Path = "/";
+            o.Cookie.HttpOnly = true;
+            o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
+            o.Cookie.SameSite = SameSiteMode.Lax;
+            o.ExpireTimeSpan = TimeSpan.FromSeconds(30);
+            o.SlidingExpiration = false;
+        });
+
+        // Add a policy scheme that tries the session cookie first, then falls back to the source scheme
+        var sessionCookieName = $".AspNetCore.{sourceScheme}.Dbsc.Session";
+        builder.AddPolicyScheme(policyScheme, policyScheme, o =>
+        {
+            o.ForwardDefaultSelector = context =>
+            {
+                // If the short-lived session cookie exists, use it
+                if (context.Request.Cookies.ContainsKey(sessionCookieName))
+                {
+                    return sessionScheme;
+                }
+                // Otherwise fall back to the source cookie (pre-registration or non-DBSC browser)
+                return sourceScheme;
+            };
+        });
+
+        // Add the DBSC protocol handler
+        builder.AddScheme(authenticationScheme, o =>
+        {
+            o.RegistrationSourceScheme = sourceScheme;
+            o.RefreshScheme = refreshScheme;
+            o.SessionScheme = sessionScheme;
+            configureOptions?.Invoke(o);
+        });
+
+        // Set the policy scheme as the default authenticate scheme
+        builder.Services.Configure(o =>
+        {
+            o.DefaultAuthenticateScheme = policyScheme;
+            o.DefaultSignInScheme = sourceScheme;
+        });
+
+        return builder;
+    }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
new file mode 100644
index 000000000000..035283e873e2
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -0,0 +1,336 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Authentication handler that implements the Device Bound Session Credentials (DBSC) protocol.
+/// Handles registration and refresh endpoints, delegating cookie management to separate cookie schemes.
+/// 
+public class DeviceBoundSessionHandler : AuthenticationHandler, IAuthenticationRequestHandler
+{
+    private const string ChallengePurpose = "Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.v1";
+
+    private readonly IDataProtectionProvider _dataProtectionProvider;
+
+    /// 
+    /// Initializes a new instance of .
+    /// 
+    public DeviceBoundSessionHandler(
+        IOptionsMonitor options,
+        ILoggerFactory logger,
+        UrlEncoder encoder,
+        IDataProtectionProvider dataProtectionProvider)
+        : base(options, logger, encoder)
+    {
+        _dataProtectionProvider = dataProtectionProvider;
+    }
+
+    /// 
+    /// The handler does not authenticate normal requests — cookie handlers do that.
+    /// 
+    protected override Task HandleAuthenticateAsync()
+        => Task.FromResult(AuthenticateResult.NoResult());
+
+    /// 
+    /// Handles DBSC registration and refresh requests if the path matches.
+    /// 
+    public async Task HandleRequestAsync()
+    {
+        if (HttpMethods.IsPost(Request.Method))
+        {
+            if (Request.Path.Equals(Options.RegistrationPath))
+            {
+                await HandleRegistrationAsync();
+                return true;
+            }
+
+            if (Request.Path.Equals(Options.RefreshPath))
+            {
+                await HandleRefreshAsync();
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private async Task HandleRegistrationAsync()
+    {
+        // Extract the JWT proof from the Secure-Session-Response header
+        var responseHeader = Request.Headers["Secure-Session-Response"].ToString();
+        if (string.IsNullOrEmpty(responseHeader))
+        {
+            Response.StatusCode = StatusCodes.Status400BadRequest;
+            return;
+        }
+
+        responseHeader = responseHeader.Trim('"');
+
+        // Validate the JWT and extract the public key (registration: no existing key to check against)
+        var jwtResult = DeviceBoundSessionJwtValidator.Validate(responseHeader, publicKeyJwk: null, expectedChallenge: null);
+        if (jwtResult is null)
+        {
+            Logger.LogWarning("DBSC registration: invalid JWT proof.");
+            Response.StatusCode = StatusCodes.Status400BadRequest;
+            return;
+        }
+
+        // Authenticate against the registration source scheme (the long-lived sign-in cookie)
+        var authResult = await Context.AuthenticateAsync(Options.RegistrationSourceScheme);
+        if (!authResult.Succeeded || authResult.Principal is null)
+        {
+            Logger.LogWarning("DBSC registration: no valid authentication from source scheme.");
+            Response.StatusCode = StatusCodes.Status401Unauthorized;
+            return;
+        }
+
+        var principal = authResult.Principal;
+        var properties = authResult.Properties ?? new AuthenticationProperties();
+
+        // Generate a session ID
+        var sessionId = GenerateSessionId();
+
+        // Store public key and session info in properties for the refresh cookie
+        var refreshProperties = new AuthenticationProperties();
+        foreach (var item in properties.Items)
+        {
+            refreshProperties.Items[item.Key] = item.Value;
+        }
+        refreshProperties.Items["DbscPublicKeyJwk"] = jwtResult.PublicKeyJwk;
+        refreshProperties.Items["DbscSessionId"] = sessionId;
+        refreshProperties.Items["DbscAlgorithm"] = jwtResult.Algorithm;
+        refreshProperties.IsPersistent = true;
+
+        // 1. Stamp the refresh cookie (path-scoped stash with ticket + public key)
+        await Context.SignInAsync(Options.RefreshScheme, principal, refreshProperties);
+
+        // 2. Stamp the short-lived session cookie
+        var sessionProperties = new AuthenticationProperties
+        {
+            IsPersistent = true,
+            ExpiresUtc = DateTimeOffset.UtcNow.Add(Options.ShortLivedCookieExpiration),
+            IssuedUtc = DateTimeOffset.UtcNow,
+        };
+        await Context.SignInAsync(Options.SessionScheme, principal, sessionProperties);
+
+        // 3. Delete the long-lived source cookie (exchange complete)
+        await Context.SignOutAsync(Options.RegistrationSourceScheme);
+
+        // Build and return session configuration JSON
+        var config = BuildSessionConfiguration(sessionId);
+
+        Response.StatusCode = StatusCodes.Status200OK;
+        Response.ContentType = "application/json";
+
+        // Include a challenge for the next refresh
+        var challenge = GenerateChallenge(sessionId);
+        Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionId}\"";
+
+        await JsonSerializer.SerializeAsync(Response.Body, config, DeviceBoundSessionJsonContext.Default.DeviceBoundSessionConfiguration, Context.RequestAborted);
+    }
+
+    private async Task HandleRefreshAsync()
+    {
+        // Read session ID from header
+        var sessionIdHeader = Request.Headers["Sec-Secure-Session-Id"].ToString();
+        if (string.IsNullOrEmpty(sessionIdHeader))
+        {
+            Response.StatusCode = StatusCodes.Status400BadRequest;
+            return;
+        }
+
+        sessionIdHeader = sessionIdHeader.Trim('"');
+
+        // Authenticate against the refresh scheme (path-scoped refresh cookie)
+        var authResult = await Context.AuthenticateAsync(Options.RefreshScheme);
+        if (!authResult.Succeeded || authResult.Principal is null)
+        {
+            Logger.LogWarning("DBSC refresh: no valid refresh cookie for session {SessionId}.", sessionIdHeader);
+            Response.StatusCode = StatusCodes.Status401Unauthorized;
+            return;
+        }
+
+        var properties = authResult.Properties;
+
+        // Verify the session ID matches what's in the refresh cookie
+        if (properties is null ||
+            !properties.Items.TryGetValue("DbscSessionId", out var storedSessionId) ||
+            !string.Equals(storedSessionId, sessionIdHeader, StringComparison.Ordinal))
+        {
+            Response.StatusCode = StatusCodes.Status403Forbidden;
+            return;
+        }
+
+        // Get the public key from the refresh cookie
+        if (!properties.Items.TryGetValue("DbscPublicKeyJwk", out var publicKeyJwk) || publicKeyJwk is null)
+        {
+            Response.StatusCode = StatusCodes.Status403Forbidden;
+            return;
+        }
+
+        // Check for the proof JWT
+        var proofHeader = Request.Headers["Secure-Session-Response"].ToString();
+        if (string.IsNullOrEmpty(proofHeader))
+        {
+            // No proof yet — issue a challenge (first leg of refresh)
+            var challenge = GenerateChallenge(sessionIdHeader);
+            Response.StatusCode = StatusCodes.Status403Forbidden;
+            Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionIdHeader}\"";
+            return;
+        }
+
+        proofHeader = proofHeader.Trim('"');
+
+        // Validate the JWT proof against the public key from the refresh cookie
+        var jwtResult = DeviceBoundSessionJwtValidator.Validate(proofHeader, publicKeyJwk, expectedChallenge: null);
+        if (jwtResult is null)
+        {
+            Logger.LogWarning("DBSC refresh: invalid JWT signature for session {SessionId}.", sessionIdHeader);
+            Response.StatusCode = StatusCodes.Status403Forbidden;
+            return;
+        }
+
+        // Validate the challenge (jti) is one we issued and is fresh
+        if (jwtResult.Challenge is not null && !ValidateChallenge(jwtResult.Challenge, sessionIdHeader))
+        {
+            Logger.LogWarning("DBSC refresh: stale or invalid challenge for session {SessionId}.", sessionIdHeader);
+            Response.StatusCode = StatusCodes.Status403Forbidden;
+            return;
+        }
+
+        // Success — stamp a fresh short-lived session cookie
+        var sessionProperties = new AuthenticationProperties
+        {
+            IsPersistent = true,
+            ExpiresUtc = DateTimeOffset.UtcNow.Add(Options.ShortLivedCookieExpiration),
+            IssuedUtc = DateTimeOffset.UtcNow,
+        };
+        await Context.SignInAsync(Options.SessionScheme, authResult.Principal, sessionProperties);
+
+        // Return session configuration with new challenge
+        var config = BuildSessionConfiguration(sessionIdHeader);
+        var nextChallenge = GenerateChallenge(sessionIdHeader);
+
+        Response.StatusCode = StatusCodes.Status200OK;
+        Response.ContentType = "application/json";
+        Response.Headers["Secure-Session-Challenge"] = $"\"{nextChallenge}\";id=\"{sessionIdHeader}\"";
+
+        await JsonSerializer.SerializeAsync(Response.Body, config, DeviceBoundSessionJsonContext.Default.DeviceBoundSessionConfiguration, Context.RequestAborted);
+    }
+
+    private DeviceBoundSessionConfiguration BuildSessionConfiguration(string sessionId)
+    {
+        var origin = $"{Request.Scheme}://{Request.Host}";
+
+        List? scopeRules = null;
+        if (Options.ScopeSpecifications.Count > 0)
+        {
+            scopeRules = Options.ScopeSpecifications.Select(r => new DeviceBoundSessionScopeRuleConfiguration
+            {
+                Type = r.Type,
+                Domain = r.Domain,
+                Path = r.Path,
+            }).ToList();
+        }
+
+        // The credential cookie name is based on the session scheme
+        // We need to resolve it from the cookie options for that scheme
+        var sessionCookieName = ResolveSessionCookieName();
+
+        return new DeviceBoundSessionConfiguration
+        {
+            SessionIdentifier = sessionId,
+            RefreshUrl = Options.RefreshPath.Value,
+            Scope = new DeviceBoundSessionScopeConfiguration
+            {
+                Origin = origin,
+                IncludeSite = Options.IncludeSite,
+                ScopeSpecification = scopeRules,
+            },
+            Credentials = new List
+            {
+                new DeviceBoundSessionCredentialConfiguration
+                {
+                    Type = "cookie",
+                    Name = sessionCookieName,
+                    Attributes = "Secure; HttpOnly; SameSite=Lax; Path=/",
+                }
+            },
+        };
+    }
+
+    private string ResolveSessionCookieName()
+    {
+        // Try to get the cookie name from the session scheme's cookie options
+        var cookieOptionsMonitor = Context.RequestServices.GetService(
+            typeof(IOptionsMonitor)) as IOptionsMonitor;
+        if (cookieOptionsMonitor is not null && Options.SessionScheme is not null)
+        {
+            var cookieOptions = cookieOptionsMonitor.Get(Options.SessionScheme);
+            if (cookieOptions.Cookie.Name is not null)
+            {
+                return cookieOptions.Cookie.Name;
+            }
+        }
+        return ".AspNetCore.Dbsc.Session";
+    }
+
+    private string GenerateChallenge(string sessionId)
+    {
+        var protector = _dataProtectionProvider.CreateProtector(ChallengePurpose);
+        var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+        var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
+        var payload = $"{timestamp}|{nonce}|{sessionId}";
+        return protector.Protect(payload);
+    }
+
+    private bool ValidateChallenge(string challenge, string expectedSessionId)
+    {
+        try
+        {
+            var protector = _dataProtectionProvider.CreateProtector(ChallengePurpose);
+            var payload = protector.Unprotect(challenge);
+            var parts = payload.Split('|', 3);
+            if (parts.Length != 3)
+            {
+                return false;
+            }
+
+            if (!long.TryParse(parts[0], out var timestamp))
+            {
+                return false;
+            }
+
+            var issued = DateTimeOffset.FromUnixTimeSeconds(timestamp);
+            if (DateTimeOffset.UtcNow - issued > Options.ChallengeMaxAge)
+            {
+                return false;
+            }
+
+            return string.Equals(parts[2], expectedSessionId, StringComparison.Ordinal);
+        }
+        catch
+        {
+            return false;
+        }
+    }
+
+    private static string GenerateSessionId()
+    {
+        return Convert.ToBase64String(RandomNumberGenerator.GetBytes(24))
+            .Replace('+', '-')
+            .Replace('/', '_')
+            .TrimEnd('=');
+    }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
new file mode 100644
index 000000000000..cce96e22b29d
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
@@ -0,0 +1,290 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Validates DBSC proof JWTs (typ: "dbsc+jwt") signed with ES256 or RS256.
+/// 
+internal static class DeviceBoundSessionJwtValidator
+{
+    /// 
+    /// Validates a DBSC proof JWT and extracts its claims.
+    /// 
+    /// The raw JWT string.
+    /// The JWK JSON of the public key to validate against. If null, extracts from JWT header.
+    /// The expected challenge value (jti claim).
+    /// The parsed result, or null if validation fails.
+    public static DeviceBoundSessionJwtResult? Validate(string jwt, string? publicKeyJwk, string? expectedChallenge)
+    {
+        var parts = jwt.Split('.');
+        if (parts.Length != 3)
+        {
+            return null;
+        }
+
+        var headerJson = Base64UrlDecode(parts[0]);
+        var payloadJson = Base64UrlDecode(parts[1]);
+        var signature = Base64UrlDecodeBytes(parts[2]);
+
+        if (headerJson is null || payloadJson is null || signature is null)
+        {
+            return null;
+        }
+
+        // Parse header
+        JsonElement header;
+        try
+        {
+            header = JsonDocument.Parse(headerJson).RootElement;
+        }
+        catch (JsonException)
+        {
+            return null;
+        }
+
+        // Validate typ
+        if (!header.TryGetProperty("typ", out var typElement) ||
+            !string.Equals(typElement.GetString(), "dbsc+jwt", StringComparison.OrdinalIgnoreCase))
+        {
+            return null;
+        }
+
+        // Get algorithm
+        if (!header.TryGetProperty("alg", out var algElement))
+        {
+            return null;
+        }
+        var algorithm = algElement.GetString();
+
+        // Get JWK - from parameter or from header (registration)
+        string? jwkJson = publicKeyJwk;
+        if (jwkJson is null)
+        {
+            if (!header.TryGetProperty("jwk", out var jwkElement))
+            {
+                return null;
+            }
+            jwkJson = jwkElement.GetRawText();
+        }
+
+        // Validate signature
+        var signedData = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
+        if (!VerifySignature(algorithm, jwkJson, signedData, signature))
+        {
+            return null;
+        }
+
+        // Parse payload
+        JsonElement payload;
+        try
+        {
+            payload = JsonDocument.Parse(payloadJson).RootElement;
+        }
+        catch (JsonException)
+        {
+            return null;
+        }
+
+        // Validate jti (challenge)
+        string? jti = null;
+        if (payload.TryGetProperty("jti", out var jtiElement))
+        {
+            jti = jtiElement.GetString();
+        }
+
+        if (expectedChallenge is not null && !string.Equals(jti, expectedChallenge, StringComparison.Ordinal))
+        {
+            return null;
+        }
+
+        // Extract authorization claim if present
+        string? authorization = null;
+        if (payload.TryGetProperty("authorization", out var authElement))
+        {
+            authorization = authElement.GetString();
+        }
+
+        return new DeviceBoundSessionJwtResult
+        {
+            Algorithm = algorithm,
+            PublicKeyJwk = jwkJson,
+            Challenge = jti,
+            Authorization = authorization
+        };
+    }
+
+    /// 
+    /// Extracts the JWK from a DBSC registration JWT header without full validation.
+    /// Used during registration when we don't yet have a stored key.
+    /// 
+    public static string? ExtractPublicKeyJwk(string jwt)
+    {
+        var parts = jwt.Split('.');
+        if (parts.Length != 3)
+        {
+            return null;
+        }
+
+        var headerJson = Base64UrlDecode(parts[0]);
+        if (headerJson is null)
+        {
+            return null;
+        }
+
+        try
+        {
+            var header = JsonDocument.Parse(headerJson).RootElement;
+            if (header.TryGetProperty("jwk", out var jwkElement))
+            {
+                return jwkElement.GetRawText();
+            }
+        }
+        catch (JsonException)
+        {
+            // Ignore parse errors
+        }
+
+        return null;
+    }
+
+    private static bool VerifySignature(string? algorithm, string jwkJson, byte[] signedData, byte[] signature)
+    {
+        return algorithm switch
+        {
+            "ES256" => VerifyES256(jwkJson, signedData, signature),
+            "RS256" => VerifyRS256(jwkJson, signedData, signature),
+            _ => false
+        };
+    }
+
+    private static bool VerifyES256(string jwkJson, byte[] signedData, byte[] signature)
+    {
+        try
+        {
+            using var doc = JsonDocument.Parse(jwkJson);
+            var jwk = doc.RootElement;
+
+            if (!jwk.TryGetProperty("kty", out var kty) || kty.GetString() != "EC")
+            {
+                return false;
+            }
+            if (!jwk.TryGetProperty("crv", out var crv) || crv.GetString() != "P-256")
+            {
+                return false;
+            }
+            if (!jwk.TryGetProperty("x", out var xProp) || !jwk.TryGetProperty("y", out var yProp))
+            {
+                return false;
+            }
+
+            var x = Base64UrlDecodeBytes(xProp.GetString()!);
+            var y = Base64UrlDecodeBytes(yProp.GetString()!);
+            if (x is null || y is null)
+            {
+                return false;
+            }
+
+            var parameters = new ECParameters
+            {
+                Curve = ECCurve.NamedCurves.nistP256,
+                Q = new ECPoint { X = x, Y = y }
+            };
+
+            using var ecdsa = ECDsa.Create(parameters);
+            return ecdsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
+                || ecdsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256);
+        }
+        catch (CryptographicException)
+        {
+            return false;
+        }
+        catch (JsonException)
+        {
+            return false;
+        }
+    }
+
+    private static bool VerifyRS256(string jwkJson, byte[] signedData, byte[] signature)
+    {
+        try
+        {
+            using var doc = JsonDocument.Parse(jwkJson);
+            var jwk = doc.RootElement;
+
+            if (!jwk.TryGetProperty("kty", out var kty) || kty.GetString() != "RSA")
+            {
+                return false;
+            }
+            if (!jwk.TryGetProperty("n", out var nProp) || !jwk.TryGetProperty("e", out var eProp))
+            {
+                return false;
+            }
+
+            var n = Base64UrlDecodeBytes(nProp.GetString()!);
+            var e = Base64UrlDecodeBytes(eProp.GetString()!);
+            if (n is null || e is null)
+            {
+                return false;
+            }
+
+            var parameters = new RSAParameters
+            {
+                Modulus = n,
+                Exponent = e
+            };
+
+            using var rsa = RSA.Create(parameters);
+            return rsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+        }
+        catch (CryptographicException)
+        {
+            return false;
+        }
+        catch (JsonException)
+        {
+            return false;
+        }
+    }
+
+    private static string? Base64UrlDecode(string input)
+    {
+        var bytes = Base64UrlDecodeBytes(input);
+        return bytes is null ? null : Encoding.UTF8.GetString(bytes);
+    }
+
+    private static byte[]? Base64UrlDecodeBytes(string input)
+    {
+        // Replace URL-safe characters and add padding
+        var base64 = input.Replace('-', '+').Replace('_', '/');
+        switch (base64.Length % 4)
+        {
+            case 2: base64 += "=="; break;
+            case 3: base64 += "="; break;
+        }
+
+        try
+        {
+            return Convert.FromBase64String(base64);
+        }
+        catch (FormatException)
+        {
+            return null;
+        }
+    }
+}
+
+/// 
+/// Result of validating a DBSC proof JWT.
+/// 
+internal sealed class DeviceBoundSessionJwtResult
+{
+    public required string? Algorithm { get; init; }
+    public required string PublicKeyJwk { get; init; }
+    public string? Challenge { get; init; }
+    public string? Authorization { get; init; }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs
new file mode 100644
index 000000000000..43f39dd1315a
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs
@@ -0,0 +1,70 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Http;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Options for the Device Bound Session Credentials authentication handler.
+/// 
+public class DeviceBoundSessionOptions : AuthenticationSchemeOptions
+{
+    /// 
+    /// Gets or sets the authentication scheme used for initial sign-in (the source of the long-lived cookie).
+    /// During registration, the handler authenticates against this scheme to read the user's ticket.
+    /// 
+    public string? RegistrationSourceScheme { get; set; }
+
+    /// 
+    /// Gets or sets the authentication scheme for the refresh cookie (path-scoped stash).
+    /// During refresh, the handler authenticates against this scheme to retrieve the ticket + public key.
+    /// 
+    public string? RefreshScheme { get; set; }
+
+    /// 
+    /// Gets or sets the authentication scheme used to stamp the short-lived session cookie.
+    /// Both registration and refresh call SignInAsync on this scheme.
+    /// 
+    public string? SessionScheme { get; set; }
+
+    /// 
+    /// Gets or sets the path for the registration endpoint.
+    /// Defaults to /.well-known/dbsc/registration.
+    /// 
+    public PathString RegistrationPath { get; set; } = DeviceBoundSessionDefaults.RegistrationPath;
+
+    /// 
+    /// Gets or sets the path for the refresh endpoint.
+    /// Defaults to /.well-known/dbsc/refresh.
+    /// 
+    public PathString RefreshPath { get; set; } = DeviceBoundSessionDefaults.RefreshPath;
+
+    /// 
+    /// Gets or sets the expiration for the short-lived session cookie.
+    /// Defaults to 30 seconds.
+    /// 
+    public TimeSpan ShortLivedCookieExpiration { get; set; } = TimeSpan.FromSeconds(30);
+
+    /// 
+    /// Gets or sets the maximum age for challenges before they are considered stale.
+    /// Defaults to 5 minutes.
+    /// 
+    public TimeSpan ChallengeMaxAge { get; set; } = TimeSpan.FromMinutes(5);
+
+    /// 
+    /// Gets or sets whether to include the entire site in the session scope.
+    /// Defaults to false (origin-only scope).
+    /// 
+    public bool IncludeSite { get; set; }
+
+    /// 
+    /// Gets the list of scope specifications for the session.
+    /// 
+    public IList ScopeSpecifications { get; } = new List();
+
+    /// 
+    /// Gets the list of allowed refresh initiator host patterns.
+    /// 
+    public IList AllowedRefreshInitiators { get; } = new List();
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRule.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRule.cs
new file mode 100644
index 000000000000..9e4af687eb4f
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRule.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Represents a scope rule for DBSC session configuration.
+/// 
+public class DeviceBoundSessionScopeRule
+{
+    /// 
+    /// Gets or sets the type of scope rule ("include" or "exclude").
+    /// 
+    public string Type { get; set; } = "include";
+
+    /// 
+    /// Gets or sets the domain pattern for this scope rule.
+    /// 
+    public string Domain { get; set; } = "*";
+
+    /// 
+    /// Gets or sets the path for this scope rule.
+    /// 
+    public string Path { get; set; } = "/";
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
new file mode 100644
index 000000000000..09d476650218
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
@@ -0,0 +1,30 @@
+
+
+  
+    ASP.NET Core authentication handler for Device Bound Session Credentials (DBSC).
+    $(DefaultNetCoreTargetFramework)
+    true
+    $(DefineConstants);SECURITY
+    true
+    aspnetcore;authentication;security;dbsc
+    false
+    true
+    enable
+    $(NoWarn);RS0016;RS0026
+  
+
+  
+    
+  
+
+  
+    
+    
+    
+    
+    
+    
+    
+  
+
+
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Shipped.txt b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Shipped.txt
new file mode 100644
index 000000000000..8b137891791f
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
new file mode 100644
index 000000000000..7dc5c58110bf
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
@@ -0,0 +1 @@
+#nullable enable

From abb136cf095a6155ef6ca7dc03eb33bed59b1e01 Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 19:09:04 +0200
Subject: [PATCH 03/33] WIP: Add v2 sample app and register project reference

Sample app demonstrates the new DBSC architecture:
- Source cookie scheme with DeviceBoundSessionCookieEvents
- AddDeviceBoundSession() wires up refresh/session/policy schemes
- Policy scheme fallback verified working

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 eng/ProjectReferences.props                   |   1 +
 .../samples/DbscSampleV2/DbscSampleV2.csproj  |  33 ++++
 .../samples/DbscSampleV2/Program.cs           | 166 ++++++++++++++++++
 .../Properties/launchSettings.json            |   8 +
 4 files changed, 208 insertions(+)
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscSampleV2.csproj
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Properties/launchSettings.json

diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props
index 94121a966bb3..a89f692ecad0 100644
--- a/eng/ProjectReferences.props
+++ b/eng/ProjectReferences.props
@@ -57,6 +57,7 @@
     
     
     
+    
     
     
     
diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscSampleV2.csproj b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscSampleV2.csproj
new file mode 100644
index 000000000000..d9324763149d
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscSampleV2.csproj
@@ -0,0 +1,33 @@
+
+
+  
+    $(DefaultNetCoreTargetFramework)
+    enable
+  
+
+  
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+  
+
+
diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs
new file mode 100644
index 000000000000..820108c52c28
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs
@@ -0,0 +1,166 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace DbscSampleV2;
+
+public static class Program
+{
+    public static Task Main(string[] args)
+    {
+        var host = new HostBuilder()
+            .ConfigureWebHost(webHostBuilder =>
+            {
+                webHostBuilder
+                    .UseKestrel(options =>
+                    {
+                        options.ListenLocalhost(7299, listenOptions =>
+                        {
+                            listenOptions.UseHttps();
+                        });
+                    })
+                    .UseStartup();
+            })
+            .ConfigureLogging(factory =>
+            {
+                factory.AddConsole();
+                factory.AddFilter("Default", LogLevel.Information);
+                factory.AddFilter("Microsoft.AspNetCore.Authentication", LogLevel.Debug);
+            })
+            .Build();
+
+        return host.RunAsync();
+    }
+}
+
+public class Startup
+{
+    private const string SourceScheme = "Application";
+
+    public void ConfigureServices(IServiceCollection services)
+    {
+        services.AddAuthentication()
+            // The source cookie scheme (long-lived sign-in cookie)
+            .AddCookie(SourceScheme, o =>
+            {
+                o.Cookie.Name = ".AspNetCore.Application";
+                o.LoginPath = "/login";
+                o.ExpireTimeSpan = TimeSpan.FromDays(7);
+                // Wire up DBSC event to emit Secure-Session-Registration header on sign-in
+                o.Events = new DeviceBoundSessionCookieEvents(new Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions
+                {
+                    RegistrationPath = DeviceBoundSessionDefaults.RegistrationPath,
+                });
+            })
+            // The DBSC handler + refresh/session cookie schemes + policy scheme
+            .AddDeviceBoundSession(SourceScheme, o =>
+            {
+                o.ShortLivedCookieExpiration = TimeSpan.FromSeconds(30);
+            });
+
+        services.AddRouting();
+    }
+
+    public void Configure(IApplicationBuilder app)
+    {
+        app.UseRouting();
+        app.UseAuthentication();
+
+        app.UseEndpoints(endpoints =>
+        {
+            endpoints.MapGet("/login", async context =>
+            {
+                context.Response.ContentType = "text/html";
+                await context.Response.WriteAsync("""
+                    
+                    
+                    DBSC v2 Sample - Login
+                    
+                        

Device Bound Session Credentials v2 - Test App

+
+ + +
+ + + """); + }); + + endpoints.MapPost("/login", async context => + { + var form = await context.Request.ReadFormAsync(); + var username = form["username"].ToString(); + + if (string.IsNullOrEmpty(username)) + { + context.Response.Redirect("/login"); + return; + } + + var identity = new ClaimsIdentity(SourceScheme); + identity.AddClaim(new Claim(ClaimTypes.Name, username)); + + // Sign in to the SOURCE scheme — this stamps the long-lived cookie + // and emits the Secure-Session-Registration header via the events + await context.SignInAsync( + SourceScheme, + new ClaimsPrincipal(identity), + new AuthenticationProperties { IsPersistent = true }); + + context.Response.Redirect("/"); + }); + + endpoints.MapGet("/", async context => + { + if (context.User.Identity?.IsAuthenticated != true) + { + context.Response.Redirect("/login"); + return; + } + + var userName = context.User.Identity!.Name; + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync( + "DBSC v2" + + $"

Welcome, {userName}!

" + + "

Authenticated with Device Bound Session Credentials (v2 architecture).

" + + "

API endpoint | Sign Out

" + + "

Auto-refresh (every 10s):

" +
+                    "");
+            });
+
+            endpoints.MapGet("/api/time", async context =>
+            {
+                if (context.User.Identity?.IsAuthenticated != true)
+                {
+                    context.Response.StatusCode = 401;
+                    await context.Response.WriteAsync("Unauthorized");
+                    return;
+                }
+                await context.Response.WriteAsync($"Server time: {DateTime.UtcNow:O} | User: {context.User.Identity!.Name}");
+            });
+
+            endpoints.MapGet("/signout", async context =>
+            {
+                // Sign out all schemes
+                await context.SignOutAsync(SourceScheme);
+                context.Response.Redirect("/login");
+            });
+        });
+    }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Properties/launchSettings.json b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Properties/launchSettings.json
new file mode 100644
index 000000000000..84fa2b8ea933
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Properties/launchSettings.json
@@ -0,0 +1,8 @@
+{
+  "profiles": {
+    "DbscSampleV2": {
+      "commandName": "Project",
+      "applicationUrl": "https://localhost:7299"
+    }
+  }
+}

From 3522b4e4cd60adbf6ceb7cb93ea0224d56d0ad1e Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 19:12:57 +0200
Subject: [PATCH 04/33] Add HAR logging middleware to v1 sample (debug tool)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../DbscSample/HarLoggingMiddleware.cs        | 295 ++++++++++++++++++
 .../Cookies/samples/DbscSample/Program.cs     |   5 +
 2 files changed, 300 insertions(+)
 create mode 100644 src/Security/Authentication/Cookies/samples/DbscSample/HarLoggingMiddleware.cs

diff --git a/src/Security/Authentication/Cookies/samples/DbscSample/HarLoggingMiddleware.cs b/src/Security/Authentication/Cookies/samples/DbscSample/HarLoggingMiddleware.cs
new file mode 100644
index 000000000000..10b8a781f6a1
--- /dev/null
+++ b/src/Security/Authentication/Cookies/samples/DbscSample/HarLoggingMiddleware.cs
@@ -0,0 +1,295 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using System.Diagnostics;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Http.Extensions;
+
+namespace DbscSample;
+
+/// 
+/// Middleware that captures all HTTP requests/responses and writes them to a HAR file on disk.
+/// This captures the full DBSC protocol flow including headers that Chrome DevTools may hide.
+/// 
+public sealed class HarLoggingMiddleware
+{
+    private readonly RequestDelegate _next;
+    private readonly string _outputPath;
+    private readonly HarLog _harLog;
+    private readonly object _lock = new();
+    private readonly JsonSerializerOptions _jsonOptions = new()
+    {
+        WriteIndented = true,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+    };
+
+    public HarLoggingMiddleware(RequestDelegate next, string outputPath)
+    {
+        _next = next;
+        _outputPath = outputPath;
+        _harLog = new HarLog
+        {
+            Log = new HarLogContent
+            {
+                Version = "1.2",
+                Creator = new HarCreator { Name = "DbscSample", Version = "1.0" },
+                Entries = [],
+            }
+        };
+    }
+
+    public async Task InvokeAsync(HttpContext context)
+    {
+        var startTime = DateTimeOffset.UtcNow;
+        var stopwatch = Stopwatch.StartNew();
+
+        // Capture request
+        var requestHeaders = CaptureHeaders(context.Request.Headers);
+        var requestBody = await CaptureRequestBodyAsync(context);
+
+        // Buffer the response body
+        var originalBodyStream = context.Response.Body;
+        using var responseBodyStream = new MemoryStream();
+        context.Response.Body = responseBodyStream;
+
+        try
+        {
+            await _next(context);
+        }
+        finally
+        {
+            stopwatch.Stop();
+            context.Response.Body = originalBodyStream;
+
+            // Read captured response body
+            responseBodyStream.Seek(0, SeekOrigin.Begin);
+            var responseBodyBytes = responseBodyStream.ToArray();
+            var responseBody = Encoding.UTF8.GetString(responseBodyBytes);
+
+            // Write response body to client
+            if (responseBodyBytes.Length > 0)
+            {
+                await originalBodyStream.WriteAsync(responseBodyBytes);
+            }
+
+            // Capture response headers (after response has been written)
+            var responseHeaders = CaptureHeaders(context.Response.Headers);
+
+            var entry = new HarEntry
+            {
+                StartedDateTime = startTime.ToString("O"),
+                Time = stopwatch.Elapsed.TotalMilliseconds,
+                Request = new HarRequest
+                {
+                    Method = context.Request.Method,
+                    Url = context.Request.GetDisplayUrl(),
+                    HttpVersion = context.Request.Protocol,
+                    Headers = requestHeaders,
+                    HeadersSize = -1,
+                    BodySize = requestBody?.Length ?? 0,
+                    PostData = requestBody is not null ? new HarPostData
+                    {
+                        MimeType = context.Request.ContentType ?? "",
+                        Text = requestBody,
+                    } : null,
+                },
+                Response = new HarResponse
+                {
+                    Status = context.Response.StatusCode,
+                    StatusText = "",
+                    HttpVersion = context.Request.Protocol,
+                    Headers = responseHeaders,
+                    HeadersSize = -1,
+                    BodySize = responseBodyBytes.Length,
+                    Content = new HarContent
+                    {
+                        Size = responseBodyBytes.Length,
+                        MimeType = context.Response.ContentType ?? "",
+                        Text = responseBody,
+                    },
+                },
+                Timings = new HarTimings
+                {
+                    Send = 0,
+                    Wait = stopwatch.Elapsed.TotalMilliseconds,
+                    Receive = 0,
+                },
+            };
+
+            lock (_lock)
+            {
+                _harLog.Log.Entries.Add(entry);
+                var json = JsonSerializer.Serialize(_harLog, _jsonOptions);
+                File.WriteAllText(_outputPath, json);
+            }
+        }
+    }
+
+    private static List CaptureHeaders(IHeaderDictionary headers)
+    {
+        var result = new List();
+        foreach (var header in headers)
+        {
+            foreach (var value in header.Value)
+            {
+                result.Add(new HarHeader { Name = header.Key, Value = value ?? "" });
+            }
+        }
+        return result;
+    }
+
+    private static async Task CaptureRequestBodyAsync(HttpContext context)
+    {
+        if (context.Request.ContentLength is null or 0)
+        {
+            return null;
+        }
+
+        context.Request.EnableBuffering();
+        using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true);
+        var body = await reader.ReadToEndAsync();
+        context.Request.Body.Position = 0;
+        return body;
+    }
+}
+
+// HAR format models
+public sealed class HarLog
+{
+    [JsonPropertyName("log")]
+    public HarLogContent Log { get; set; } = null!;
+}
+
+public sealed class HarLogContent
+{
+    [JsonPropertyName("version")]
+    public string Version { get; set; } = "1.2";
+
+    [JsonPropertyName("creator")]
+    public HarCreator Creator { get; set; } = null!;
+
+    [JsonPropertyName("entries")]
+    public List Entries { get; set; } = [];
+}
+
+public sealed class HarCreator
+{
+    [JsonPropertyName("name")]
+    public string Name { get; set; } = "";
+
+    [JsonPropertyName("version")]
+    public string Version { get; set; } = "";
+}
+
+public sealed class HarEntry
+{
+    [JsonPropertyName("startedDateTime")]
+    public string StartedDateTime { get; set; } = "";
+
+    [JsonPropertyName("time")]
+    public double Time { get; set; }
+
+    [JsonPropertyName("request")]
+    public HarRequest Request { get; set; } = null!;
+
+    [JsonPropertyName("response")]
+    public HarResponse Response { get; set; } = null!;
+
+    [JsonPropertyName("timings")]
+    public HarTimings Timings { get; set; } = null!;
+}
+
+public sealed class HarRequest
+{
+    [JsonPropertyName("method")]
+    public string Method { get; set; } = "";
+
+    [JsonPropertyName("url")]
+    public string Url { get; set; } = "";
+
+    [JsonPropertyName("httpVersion")]
+    public string HttpVersion { get; set; } = "";
+
+    [JsonPropertyName("headers")]
+    public List Headers { get; set; } = [];
+
+    [JsonPropertyName("headersSize")]
+    public int HeadersSize { get; set; }
+
+    [JsonPropertyName("bodySize")]
+    public int BodySize { get; set; }
+
+    [JsonPropertyName("postData")]
+    public HarPostData? PostData { get; set; }
+}
+
+public sealed class HarResponse
+{
+    [JsonPropertyName("status")]
+    public int Status { get; set; }
+
+    [JsonPropertyName("statusText")]
+    public string StatusText { get; set; } = "";
+
+    [JsonPropertyName("httpVersion")]
+    public string HttpVersion { get; set; } = "";
+
+    [JsonPropertyName("headers")]
+    public List Headers { get; set; } = [];
+
+    [JsonPropertyName("headersSize")]
+    public int HeadersSize { get; set; }
+
+    [JsonPropertyName("bodySize")]
+    public int BodySize { get; set; }
+
+    [JsonPropertyName("content")]
+    public HarContent Content { get; set; } = null!;
+}
+
+public sealed class HarHeader
+{
+    [JsonPropertyName("name")]
+    public string Name { get; set; } = "";
+
+    [JsonPropertyName("value")]
+    public string Value { get; set; } = "";
+}
+
+public sealed class HarPostData
+{
+    [JsonPropertyName("mimeType")]
+    public string MimeType { get; set; } = "";
+
+    [JsonPropertyName("text")]
+    public string Text { get; set; } = "";
+}
+
+public sealed class HarContent
+{
+    [JsonPropertyName("size")]
+    public int Size { get; set; }
+
+    [JsonPropertyName("mimeType")]
+    public string MimeType { get; set; } = "";
+
+    [JsonPropertyName("text")]
+    public string? Text { get; set; }
+}
+
+public sealed class HarTimings
+{
+    [JsonPropertyName("send")]
+    public double Send { get; set; }
+
+    [JsonPropertyName("wait")]
+    public double Wait { get; set; }
+
+    [JsonPropertyName("receive")]
+    public double Receive { get; set; }
+}
diff --git a/src/Security/Authentication/Cookies/samples/DbscSample/Program.cs b/src/Security/Authentication/Cookies/samples/DbscSample/Program.cs
index dfaefd59640d..51c33875766a 100644
--- a/src/Security/Authentication/Cookies/samples/DbscSample/Program.cs
+++ b/src/Security/Authentication/Cookies/samples/DbscSample/Program.cs
@@ -80,6 +80,11 @@ public void ConfigureServices(IServiceCollection services)
 
     public void Configure(IApplicationBuilder app)
     {
+        // Write all HTTP traffic to a HAR file for inspection
+        var harPath = Path.Combine(AppContext.BaseDirectory, "dbsc-traffic.har");
+        Console.WriteLine($"[HAR] Writing traffic to: {harPath}");
+        app.UseMiddleware(harPath);
+
         app.UseHttpLogging();
         app.UseRouting();
         app.UseAuthentication();

From 1d8f73a09ec11dfa8c4dbce25660c7de502af109 Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 19:43:09 +0200
Subject: [PATCH 05/33] Pseudo working

---
 .../DeviceBoundSessions/plans/dbsc-design.md  | 323 ++++++++++++++++++
 .../DbscSampleV2/HarLoggingMiddleware.cs      | 295 ++++++++++++++++
 .../samples/DbscSampleV2/Program.cs           |   5 +
 3 files changed, 623 insertions(+)
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/HarLoggingMiddleware.cs

diff --git a/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md b/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
new file mode 100644
index 000000000000..76fd5ec3940d
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
@@ -0,0 +1,323 @@
+# Device Bound Session Credentials (DBSC) for ASP.NET Core
+
+## Summary
+
+Add support for [Device Bound Session Credentials (DBSC)](https://w3c.github.io/webappsec-dbsc/) to ASP.NET Core's authentication system. DBSC binds session cookies to a device using TPM-backed cryptographic keys, preventing session hijacking even if cookies are exfiltrated by malware.
+
+The implementation introduces a new `DeviceBoundSession` authentication handler that manages the DBSC protocol (registration, refresh, key proof validation) and delegates cookie management to existing cookie authentication handlers — following the same architectural pattern as OpenID Connect.
+
+## Motivation
+
+Cookie-based sessions are the most common authentication mechanism on the web. Their primary vulnerability is **cookie theft** — malware with access to the file system or browser memory can exfiltrate cookies and use them on another device.
+
+DBSC solves this by introducing a cryptographic key pair per session, where the private key is stored in secure hardware (TPM). The server issues short-lived cookies that must be periodically refreshed by proving possession of the private key. Even if the cookie is stolen, the attacker cannot refresh it without the TPM-bound key.
+
+Chrome 135+ ships DBSC support. This proposal integrates DBSC into ASP.NET Core so that applications can opt in with minimal code changes.
+
+## Goals
+
+- Provide a DBSC authentication handler that manages the registration/refresh protocol
+- Follow the established pattern of remote authentication handlers (like OpenID Connect) that delegate cookie management to a separate cookie scheme
+- Support fully stateless operation (no server-side session store required)
+- Ensure non-DBSC browsers continue to work normally (graceful degradation)
+- Make integration with ASP.NET Core Identity straightforward
+
+## Non-goals
+
+- **Federated DBSC sessions** (cross-site key sharing via Session Provider) — deferred to a future version
+- **Server-side session revocation store** — optional, not required for baseline operation
+- **Custom signing algorithms beyond ES256/RS256** — only the algorithms Chrome currently supports
+
+## Detailed Design
+
+### Architectural Parallel with OpenID Connect
+
+The DBSC handler follows the same delegation pattern as OpenID Connect:
+
+| Concept | OpenID Connect | DBSC |
+|---------|---------------|------|
+| Protocol handler | `OpenIdConnectHandler` | `DeviceBoundSessionHandler` |
+| Protocol dance | Authorization code flow | Registration + JWT proof |
+| Credential stamping | `SignInAsync(SignInScheme)` | `SignInAsync(SessionScheme)` |
+| Cookie storage | Cookie handler for `.AspNetCore.Cookies` | Cookie handler for `.Session` cookie |
+| Trigger | HTTP redirect to IdP | Browser-initiated POST after seeing `Secure-Session-Registration` header |
+
+**Why a separate handler instead of embedding in cookie middleware:**
+
+1. **Separation of concerns** — Cookie auth handles reading/writing cookies. DBSC handles a JWT-based cryptographic protocol. These are distinct responsibilities.
+2. **JWT dependency** — The cookie authentication package has zero JWT dependencies today. DBSC requires JWT parsing and signature validation (ES256/RS256).
+3. **Independent evolution** — The DBSC spec is still a W3C draft. A separate handler can evolve without affecting the stable cookie auth package.
+4. **Composability** — The handler can delegate to any cookie scheme, just like OIDC can sign into any cookie scheme.
+5. **Testability** — The protocol logic can be tested independently from cookie management.
+
+### Scheme Architecture
+
+```mermaid
+graph TD
+    subgraph "Authentication Schemes"
+        PS["Policy Scheme
(Default Authenticate)
Identity.Application.Dbsc"] + IS["Identity Sign-In Scheme
(Cookie Handler)
Identity.Application"] + RS["Refresh Scheme
(Cookie Handler)
Identity.Application.Dbsc.Refresh"] + SS["Session Scheme
(Cookie Handler)
Identity.Application.Dbsc.Session"] + DH["DBSC Handler
(Protocol Handler)
DeviceBoundSession"] + end + + PS -->|"Try first"| SS + PS -->|"Fallback"| IS + + DH -->|"Registration: reads from"| IS + DH -->|"Registration: stamps"| RS + DH -->|"Registration: stamps"| SS + DH -->|"Registration: deletes"| IS + DH -->|"Refresh: reads from"| RS + DH -->|"Refresh: stamps"| SS +``` + +| Scheme | Cookie Name | Path | Lifetime | Role | +|--------|-------------|------|----------|------| +| `Identity.Application` | `.AspNetCore.Identity.Application` | `/` | Long (days) | Initial sign-in. Deleted after DBSC registration. | +| `Identity.Application.Dbsc.Refresh` | `.AspNetCore.Identity.Application.Dbsc.Refresh` | `/.well-known/dbsc/` | Long (days) | Stash — holds data-protected ticket + public key. Only sent to refresh endpoint. | +| `Identity.Application.Dbsc.Session` | `.AspNetCore.Identity.Application.Dbsc.Session` | `/` | Short (30s) | Active session cookie. Used for authentication on all requests. | +| Policy Scheme | — | — | — | Routes authentication: tries `.Session` first, falls back to `Identity.Application`. | + +### Protocol Flow + +```mermaid +sequenceDiagram + participant User + participant Browser as Chrome (DBSC) + participant App as ASP.NET Core + participant Identity as Identity.Application
(Cookie Handler) + participant DBSC as DBSC Handler + participant Refresh as Refresh Scheme
(Cookie Handler) + participant Session as Session Scheme
(Cookie Handler) + + User->>Browser: Submit login form + Browser->>App: POST /login + App->>Identity: SignInAsync(principal) + Identity-->>Browser: Set-Cookie: .Identity.Application (long-lived)
Secure-Session-Registration header + Browser->>Browser: Generate key pair (TPM) + + Note over Browser,App: Registration (async, non-blocking) + Browser->>App: POST /.well-known/dbsc/registration
Header: Secure-Session-Response (JWT with public key) + App->>DBSC: HandleRegistration + DBSC->>Identity: AuthenticateAsync() → read long-lived cookie + DBSC->>DBSC: Validate JWT, extract public key + DBSC->>Refresh: SignInAsync(principal + public key) → stamp refresh cookie + DBSC->>Session: SignInAsync(principal) → stamp short-lived cookie + DBSC->>Identity: SignOutAsync() → delete long-lived cookie + DBSC-->>Browser: 200 + JSON session config + Secure-Session-Challenge + + Note over Browser,App: Normal requests (short-lived cookie only) + Browser->>App: GET /api/data
Cookie: .Identity.Application.Dbsc.Session + App->>Session: AuthenticateAsync() → read session cookie + Session-->>App: Principal ✓ + + Note over Browser,App: Refresh (when session cookie expires) + Browser->>App: POST /.well-known/dbsc/refresh
Header: Sec-Secure-Session-Id + App->>DBSC: HandleRefresh (no proof yet) + DBSC->>Refresh: AuthenticateAsync() → read refresh cookie (has public key) + DBSC-->>Browser: 403 + Secure-Session-Challenge + + Browser->>Browser: Sign challenge with TPM key + Browser->>App: POST /.well-known/dbsc/refresh
Headers: Sec-Secure-Session-Id + Secure-Session-Response (signed JWT) + App->>DBSC: HandleRefresh (with proof) + DBSC->>Refresh: AuthenticateAsync() → get public key + DBSC->>DBSC: Validate JWT signature against public key + DBSC->>Session: SignInAsync(principal) → stamp fresh short-lived cookie + DBSC-->>Browser: 200 + JSON config + Secure-Session-Challenge (for next refresh) + + Note over Browser,App: Chrome resumes the original deferred request + Browser->>App: GET /api/data (with fresh session cookie) + App-->>Browser: 200 OK +``` + +### Cookie Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> SignedIn: User logs in + SignedIn --> Registered: DBSC registration completes + + state SignedIn { + [*] --> LongLived + LongLived: .Identity.Application cookie
(path=/, long expiry) + note right of LongLived: Policy scheme falls back here + } + + state Registered { + [*] --> Active + Active: .Session cookie (path=/, 30s expiry)
.Refresh cookie (path=/.well-known/dbsc/, long expiry) + Active --> Expired: Session cookie expires + Expired --> Active: Refresh succeeds + Expired --> [*]: Refresh fails → re-login + note right of Active: Long-lived cookie deleted
Policy scheme uses .Session + } +``` + +### Security Properties + +```mermaid +graph LR + subgraph "At Rest (on disk)" + RC["Refresh Cookie
path=/.well-known/dbsc/
✓ Useless without TPM key"] + SC["Session Cookie
path=/
✓ Expires in 30s"] + end + subgraph "In Transit (normal requests)" + SC2["Only Session Cookie sent
✓ Short-lived"] + end + subgraph "In Transit (refresh)" + RC2["Refresh Cookie sent
✓ Requires JWT proof"] + end + + style RC fill:#e8f5e9 + style SC fill:#e8f5e9 + style SC2 fill:#e8f5e9 + style RC2 fill:#e8f5e9 +``` + +| Threat | Mitigation | +|--------|-----------| +| Malware steals session cookie | Expires in ≤30s. Attacker has a tiny window. | +| Malware steals refresh cookie | Useless — refresh endpoint requires JWT proof signed with TPM-bound private key. | +| Malware steals both cookies | Session cookie expires quickly. Refresh cookie can't be used remotely. | +| Long-lived cookie exfiltration | Deleted after registration. Only exists briefly during sign-in → registration gap. | +| Refresh endpoint is down | User must re-login. No stealable long-lived token persists. | +| Non-DBSC browser | Falls back to long-lived cookie via policy scheme. Same security as today (no regression). | + +### Policy Scheme (Fallback Behavior) + +The policy scheme handles the transition between pre-registration (long-lived cookie) and post-registration (session cookie): + +```csharp +.AddPolicyScheme("Identity.Application.Dbsc", options => +{ + options.ForwardDefaultSelector = context => + { + // If the short-lived session cookie exists, authenticate with it + if (context.Request.Cookies.ContainsKey(sessionCookieName)) + return "Identity.Application.Dbsc.Session"; + + // Otherwise fall back to the long-lived cookie + // (pre-registration gap, or non-DBSC browser) + return "Identity.Application"; + }; +}); +``` + +This ensures: +- **Non-DBSC browsers**: Continue using the long-lived cookie forever (no regression) +- **Pre-registration gap**: Long-lived cookie covers the user until registration completes +- **Post-registration**: Session cookie is the active credential; long-lived cookie is deleted + +### Refresh Cookie as "Stash" + +The refresh cookie stores the authentication ticket + public key + session metadata, data-protected and path-scoped: + +``` +Refresh Cookie Value = DataProtect( + session_id_length | session_id | + algorithm_length | algorithm | + public_key_length | public_key_jwk | + ticket_bytes +) +``` + +- **Path-scoped** to `/.well-known/dbsc/` → never sent on normal requests +- **Data-protected** → opaque to the browser and any code that doesn't have the server's keys +- **Long-lived** → matches the original ticket's intended lifetime +- **Stateless** → no server-side store needed; the cookie IS the store + +### Stateless Challenges + +Challenges use the same self-contained pattern: + +``` +Challenge = DataProtect(timestamp | nonce | session_id) +``` + +The server validates a challenge by unprotecting it and checking: +1. The timestamp is within the allowed window (e.g., 5 minutes) +2. The session ID matches the requesting session + +No server-side challenge storage is needed. + +### Integration with ASP.NET Core Identity + +```csharp +// In Program.cs or Startup: +builder.Services.AddAuthentication() + .AddIdentityCookies() + .AddDeviceBoundSession(options => + { + options.RegistrationSourceScheme = IdentityConstants.ApplicationScheme; + options.RefreshSourceScheme = "Identity.Application.Dbsc.Refresh"; + options.SignInScheme = "Identity.Application.Dbsc.Session"; + options.ShortLivedCookieExpiration = TimeSpan.FromSeconds(30); + }); + +// Or a convenience helper: +builder.Services.AddIdentity() + .AddDeviceBoundSessions(); // wires up all schemes automatically +``` + +### Handler Implementation Sketch + +```csharp +public class DeviceBoundSessionHandler : AuthenticationHandler +{ + // Not used for normal request authentication — the cookie handlers do that. + protected override Task HandleAuthenticateAsync() + => Task.FromResult(AuthenticateResult.NoResult()); + + // Registration and refresh are handled as middleware-style endpoint matching + // within the handler, similar to how RemoteAuthenticationHandler handles callbacks. +} +``` + +The handler uses the `RemoteAuthenticationHandler` pattern: +- It doesn't authenticate normal requests (returns `NoResult`) +- It intercepts specific paths (registration, refresh) and handles the protocol +- On success, it delegates to cookie handlers via `SignInAsync` + +## Risks + +- **Header size for `Secure-Session-Challenge`**: Challenges are data-protected blobs (~200 bytes). Well within HTTP header limits. +- **Refresh cookie size**: Contains a full authentication ticket (~500-1500 bytes base64). Within the 4KB per-cookie limit for typical claims sets. Large claims sets may need the existing cookie chunking mechanism. +- **Chrome spec changes**: DBSC is still a W3C draft. The separate handler/package approach makes it easier to adapt to spec changes without impacting stable cookie auth code. +- **TPM rate limiting**: Chrome batches/deduplicates refresh requests. With 30s cookie expiry, TPM signing happens at most once per 30s per session — well within hardware limits. + +## Drawbacks + +- Adds complexity to the authentication scheme graph (policy scheme + 3 cookie schemes + DBSC handler) +- Non-DBSC browsers retain the long-lived cookie (no security improvement for them) +- If the refresh endpoint is unavailable, DBSC users lose their session and must re-login + +## Considered Alternatives + +### Embedding DBSC in the Cookie Authentication Handler + +Rejected because: +- Adds JWT dependencies to the cookie auth package +- Mixes two distinct responsibilities (cookie management vs. cryptographic protocol) +- Makes the cookie handler harder to maintain and test +- Prevents independent evolution of DBSC support + +### Using the long-lived cookie for both normal requests and refresh + +Rejected because: +- If the long-lived cookie is sent on every request, stealing it defeats the purpose of DBSC +- The whole point is that the "stealable" tokens are short-lived + +### Keeping the long-lived cookie as fallback (Chrome's "Alternative integration pattern") + +Rejected for the strict security mode because: +- A persistent long-lived cookie at `path=/` is exactly what DBSC is trying to eliminate +- Acceptable as an opt-in configuration for sites that prioritize availability over maximum security + +### Stashing ticket data in the session ID or challenge header + +Rejected because: +- Both appear in HTTP headers with size constraints +- Path-scoped refresh cookie provides ample space (4KB) without header size concerns +- Cookie infrastructure (chunking, data protection) already exists in ASP.NET Core diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/HarLoggingMiddleware.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/HarLoggingMiddleware.cs new file mode 100644 index 000000000000..2a2a9ff5b1f9 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/HarLoggingMiddleware.cs @@ -0,0 +1,295 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; + +namespace DbscSampleV2; + +/// +/// Middleware that captures all HTTP requests/responses and writes them to a HAR file on disk. +/// This captures the full DBSC protocol flow including headers that Chrome DevTools may hide. +/// +public sealed class HarLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly string _outputPath; + private readonly HarLog _harLog; + private readonly object _lock = new(); + private readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public HarLoggingMiddleware(RequestDelegate next, string outputPath) + { + _next = next; + _outputPath = outputPath; + _harLog = new HarLog + { + Log = new HarLogContent + { + Version = "1.2", + Creator = new HarCreator { Name = "DbscSample", Version = "1.0" }, + Entries = [], + } + }; + } + + public async Task InvokeAsync(HttpContext context) + { + var startTime = DateTimeOffset.UtcNow; + var stopwatch = Stopwatch.StartNew(); + + // Capture request + var requestHeaders = CaptureHeaders(context.Request.Headers); + var requestBody = await CaptureRequestBodyAsync(context); + + // Buffer the response body + var originalBodyStream = context.Response.Body; + using var responseBodyStream = new MemoryStream(); + context.Response.Body = responseBodyStream; + + try + { + await _next(context); + } + finally + { + stopwatch.Stop(); + context.Response.Body = originalBodyStream; + + // Read captured response body + responseBodyStream.Seek(0, SeekOrigin.Begin); + var responseBodyBytes = responseBodyStream.ToArray(); + var responseBody = Encoding.UTF8.GetString(responseBodyBytes); + + // Write response body to client + if (responseBodyBytes.Length > 0) + { + await originalBodyStream.WriteAsync(responseBodyBytes); + } + + // Capture response headers (after response has been written) + var responseHeaders = CaptureHeaders(context.Response.Headers); + + var entry = new HarEntry + { + StartedDateTime = startTime.ToString("O"), + Time = stopwatch.Elapsed.TotalMilliseconds, + Request = new HarRequest + { + Method = context.Request.Method, + Url = context.Request.GetDisplayUrl(), + HttpVersion = context.Request.Protocol, + Headers = requestHeaders, + HeadersSize = -1, + BodySize = requestBody?.Length ?? 0, + PostData = requestBody is not null ? new HarPostData + { + MimeType = context.Request.ContentType ?? "", + Text = requestBody, + } : null, + }, + Response = new HarResponse + { + Status = context.Response.StatusCode, + StatusText = "", + HttpVersion = context.Request.Protocol, + Headers = responseHeaders, + HeadersSize = -1, + BodySize = responseBodyBytes.Length, + Content = new HarContent + { + Size = responseBodyBytes.Length, + MimeType = context.Response.ContentType ?? "", + Text = responseBody, + }, + }, + Timings = new HarTimings + { + Send = 0, + Wait = stopwatch.Elapsed.TotalMilliseconds, + Receive = 0, + }, + }; + + lock (_lock) + { + _harLog.Log.Entries.Add(entry); + var json = JsonSerializer.Serialize(_harLog, _jsonOptions); + File.WriteAllText(_outputPath, json); + } + } + } + + private static List CaptureHeaders(IHeaderDictionary headers) + { + var result = new List(); + foreach (var header in headers) + { + foreach (var value in header.Value) + { + result.Add(new HarHeader { Name = header.Key, Value = value ?? "" }); + } + } + return result; + } + + private static async Task CaptureRequestBodyAsync(HttpContext context) + { + if (context.Request.ContentLength is null or 0) + { + return null; + } + + context.Request.EnableBuffering(); + using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + context.Request.Body.Position = 0; + return body; + } +} + +// HAR format models +public sealed class HarLog +{ + [JsonPropertyName("log")] + public HarLogContent Log { get; set; } = null!; +} + +public sealed class HarLogContent +{ + [JsonPropertyName("version")] + public string Version { get; set; } = "1.2"; + + [JsonPropertyName("creator")] + public HarCreator Creator { get; set; } = null!; + + [JsonPropertyName("entries")] + public List Entries { get; set; } = []; +} + +public sealed class HarCreator +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("version")] + public string Version { get; set; } = ""; +} + +public sealed class HarEntry +{ + [JsonPropertyName("startedDateTime")] + public string StartedDateTime { get; set; } = ""; + + [JsonPropertyName("time")] + public double Time { get; set; } + + [JsonPropertyName("request")] + public HarRequest Request { get; set; } = null!; + + [JsonPropertyName("response")] + public HarResponse Response { get; set; } = null!; + + [JsonPropertyName("timings")] + public HarTimings Timings { get; set; } = null!; +} + +public sealed class HarRequest +{ + [JsonPropertyName("method")] + public string Method { get; set; } = ""; + + [JsonPropertyName("url")] + public string Url { get; set; } = ""; + + [JsonPropertyName("httpVersion")] + public string HttpVersion { get; set; } = ""; + + [JsonPropertyName("headers")] + public List Headers { get; set; } = []; + + [JsonPropertyName("headersSize")] + public int HeadersSize { get; set; } + + [JsonPropertyName("bodySize")] + public int BodySize { get; set; } + + [JsonPropertyName("postData")] + public HarPostData? PostData { get; set; } +} + +public sealed class HarResponse +{ + [JsonPropertyName("status")] + public int Status { get; set; } + + [JsonPropertyName("statusText")] + public string StatusText { get; set; } = ""; + + [JsonPropertyName("httpVersion")] + public string HttpVersion { get; set; } = ""; + + [JsonPropertyName("headers")] + public List Headers { get; set; } = []; + + [JsonPropertyName("headersSize")] + public int HeadersSize { get; set; } + + [JsonPropertyName("bodySize")] + public int BodySize { get; set; } + + [JsonPropertyName("content")] + public HarContent Content { get; set; } = null!; +} + +public sealed class HarHeader +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("value")] + public string Value { get; set; } = ""; +} + +public sealed class HarPostData +{ + [JsonPropertyName("mimeType")] + public string MimeType { get; set; } = ""; + + [JsonPropertyName("text")] + public string Text { get; set; } = ""; +} + +public sealed class HarContent +{ + [JsonPropertyName("size")] + public int Size { get; set; } + + [JsonPropertyName("mimeType")] + public string MimeType { get; set; } = ""; + + [JsonPropertyName("text")] + public string? Text { get; set; } +} + +public sealed class HarTimings +{ + [JsonPropertyName("send")] + public double Send { get; set; } + + [JsonPropertyName("wait")] + public double Wait { get; set; } + + [JsonPropertyName("receive")] + public double Receive { get; set; } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs index 820108c52c28..18335a6952ee 100644 --- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs @@ -73,6 +73,11 @@ public void ConfigureServices(IServiceCollection services) public void Configure(IApplicationBuilder app) { + // Write all HTTP traffic to a HAR file + var harPath = Path.Combine(AppContext.BaseDirectory, "dbsc-v2-traffic.har"); + Console.WriteLine($"[HAR] Writing traffic to: {harPath}"); + app.UseMiddleware(harPath); + app.UseRouting(); app.UseAuthentication(); From f7d40087dd7b4118e2970ba984bbd93d2f4e0b16 Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 4 Jun 2026 19:50:30 +0200 Subject: [PATCH 06/33] Remove v1 DBSC implementation from Cookies project The v1 approach embedded DBSC directly in the cookie authentication handler. This has been replaced by the v2 architecture in the separate DeviceBoundSessions project which uses the OAuth/OIDC delegation pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../samples/DbscSample/DbscSample.csproj | 32 -- .../DbscSample/HarLoggingMiddleware.cs | 295 ---------- .../Cookies/samples/DbscSample/Program.cs | 220 -------- .../DbscSample/Properties/launchSettings.json | 8 - .../src/CookieAuthenticationHandler.cs | 18 - .../src/CookieAuthenticationOptions.cs | 18 - .../DeviceBoundSessionChallengeGenerator.cs | 81 --- .../src/DeviceBoundSessionConfiguration.cs | 132 ----- .../src/DeviceBoundSessionExtensions.cs | 40 -- .../src/DeviceBoundSessionJsonContext.cs | 11 - .../src/DeviceBoundSessionJwtValidator.cs | 290 ---------- .../src/DeviceBoundSessionMiddleware.cs | 385 ------------- .../Cookies/src/DeviceBoundSessionOptions.cs | 112 ---- .../src/DeviceBoundSessionScopeRule.cs | 28 - .../Cookies/src/IDeviceBoundSessionStore.cs | 50 -- .../Cookies/src/PublicAPI.Unshipped.txt | 76 --- .../test/DeviceBoundSessionTests.cs | 521 ------------------ 17 files changed, 2317 deletions(-) delete mode 100644 src/Security/Authentication/Cookies/samples/DbscSample/DbscSample.csproj delete mode 100644 src/Security/Authentication/Cookies/samples/DbscSample/HarLoggingMiddleware.cs delete mode 100644 src/Security/Authentication/Cookies/samples/DbscSample/Program.cs delete mode 100644 src/Security/Authentication/Cookies/samples/DbscSample/Properties/launchSettings.json delete mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionChallengeGenerator.cs delete mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionConfiguration.cs delete mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionExtensions.cs delete mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionJsonContext.cs delete mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionJwtValidator.cs delete mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionMiddleware.cs delete mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionOptions.cs delete mode 100644 src/Security/Authentication/Cookies/src/DeviceBoundSessionScopeRule.cs delete mode 100644 src/Security/Authentication/Cookies/src/IDeviceBoundSessionStore.cs delete mode 100644 src/Security/Authentication/test/DeviceBoundSessionTests.cs diff --git a/src/Security/Authentication/Cookies/samples/DbscSample/DbscSample.csproj b/src/Security/Authentication/Cookies/samples/DbscSample/DbscSample.csproj deleted file mode 100644 index 908af9896fbb..000000000000 --- a/src/Security/Authentication/Cookies/samples/DbscSample/DbscSample.csproj +++ /dev/null @@ -1,32 +0,0 @@ - - - - $(DefaultNetCoreTargetFramework) - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/Security/Authentication/Cookies/samples/DbscSample/HarLoggingMiddleware.cs b/src/Security/Authentication/Cookies/samples/DbscSample/HarLoggingMiddleware.cs deleted file mode 100644 index 10b8a781f6a1..000000000000 --- a/src/Security/Authentication/Cookies/samples/DbscSample/HarLoggingMiddleware.cs +++ /dev/null @@ -1,295 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -#nullable enable - -using System.Diagnostics; -using System.Text; -using System.Text.Json; -using System.Text.Json.Serialization; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.Extensions; - -namespace DbscSample; - -/// -/// Middleware that captures all HTTP requests/responses and writes them to a HAR file on disk. -/// This captures the full DBSC protocol flow including headers that Chrome DevTools may hide. -/// -public sealed class HarLoggingMiddleware -{ - private readonly RequestDelegate _next; - private readonly string _outputPath; - private readonly HarLog _harLog; - private readonly object _lock = new(); - private readonly JsonSerializerOptions _jsonOptions = new() - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - }; - - public HarLoggingMiddleware(RequestDelegate next, string outputPath) - { - _next = next; - _outputPath = outputPath; - _harLog = new HarLog - { - Log = new HarLogContent - { - Version = "1.2", - Creator = new HarCreator { Name = "DbscSample", Version = "1.0" }, - Entries = [], - } - }; - } - - public async Task InvokeAsync(HttpContext context) - { - var startTime = DateTimeOffset.UtcNow; - var stopwatch = Stopwatch.StartNew(); - - // Capture request - var requestHeaders = CaptureHeaders(context.Request.Headers); - var requestBody = await CaptureRequestBodyAsync(context); - - // Buffer the response body - var originalBodyStream = context.Response.Body; - using var responseBodyStream = new MemoryStream(); - context.Response.Body = responseBodyStream; - - try - { - await _next(context); - } - finally - { - stopwatch.Stop(); - context.Response.Body = originalBodyStream; - - // Read captured response body - responseBodyStream.Seek(0, SeekOrigin.Begin); - var responseBodyBytes = responseBodyStream.ToArray(); - var responseBody = Encoding.UTF8.GetString(responseBodyBytes); - - // Write response body to client - if (responseBodyBytes.Length > 0) - { - await originalBodyStream.WriteAsync(responseBodyBytes); - } - - // Capture response headers (after response has been written) - var responseHeaders = CaptureHeaders(context.Response.Headers); - - var entry = new HarEntry - { - StartedDateTime = startTime.ToString("O"), - Time = stopwatch.Elapsed.TotalMilliseconds, - Request = new HarRequest - { - Method = context.Request.Method, - Url = context.Request.GetDisplayUrl(), - HttpVersion = context.Request.Protocol, - Headers = requestHeaders, - HeadersSize = -1, - BodySize = requestBody?.Length ?? 0, - PostData = requestBody is not null ? new HarPostData - { - MimeType = context.Request.ContentType ?? "", - Text = requestBody, - } : null, - }, - Response = new HarResponse - { - Status = context.Response.StatusCode, - StatusText = "", - HttpVersion = context.Request.Protocol, - Headers = responseHeaders, - HeadersSize = -1, - BodySize = responseBodyBytes.Length, - Content = new HarContent - { - Size = responseBodyBytes.Length, - MimeType = context.Response.ContentType ?? "", - Text = responseBody, - }, - }, - Timings = new HarTimings - { - Send = 0, - Wait = stopwatch.Elapsed.TotalMilliseconds, - Receive = 0, - }, - }; - - lock (_lock) - { - _harLog.Log.Entries.Add(entry); - var json = JsonSerializer.Serialize(_harLog, _jsonOptions); - File.WriteAllText(_outputPath, json); - } - } - } - - private static List CaptureHeaders(IHeaderDictionary headers) - { - var result = new List(); - foreach (var header in headers) - { - foreach (var value in header.Value) - { - result.Add(new HarHeader { Name = header.Key, Value = value ?? "" }); - } - } - return result; - } - - private static async Task CaptureRequestBodyAsync(HttpContext context) - { - if (context.Request.ContentLength is null or 0) - { - return null; - } - - context.Request.EnableBuffering(); - using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true); - var body = await reader.ReadToEndAsync(); - context.Request.Body.Position = 0; - return body; - } -} - -// HAR format models -public sealed class HarLog -{ - [JsonPropertyName("log")] - public HarLogContent Log { get; set; } = null!; -} - -public sealed class HarLogContent -{ - [JsonPropertyName("version")] - public string Version { get; set; } = "1.2"; - - [JsonPropertyName("creator")] - public HarCreator Creator { get; set; } = null!; - - [JsonPropertyName("entries")] - public List Entries { get; set; } = []; -} - -public sealed class HarCreator -{ - [JsonPropertyName("name")] - public string Name { get; set; } = ""; - - [JsonPropertyName("version")] - public string Version { get; set; } = ""; -} - -public sealed class HarEntry -{ - [JsonPropertyName("startedDateTime")] - public string StartedDateTime { get; set; } = ""; - - [JsonPropertyName("time")] - public double Time { get; set; } - - [JsonPropertyName("request")] - public HarRequest Request { get; set; } = null!; - - [JsonPropertyName("response")] - public HarResponse Response { get; set; } = null!; - - [JsonPropertyName("timings")] - public HarTimings Timings { get; set; } = null!; -} - -public sealed class HarRequest -{ - [JsonPropertyName("method")] - public string Method { get; set; } = ""; - - [JsonPropertyName("url")] - public string Url { get; set; } = ""; - - [JsonPropertyName("httpVersion")] - public string HttpVersion { get; set; } = ""; - - [JsonPropertyName("headers")] - public List Headers { get; set; } = []; - - [JsonPropertyName("headersSize")] - public int HeadersSize { get; set; } - - [JsonPropertyName("bodySize")] - public int BodySize { get; set; } - - [JsonPropertyName("postData")] - public HarPostData? PostData { get; set; } -} - -public sealed class HarResponse -{ - [JsonPropertyName("status")] - public int Status { get; set; } - - [JsonPropertyName("statusText")] - public string StatusText { get; set; } = ""; - - [JsonPropertyName("httpVersion")] - public string HttpVersion { get; set; } = ""; - - [JsonPropertyName("headers")] - public List Headers { get; set; } = []; - - [JsonPropertyName("headersSize")] - public int HeadersSize { get; set; } - - [JsonPropertyName("bodySize")] - public int BodySize { get; set; } - - [JsonPropertyName("content")] - public HarContent Content { get; set; } = null!; -} - -public sealed class HarHeader -{ - [JsonPropertyName("name")] - public string Name { get; set; } = ""; - - [JsonPropertyName("value")] - public string Value { get; set; } = ""; -} - -public sealed class HarPostData -{ - [JsonPropertyName("mimeType")] - public string MimeType { get; set; } = ""; - - [JsonPropertyName("text")] - public string Text { get; set; } = ""; -} - -public sealed class HarContent -{ - [JsonPropertyName("size")] - public int Size { get; set; } - - [JsonPropertyName("mimeType")] - public string MimeType { get; set; } = ""; - - [JsonPropertyName("text")] - public string? Text { get; set; } -} - -public sealed class HarTimings -{ - [JsonPropertyName("send")] - public double Send { get; set; } - - [JsonPropertyName("wait")] - public double Wait { get; set; } - - [JsonPropertyName("receive")] - public double Receive { get; set; } -} diff --git a/src/Security/Authentication/Cookies/samples/DbscSample/Program.cs b/src/Security/Authentication/Cookies/samples/DbscSample/Program.cs deleted file mode 100644 index 51c33875766a..000000000000 --- a/src/Security/Authentication/Cookies/samples/DbscSample/Program.cs +++ /dev/null @@ -1,220 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Security.Claims; -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.HttpLogging; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace DbscSample; - -public static class Program -{ - public static Task Main(string[] args) - { - var host = new HostBuilder() - .ConfigureWebHost(webHostBuilder => - { - webHostBuilder - .UseKestrel(options => - { - options.ListenLocalhost(7298, listenOptions => - { - listenOptions.UseHttps(); - }); - }) - .UseStartup(); - }) - .ConfigureLogging(factory => - { - factory.AddConsole(); - factory.AddFilter("Default", LogLevel.Information); - factory.AddFilter("Microsoft.AspNetCore.HttpLogging", LogLevel.Information); - factory.AddFilter("Microsoft.AspNetCore", LogLevel.Warning); - }) - .Build(); - - return host.RunAsync(); - } -} - -public class Startup -{ - public void ConfigureServices(IServiceCollection services) - { - services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => - { - options.LoginPath = "/login"; - options.ExpireTimeSpan = TimeSpan.FromDays(7); - options.DeviceBoundSession = new DeviceBoundSessionOptions - { - Enabled = true, - // Short expiration for testing (refreshes happen every 30s) - ShortLivedCookieExpiration = TimeSpan.FromSeconds(30), - }; - }); - - services.AddHttpLogging(logging => - { - logging.LoggingFields = HttpLoggingFields.All; - logging.RequestHeaders.Add("Sec-Session-Id"); - logging.RequestHeaders.Add("Sec-Secure-Session-Id"); - logging.RequestHeaders.Add("Secure-Session-Response"); - logging.ResponseHeaders.Add("Secure-Session-Registration"); - logging.ResponseHeaders.Add("Secure-Session-Challenge"); - logging.ResponseHeaders.Add("Set-Cookie"); - logging.RequestBodyLogLimit = 4096; - logging.ResponseBodyLogLimit = 4096; - logging.CombineLogs = true; - }); - - services.AddRouting(); - } - - public void Configure(IApplicationBuilder app) - { - // Write all HTTP traffic to a HAR file for inspection - var harPath = Path.Combine(AppContext.BaseDirectory, "dbsc-traffic.har"); - Console.WriteLine($"[HAR] Writing traffic to: {harPath}"); - app.UseMiddleware(harPath); - - app.UseHttpLogging(); - app.UseRouting(); - app.UseAuthentication(); - app.UseDeviceBoundSessions(); - - // DEBUG: dump authenticated ticket properties into response headers - app.Use(async (context, next) => - { - context.Response.OnStarting(() => - { - if (context.User.Identity?.IsAuthenticated == true) - { - context.Response.Headers["X-Debug-Principal"] = context.User.Identity.Name ?? "(no name)"; - - // Get the auth ticket from the feature - var authFeature = context.Features.Get(); - var ticket = authFeature?.AuthenticateResult?.Ticket; - if (ticket is not null) - { - context.Response.Headers["X-Debug-Scheme"] = ticket.AuthenticationScheme; - foreach (var kvp in ticket.Properties.Items) - { - var headerKey = kvp.Key.Replace(".", "-").Replace("/", "_"); - context.Response.Headers[$"X-Debug-Prop-{headerKey}"] = kvp.Value ?? "(null)"; - } - } - else - { - context.Response.Headers["X-Debug-Ticket"] = "authenticated-but-no-ticket-feature"; - } - } - else - { - context.Response.Headers["X-Debug-Auth"] = "not-authenticated"; - } - return Task.CompletedTask; - }); - - await next(); - }); - - app.UseEndpoints(endpoints => - { - endpoints.MapGet("/login", async context => - { - context.Response.ContentType = "text/html"; - await context.Response.WriteAsync(""" - - - DBSC Sample - Login - -

Device Bound Session Credentials - Test App

-
- - -
- - - """); - }); - - endpoints.MapPost("/login", async context => - { - var form = await context.Request.ReadFormAsync(); - var username = form["username"].ToString(); - - if (string.IsNullOrEmpty(username)) - { - context.Response.Redirect("/login"); - return; - } - - var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme); - identity.AddClaim(new Claim(ClaimTypes.Name, username)); - identity.AddClaim(new Claim(ClaimTypes.Email, $"{username}@example.com")); - - await context.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - new ClaimsPrincipal(identity), - new AuthenticationProperties { IsPersistent = true }); - - context.Response.Redirect("/"); - }); - - endpoints.MapGet("/", async context => - { - if (context.User.Identity?.IsAuthenticated != true) - { - context.Response.Redirect("/login"); - return; - } - - var userName = context.User.Identity!.Name; - context.Response.ContentType = "text/html"; - await context.Response.WriteAsync( - "DBSC Sample" + - $"

Welcome, {userName}!

" + - "

Authenticated with Device Bound Session Credentials.

" + - "

How to test:

    " + - "
  1. Open Chrome DevTools (F12) → Network tab
  2. " + - "
  3. Check the sign-in response for Secure-Session-Registration header
  4. " + - "
  5. Watch for POST to /.well-known/dbsc/registration
  6. " + - "
  7. Wait 30s, then make a request to observe refresh at /.well-known/dbsc/refresh
  8. " + - "
" + - "

API endpoint | Sign Out

" + - "

Auto-refresh (every 10s):

" +
-                    "");
-            });
-
-            endpoints.MapGet("/api/time", async context =>
-            {
-                if (context.User.Identity?.IsAuthenticated != true)
-                {
-                    context.Response.StatusCode = 401;
-                    await context.Response.WriteAsync("Unauthorized");
-                    return;
-                }
-                await context.Response.WriteAsync($"Server time: {DateTime.UtcNow:O} | User: {context.User.Identity!.Name}");
-            });
-
-            endpoints.MapGet("/signout", async context =>
-            {
-                await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
-                context.Response.Redirect("/login");
-            });
-        });
-    }
-}
diff --git a/src/Security/Authentication/Cookies/samples/DbscSample/Properties/launchSettings.json b/src/Security/Authentication/Cookies/samples/DbscSample/Properties/launchSettings.json
deleted file mode 100644
index 71c0d45b5478..000000000000
--- a/src/Security/Authentication/Cookies/samples/DbscSample/Properties/launchSettings.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
-  "profiles": {
-    "DbscSample": {
-      "commandName": "Project",
-      "applicationUrl": "https://localhost:7298"
-    }
-  }
-}
diff --git a/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs b/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs
index 21f1fa382058..0e9ad0883808 100644
--- a/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs
+++ b/src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs
@@ -370,16 +370,6 @@ protected override async Task HandleSignInAsync(ClaimsPrincipal user, Authentica
 
         await Events.SignedIn(signedInContext);
 
-        // Emit DBSC registration header if enabled
-        if (Options.DeviceBoundSession is { Enabled: true } dbscOptions)
-        {
-            var algorithms = string.Join(" ", dbscOptions.SupportedAlgorithms);
-            var registrationPath = dbscOptions.RegistrationPath.Value;
-            var challenge = GenerateDbscRegistrationChallenge();
-            Response.Headers.Append("Secure-Session-Registration",
-                $"({algorithms});path=\"{registrationPath}\";challenge=\"{challenge}\"");
-        }
-
         // Only honor the ReturnUrl query string parameter on the login path
         var shouldHonorReturnUrlParameter = Options.LoginPath.HasValue && OriginalPath == Options.LoginPath;
         await ApplyHeaders(shouldRedirect: true, shouldHonorReturnUrlParameter, signedInContext.Properties);
@@ -500,12 +490,4 @@ protected override async Task HandleChallengeAsync(AuthenticationProperties prop
         var binding = Context.Features.Get()?.GetProvidedTokenBindingId();
         return binding == null ? null : Convert.ToBase64String(binding);
     }
-
-    private static string GenerateDbscRegistrationChallenge()
-    {
-        return Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
-            .Replace('+', '-')
-            .Replace('/', '_')
-            .TrimEnd('=');
-    }
 }
diff --git a/src/Security/Authentication/Cookies/src/CookieAuthenticationOptions.cs b/src/Security/Authentication/Cookies/src/CookieAuthenticationOptions.cs
index 31fb89ef6276..1655b90552a0 100644
--- a/src/Security/Authentication/Cookies/src/CookieAuthenticationOptions.cs
+++ b/src/Security/Authentication/Cookies/src/CookieAuthenticationOptions.cs
@@ -132,22 +132,4 @@ public CookieBuilder Cookie
     /// 
     /// 
     public TimeSpan ExpireTimeSpan { get; set; }
-
-    /// 
-    /// Gets or sets the Device Bound Session Credentials (DBSC) options.
-    /// When enabled, session cookies are bound to a device using cryptographic key pairs,
-    /// preventing session cookie theft and exfiltration.
-    /// 
-    /// 
-    /// 
-    /// DBSC uses a two-cookie pattern: a long-lived cookie (containing the auth ticket and public key)
-    /// and a short-lived cookie (the DBSC-bound credential). When the short-lived cookie expires,
-    /// the browser must prove possession of the device-bound private key to obtain a new one.
-    /// 
-    /// 
-    /// This feature requires browser support (currently Chrome 135+). Browsers that do not support DBSC
-    /// will simply ignore the Secure-Session-Registration header and continue using the long-lived cookie.
-    /// 
-    /// 
-    public DeviceBoundSessionOptions? DeviceBoundSession { get; set; }
 }
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionChallengeGenerator.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionChallengeGenerator.cs
deleted file mode 100644
index 28e37f9b5151..000000000000
--- a/src/Security/Authentication/Cookies/src/DeviceBoundSessionChallengeGenerator.cs
+++ /dev/null
@@ -1,81 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Security.Cryptography;
-using Microsoft.AspNetCore.DataProtection;
-
-namespace Microsoft.AspNetCore.Authentication.Cookies;
-
-/// 
-/// Generates and validates self-contained DBSC challenges using Data Protection.
-/// Challenges are stateless — the server can validate them without storing them.
-/// 
-internal sealed class DeviceBoundSessionChallengeGenerator
-{
-    private readonly IDataProtector _protector;
-    private readonly TimeProvider _timeProvider;
-
-    public DeviceBoundSessionChallengeGenerator(IDataProtectionProvider dataProtectionProvider, TimeProvider? timeProvider = null)
-    {
-        _protector = dataProtectionProvider.CreateProtector("Microsoft.AspNetCore.Authentication.Cookies.DBSC.Challenge");
-        _timeProvider = timeProvider ?? TimeProvider.System;
-    }
-
-    /// 
-    /// Generates a self-contained challenge string for the given session.
-    /// 
-    /// The session identifier.
-    /// A data-protected challenge string.
-    public string GenerateChallenge(string sessionId)
-    {
-        var nonce = RandomNumberGenerator.GetBytes(16);
-        var timestamp = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
-
-        // Format: timestamp|nonce_base64|sessionId
-        var payload = $"{timestamp}|{Convert.ToBase64String(nonce)}|{sessionId}";
-        return _protector.Protect(payload);
-    }
-
-    /// 
-    /// Validates a challenge string and checks that it was issued recently and for the correct session.
-    /// 
-    /// The challenge string to validate.
-    /// The expected session identifier.
-    /// The maximum age of the challenge.
-    /// true if the challenge is valid and fresh; otherwise, false.
-    public bool ValidateChallenge(string challenge, string expectedSessionId, TimeSpan maxAge)
-    {
-        string payload;
-        try
-        {
-            payload = _protector.Unprotect(challenge);
-        }
-        catch (CryptographicException)
-        {
-            return false;
-        }
-
-        var parts = payload.Split('|', 3);
-        if (parts.Length != 3)
-        {
-            return false;
-        }
-
-        if (!long.TryParse(parts[0], out var timestamp))
-        {
-            return false;
-        }
-
-        // Check freshness
-        var issued = DateTimeOffset.FromUnixTimeSeconds(timestamp);
-        var now = _timeProvider.GetUtcNow();
-        if (now - issued > maxAge)
-        {
-            return false;
-        }
-
-        // Check session ID
-        var sessionId = parts[2];
-        return string.Equals(sessionId, expectedSessionId, StringComparison.Ordinal);
-    }
-}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionConfiguration.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionConfiguration.cs
deleted file mode 100644
index 8997a880d852..000000000000
--- a/src/Security/Authentication/Cookies/src/DeviceBoundSessionConfiguration.cs
+++ /dev/null
@@ -1,132 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Text.Json.Serialization;
-
-namespace Microsoft.AspNetCore.Authentication.Cookies;
-
-/// 
-/// Represents the JSON session configuration returned by the DBSC registration and refresh endpoints.
-/// This instructs the browser on how to manage the device-bound session.
-/// 
-public sealed class DeviceBoundSessionConfiguration
-{
-    /// 
-    /// Gets or sets the unique identifier for the session.
-    /// 
-    [JsonPropertyName("session_identifier")]
-    public required string SessionIdentifier { get; set; }
-
-    /// 
-    /// Gets or sets the URL for future refresh requests.
-    /// Can be relative to the registration/refresh URL.
-    /// 
-    [JsonPropertyName("refresh_url")]
-    public string? RefreshUrl { get; set; }
-
-    /// 
-    /// Gets or sets whether the session should continue.
-    /// Set to false to terminate the session.
-    /// 
-    [JsonPropertyName("continue")]
-    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
-    public bool? Continue { get; set; }
-
-    /// 
-    /// Gets or sets the session scope configuration.
-    /// 
-    [JsonPropertyName("scope")]
-    public required DeviceBoundSessionScopeConfiguration Scope { get; set; }
-
-    /// 
-    /// Gets or sets the list of credentials (cookies) protected by this session.
-    /// 
-    [JsonPropertyName("credentials")]
-    public required IList Credentials { get; set; }
-
-    /// 
-    /// Gets or sets the hosts allowed to initiate DBSC refreshes.
-    /// 
-    [JsonPropertyName("allowed_refresh_initiators")]
-    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
-    public IList? AllowedRefreshInitiators { get; set; }
-}
-
-/// 
-/// Represents the scope configuration for a device-bound session.
-/// 
-public sealed class DeviceBoundSessionScopeConfiguration
-{
-    /// 
-    /// Gets or sets the origin the session applies to.
-    /// If not set, the origin of the registration/refresh URL is used.
-    /// 
-    [JsonPropertyName("origin")]
-    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
-    public string? Origin { get; set; }
-
-    /// 
-    /// Gets or sets whether the session applies to the entire site (all subdomains)
-    /// or just the origin.
-    /// 
-    [JsonPropertyName("include_site")]
-    public bool IncludeSite { get; set; }
-
-    /// 
-    /// Gets or sets the scope specification rules.
-    /// 
-    [JsonPropertyName("scope_specification")]
-    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
-    public IList? ScopeSpecification { get; set; }
-}
-
-/// 
-/// Represents a scope rule in the session configuration JSON.
-/// 
-public sealed class DeviceBoundSessionScopeRuleConfiguration
-{
-    /// 
-    /// Gets or sets the type: "include" or "exclude".
-    /// 
-    [JsonPropertyName("type")]
-    public required string Type { get; set; }
-
-    /// 
-    /// Gets or sets the domain pattern.
-    /// 
-    [JsonPropertyName("domain")]
-    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
-    public string? Domain { get; set; }
-
-    /// 
-    /// Gets or sets the path prefix.
-    /// 
-    [JsonPropertyName("path")]
-    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
-    public string? Path { get; set; }
-}
-
-/// 
-/// Represents a credential (cookie) protected by the device-bound session.
-/// 
-public sealed class DeviceBoundSessionCredentialConfiguration
-{
-    /// 
-    /// Gets or sets the credential type. Must be "cookie".
-    /// 
-    [JsonPropertyName("type")]
-    public string Type { get; set; } = "cookie";
-
-    /// 
-    /// Gets or sets the name of the bound cookie.
-    /// 
-    [JsonPropertyName("name")]
-    public required string Name { get; set; }
-
-    /// 
-    /// Gets or sets the expected attributes of the cookie (e.g., "Domain=example.com; Secure; SameSite=Lax").
-    /// 
-    [JsonPropertyName("attributes")]
-    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
-    public string? Attributes { get; set; }
-}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionExtensions.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionExtensions.cs
deleted file mode 100644
index a63693540ba5..000000000000
--- a/src/Security/Authentication/Cookies/src/DeviceBoundSessionExtensions.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Builder;
-
-namespace Microsoft.Extensions.DependencyInjection;
-
-/// 
-/// Extension methods for configuring Device Bound Session Credentials (DBSC).
-/// 
-public static class DeviceBoundSessionExtensions
-{
-    /// 
-    /// Adds the Device Bound Session Credentials middleware to the application pipeline.
-    /// This middleware handles DBSC registration and refresh requests.
-    /// 
-    /// 
-    /// 
-    /// This middleware should be added after UseAuthentication() in the pipeline
-    /// so that the authentication cookie is available for DBSC operations.
-    /// 
-    /// 
-    /// 
-    /// var app = builder.Build();
-    /// app.UseAuthentication();
-    /// app.UseDeviceBoundSessions();
-    /// app.UseAuthorization();
-    /// 
-    /// 
-    /// 
-    /// The .
-    /// The  for chaining.
-    public static IApplicationBuilder UseDeviceBoundSessions(this IApplicationBuilder app)
-    {
-        ArgumentNullException.ThrowIfNull(app);
-
-        return app.UseMiddleware();
-    }
-}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionJsonContext.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionJsonContext.cs
deleted file mode 100644
index d89ea29ad1f3..000000000000
--- a/src/Security/Authentication/Cookies/src/DeviceBoundSessionJsonContext.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Text.Json.Serialization;
-
-namespace Microsoft.AspNetCore.Authentication.Cookies;
-
-[JsonSerializable(typeof(DeviceBoundSessionConfiguration))]
-internal sealed partial class DeviceBoundSessionJsonContext : JsonSerializerContext
-{
-}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionJwtValidator.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionJwtValidator.cs
deleted file mode 100644
index 2ad881b07a5e..000000000000
--- a/src/Security/Authentication/Cookies/src/DeviceBoundSessionJwtValidator.cs
+++ /dev/null
@@ -1,290 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Security.Cryptography;
-using System.Text;
-using System.Text.Json;
-
-namespace Microsoft.AspNetCore.Authentication.Cookies;
-
-/// 
-/// Validates DBSC proof JWTs (typ: "dbsc+jwt") signed with ES256 or RS256.
-/// 
-internal static class DeviceBoundSessionJwtValidator
-{
-    /// 
-    /// Validates a DBSC proof JWT and extracts its claims.
-    /// 
-    /// The raw JWT string.
-    /// The JWK JSON of the public key to validate against. If null, extracts from JWT header.
-    /// The expected challenge value (jti claim).
-    /// The parsed result, or null if validation fails.
-    public static DeviceBoundSessionJwtResult? Validate(string jwt, string? publicKeyJwk, string? expectedChallenge)
-    {
-        var parts = jwt.Split('.');
-        if (parts.Length != 3)
-        {
-            return null;
-        }
-
-        var headerJson = Base64UrlDecode(parts[0]);
-        var payloadJson = Base64UrlDecode(parts[1]);
-        var signature = Base64UrlDecodeBytes(parts[2]);
-
-        if (headerJson is null || payloadJson is null || signature is null)
-        {
-            return null;
-        }
-
-        // Parse header
-        JsonElement header;
-        try
-        {
-            header = JsonDocument.Parse(headerJson).RootElement;
-        }
-        catch (JsonException)
-        {
-            return null;
-        }
-
-        // Validate typ
-        if (!header.TryGetProperty("typ", out var typElement) ||
-            !string.Equals(typElement.GetString(), "dbsc+jwt", StringComparison.OrdinalIgnoreCase))
-        {
-            return null;
-        }
-
-        // Get algorithm
-        if (!header.TryGetProperty("alg", out var algElement))
-        {
-            return null;
-        }
-        var algorithm = algElement.GetString();
-
-        // Get JWK - from parameter or from header (registration)
-        string? jwkJson = publicKeyJwk;
-        if (jwkJson is null)
-        {
-            if (!header.TryGetProperty("jwk", out var jwkElement))
-            {
-                return null;
-            }
-            jwkJson = jwkElement.GetRawText();
-        }
-
-        // Validate signature
-        var signedData = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
-        if (!VerifySignature(algorithm, jwkJson, signedData, signature))
-        {
-            return null;
-        }
-
-        // Parse payload
-        JsonElement payload;
-        try
-        {
-            payload = JsonDocument.Parse(payloadJson).RootElement;
-        }
-        catch (JsonException)
-        {
-            return null;
-        }
-
-        // Validate jti (challenge)
-        string? jti = null;
-        if (payload.TryGetProperty("jti", out var jtiElement))
-        {
-            jti = jtiElement.GetString();
-        }
-
-        if (expectedChallenge is not null && !string.Equals(jti, expectedChallenge, StringComparison.Ordinal))
-        {
-            return null;
-        }
-
-        // Extract authorization claim if present
-        string? authorization = null;
-        if (payload.TryGetProperty("authorization", out var authElement))
-        {
-            authorization = authElement.GetString();
-        }
-
-        return new DeviceBoundSessionJwtResult
-        {
-            Algorithm = algorithm,
-            PublicKeyJwk = jwkJson,
-            Challenge = jti,
-            Authorization = authorization
-        };
-    }
-
-    /// 
-    /// Extracts the JWK from a DBSC registration JWT header without full validation.
-    /// Used during registration when we don't yet have a stored key.
-    /// 
-    public static string? ExtractPublicKeyJwk(string jwt)
-    {
-        var parts = jwt.Split('.');
-        if (parts.Length != 3)
-        {
-            return null;
-        }
-
-        var headerJson = Base64UrlDecode(parts[0]);
-        if (headerJson is null)
-        {
-            return null;
-        }
-
-        try
-        {
-            var header = JsonDocument.Parse(headerJson).RootElement;
-            if (header.TryGetProperty("jwk", out var jwkElement))
-            {
-                return jwkElement.GetRawText();
-            }
-        }
-        catch (JsonException)
-        {
-            // Ignore parse errors
-        }
-
-        return null;
-    }
-
-    private static bool VerifySignature(string? algorithm, string jwkJson, byte[] signedData, byte[] signature)
-    {
-        return algorithm switch
-        {
-            "ES256" => VerifyES256(jwkJson, signedData, signature),
-            "RS256" => VerifyRS256(jwkJson, signedData, signature),
-            _ => false
-        };
-    }
-
-    private static bool VerifyES256(string jwkJson, byte[] signedData, byte[] signature)
-    {
-        try
-        {
-            using var doc = JsonDocument.Parse(jwkJson);
-            var jwk = doc.RootElement;
-
-            if (!jwk.TryGetProperty("kty", out var kty) || kty.GetString() != "EC")
-            {
-                return false;
-            }
-            if (!jwk.TryGetProperty("crv", out var crv) || crv.GetString() != "P-256")
-            {
-                return false;
-            }
-            if (!jwk.TryGetProperty("x", out var xProp) || !jwk.TryGetProperty("y", out var yProp))
-            {
-                return false;
-            }
-
-            var x = Base64UrlDecodeBytes(xProp.GetString()!);
-            var y = Base64UrlDecodeBytes(yProp.GetString()!);
-            if (x is null || y is null)
-            {
-                return false;
-            }
-
-            var parameters = new ECParameters
-            {
-                Curve = ECCurve.NamedCurves.nistP256,
-                Q = new ECPoint { X = x, Y = y }
-            };
-
-            using var ecdsa = ECDsa.Create(parameters);
-            return ecdsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
-                || ecdsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256);
-        }
-        catch (CryptographicException)
-        {
-            return false;
-        }
-        catch (JsonException)
-        {
-            return false;
-        }
-    }
-
-    private static bool VerifyRS256(string jwkJson, byte[] signedData, byte[] signature)
-    {
-        try
-        {
-            using var doc = JsonDocument.Parse(jwkJson);
-            var jwk = doc.RootElement;
-
-            if (!jwk.TryGetProperty("kty", out var kty) || kty.GetString() != "RSA")
-            {
-                return false;
-            }
-            if (!jwk.TryGetProperty("n", out var nProp) || !jwk.TryGetProperty("e", out var eProp))
-            {
-                return false;
-            }
-
-            var n = Base64UrlDecodeBytes(nProp.GetString()!);
-            var e = Base64UrlDecodeBytes(eProp.GetString()!);
-            if (n is null || e is null)
-            {
-                return false;
-            }
-
-            var parameters = new RSAParameters
-            {
-                Modulus = n,
-                Exponent = e
-            };
-
-            using var rsa = RSA.Create(parameters);
-            return rsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
-        }
-        catch (CryptographicException)
-        {
-            return false;
-        }
-        catch (JsonException)
-        {
-            return false;
-        }
-    }
-
-    private static string? Base64UrlDecode(string input)
-    {
-        var bytes = Base64UrlDecodeBytes(input);
-        return bytes is null ? null : Encoding.UTF8.GetString(bytes);
-    }
-
-    private static byte[]? Base64UrlDecodeBytes(string input)
-    {
-        // Replace URL-safe characters and add padding
-        var base64 = input.Replace('-', '+').Replace('_', '/');
-        switch (base64.Length % 4)
-        {
-            case 2: base64 += "=="; break;
-            case 3: base64 += "="; break;
-        }
-
-        try
-        {
-            return Convert.FromBase64String(base64);
-        }
-        catch (FormatException)
-        {
-            return null;
-        }
-    }
-}
-
-/// 
-/// Result of validating a DBSC proof JWT.
-/// 
-internal sealed class DeviceBoundSessionJwtResult
-{
-    public required string? Algorithm { get; init; }
-    public required string PublicKeyJwk { get; init; }
-    public string? Challenge { get; init; }
-    public string? Authorization { get; init; }
-}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionMiddleware.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionMiddleware.cs
deleted file mode 100644
index cb3fd496a8ba..000000000000
--- a/src/Security/Authentication/Cookies/src/DeviceBoundSessionMiddleware.cs
+++ /dev/null
@@ -1,385 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Linq;
-using System.Security.Cryptography;
-using System.Text.Json;
-using Microsoft.AspNetCore.DataProtection;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Options;
-
-namespace Microsoft.AspNetCore.Authentication.Cookies;
-
-/// 
-/// Middleware that handles DBSC registration and refresh requests.
-/// 
-internal sealed class DeviceBoundSessionMiddleware
-{
-    private readonly RequestDelegate _next;
-    private readonly ILogger _logger;
-
-    public DeviceBoundSessionMiddleware(RequestDelegate next, ILogger logger)
-    {
-        _next = next;
-        _logger = logger;
-    }
-
-    public async Task InvokeAsync(HttpContext context)
-    {
-        // Check all configured cookie authentication schemes for DBSC paths
-        var authOptions = context.RequestServices.GetService>();
-        var schemes = context.RequestServices.GetService();
-
-        if (authOptions is null || schemes is null)
-        {
-            await _next(context);
-            return;
-        }
-
-        var allSchemes = await schemes.GetAllSchemesAsync();
-        foreach (var scheme in allSchemes)
-        {
-            if (scheme.HandlerType != typeof(CookieAuthenticationHandler))
-            {
-                continue;
-            }
-
-            var options = authOptions.Get(scheme.Name);
-            var dbscOptions = options.DeviceBoundSession;
-            if (dbscOptions is null || !dbscOptions.Enabled)
-            {
-                continue;
-            }
-
-            if (context.Request.Path.Equals(dbscOptions.RegistrationPath) && HttpMethods.IsPost(context.Request.Method))
-            {
-                await HandleRegistrationAsync(context, options, dbscOptions);
-                return;
-            }
-
-            if (context.Request.Path.Equals(dbscOptions.RefreshPath) && HttpMethods.IsPost(context.Request.Method))
-            {
-                await HandleRefreshAsync(context, options, dbscOptions);
-                return;
-            }
-        }
-
-        await _next(context);
-    }
-
-    private async Task HandleRegistrationAsync(
-        HttpContext context,
-        CookieAuthenticationOptions options,
-        DeviceBoundSessionOptions dbscOptions)
-    {
-        // Extract the JWT from the Secure-Session-Response header
-        var responseHeader = context.Request.Headers["Secure-Session-Response"].ToString();
-        if (string.IsNullOrEmpty(responseHeader))
-        {
-            // Also try reading from body for compatibility
-            context.Response.StatusCode = StatusCodes.Status400BadRequest;
-            return;
-        }
-
-        // Remove quotes if present (structured header format)
-        responseHeader = responseHeader.Trim('"');
-
-        // Validate the JWT and extract the public key
-        var result = DeviceBoundSessionJwtValidator.Validate(responseHeader, publicKeyJwk: null, expectedChallenge: null);
-        if (result is null)
-        {
-            _logger.LogWarning("DBSC registration: invalid JWT proof.");
-            context.Response.StatusCode = StatusCodes.Status400BadRequest;
-            return;
-        }
-
-        // Read the existing auth cookie to get the current session
-        var cookie = options.CookieManager.GetRequestCookie(context, options.Cookie.Name!);
-        if (string.IsNullOrEmpty(cookie))
-        {
-            _logger.LogWarning("DBSC registration: no auth cookie present.");
-            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
-            return;
-        }
-
-        var ticket = options.TicketDataFormat.Unprotect(cookie, GetTlsTokenBinding(context));
-        if (ticket is null)
-        {
-            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
-            return;
-        }
-
-        // Generate session ID
-        var sessionId = GenerateSessionId();
-
-        // Store public key in ticket properties (will be re-protected into the long-lived cookie)
-        ticket.Properties.Items["DbscPublicKeyJwk"] = result.PublicKeyJwk;
-        ticket.Properties.Items["DbscSessionId"] = sessionId;
-        ticket.Properties.Items["DbscAlgorithm"] = result.Algorithm;
-
-        // Re-protect and set the long-lived cookie with embedded public key
-        var cookieValue = options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding(context));
-        var cookieOptions = options.Cookie.Build(context);
-        if (ticket.Properties.IsPersistent && ticket.Properties.ExpiresUtc.HasValue)
-        {
-            cookieOptions.Expires = ticket.Properties.ExpiresUtc.Value.ToUniversalTime();
-        }
-
-        options.CookieManager.AppendResponseCookie(context, options.Cookie.Name!, cookieValue, cookieOptions);
-
-        // Set the short-lived cookie
-        var shortLivedCookieName = dbscOptions.ShortLivedCookieName ?? $"{options.Cookie.Name}__dbsc";
-        var shortLivedCookieOptions = options.Cookie.Build(context);
-        shortLivedCookieOptions.Expires = DateTimeOffset.UtcNow.Add(dbscOptions.ShortLivedCookieExpiration);
-        shortLivedCookieOptions.MaxAge = dbscOptions.ShortLivedCookieExpiration;
-
-        var shortLivedValue = GenerateShortLivedCookieValue(sessionId);
-        options.CookieManager.AppendResponseCookie(context, shortLivedCookieName, shortLivedValue, shortLivedCookieOptions);
-
-        // Optionally store in server-side store
-        var store = context.RequestServices.GetService();
-        if (store is not null)
-        {
-            await store.StoreAsync(sessionId, result.PublicKeyJwk, context.RequestAborted);
-        }
-
-        // Build and return session configuration
-        var config = BuildSessionConfiguration(context, options, dbscOptions, sessionId, shortLivedCookieName);
-
-        context.Response.StatusCode = StatusCodes.Status200OK;
-        context.Response.ContentType = "application/json";
-
-        // Include a challenge for the next refresh
-        var challengeGenerator = GetChallengeGenerator(context);
-        var challenge = challengeGenerator.GenerateChallenge(sessionId);
-        context.Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionId}\"";
-
-        await JsonSerializer.SerializeAsync(context.Response.Body, config, DeviceBoundSessionJsonContext.Default.DeviceBoundSessionConfiguration, context.RequestAborted);
-    }
-
-    private async Task HandleRefreshAsync(
-        HttpContext context,
-        CookieAuthenticationOptions options,
-        DeviceBoundSessionOptions dbscOptions)
-    {
-        // Read session ID from header
-        var sessionIdHeader = context.Request.Headers["Sec-Secure-Session-Id"].ToString();
-        if (string.IsNullOrEmpty(sessionIdHeader))
-        {
-            context.Response.StatusCode = StatusCodes.Status400BadRequest;
-            return;
-        }
-
-        // Remove quotes if present
-        sessionIdHeader = sessionIdHeader.Trim('"');
-
-        // Check for server-side revocation
-        var store = context.RequestServices.GetService();
-        if (store is not null && await store.IsRevokedAsync(sessionIdHeader, context.RequestAborted))
-        {
-            // Return 403 without challenge to terminate the session
-            context.Response.StatusCode = StatusCodes.Status403Forbidden;
-            return;
-        }
-
-        // Read the long-lived cookie to get the public key
-        var cookie = options.CookieManager.GetRequestCookie(context, options.Cookie.Name!);
-        if (string.IsNullOrEmpty(cookie))
-        {
-            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
-            return;
-        }
-
-        var ticket = options.TicketDataFormat.Unprotect(cookie, GetTlsTokenBinding(context));
-        if (ticket is null)
-        {
-            context.Response.StatusCode = StatusCodes.Status401Unauthorized;
-            return;
-        }
-
-        // Verify the session ID matches
-        if (!ticket.Properties.Items.TryGetValue("DbscSessionId", out var storedSessionId) ||
-            !string.Equals(storedSessionId, sessionIdHeader, StringComparison.Ordinal))
-        {
-            context.Response.StatusCode = StatusCodes.Status403Forbidden;
-            return;
-        }
-
-        // Check for the proof JWT
-        var proofHeader = context.Request.Headers["Secure-Session-Response"].ToString();
-        if (string.IsNullOrEmpty(proofHeader))
-        {
-            // No proof yet — issue a challenge
-            var challengeGenerator = GetChallengeGenerator(context);
-            var challenge = challengeGenerator.GenerateChallenge(sessionIdHeader);
-
-            context.Response.StatusCode = StatusCodes.Status403Forbidden;
-            context.Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionIdHeader}\"";
-            return;
-        }
-
-        // Remove quotes if present
-        proofHeader = proofHeader.Trim('"');
-
-        // Get the public key from the ticket
-        if (!ticket.Properties.Items.TryGetValue("DbscPublicKeyJwk", out var publicKeyJwk) ||
-            publicKeyJwk is null)
-        {
-            context.Response.StatusCode = StatusCodes.Status403Forbidden;
-            return;
-        }
-
-        // Validate the JWT proof
-        // The jti claim should match a challenge we issued — we validate it's a valid challenge
-        var jwtResult = DeviceBoundSessionJwtValidator.Validate(proofHeader, publicKeyJwk, expectedChallenge: null);
-        if (jwtResult is null)
-        {
-            _logger.LogWarning("DBSC refresh: invalid JWT signature for session {SessionId}.", sessionIdHeader);
-            context.Response.StatusCode = StatusCodes.Status403Forbidden;
-            return;
-        }
-
-        // Validate the challenge (jti) is one we issued and is fresh
-        if (jwtResult.Challenge is not null)
-        {
-            var challengeGenerator = GetChallengeGenerator(context);
-            if (!challengeGenerator.ValidateChallenge(jwtResult.Challenge, sessionIdHeader, dbscOptions.ChallengeMaxAge))
-            {
-                _logger.LogWarning("DBSC refresh: stale or invalid challenge for session {SessionId}.", sessionIdHeader);
-                context.Response.StatusCode = StatusCodes.Status403Forbidden;
-                return;
-            }
-        }
-
-        // Success — issue a new short-lived cookie
-        var shortLivedCookieName = dbscOptions.ShortLivedCookieName ?? $"{options.Cookie.Name}__dbsc";
-        var shortLivedCookieOptions = options.Cookie.Build(context);
-        shortLivedCookieOptions.Expires = DateTimeOffset.UtcNow.Add(dbscOptions.ShortLivedCookieExpiration);
-        shortLivedCookieOptions.MaxAge = dbscOptions.ShortLivedCookieExpiration;
-
-        var shortLivedValue = GenerateShortLivedCookieValue(sessionIdHeader);
-        options.CookieManager.AppendResponseCookie(context, shortLivedCookieName, shortLivedValue, shortLivedCookieOptions);
-
-        // Return session configuration with new challenge
-        var config = BuildSessionConfiguration(context, options, dbscOptions, sessionIdHeader, shortLivedCookieName);
-        var nextChallengeGenerator = GetChallengeGenerator(context);
-        var nextChallenge = nextChallengeGenerator.GenerateChallenge(sessionIdHeader);
-
-        context.Response.StatusCode = StatusCodes.Status200OK;
-        context.Response.ContentType = "application/json";
-        context.Response.Headers["Secure-Session-Challenge"] = $"\"{nextChallenge}\";id=\"{sessionIdHeader}\"";
-
-        await JsonSerializer.SerializeAsync(context.Response.Body, config, DeviceBoundSessionJsonContext.Default.DeviceBoundSessionConfiguration, context.RequestAborted);
-    }
-
-    private static DeviceBoundSessionConfiguration BuildSessionConfiguration(
-        HttpContext context,
-        CookieAuthenticationOptions options,
-        DeviceBoundSessionOptions dbscOptions,
-        string sessionId,
-        string shortLivedCookieName)
-    {
-        var request = context.Request;
-        var origin = $"{request.Scheme}://{request.Host}";
-
-        var scopeRules = dbscOptions.ScopeSpecifications.Count > 0
-            ? dbscOptions.ScopeSpecifications.Select(r => new DeviceBoundSessionScopeRuleConfiguration
-            {
-                Type = r.Type,
-                Domain = r.Domain,
-                Path = r.Path
-            }).ToList()
-            : null;
-
-        // Build cookie attributes string
-        var cookieBuilder = options.Cookie.Build(context);
-        var attributes = BuildCookieAttributesString(cookieBuilder);
-
-        return new DeviceBoundSessionConfiguration
-        {
-            SessionIdentifier = sessionId,
-            RefreshUrl = dbscOptions.RefreshPath.Value,
-            Scope = new DeviceBoundSessionScopeConfiguration
-            {
-                Origin = origin,
-                IncludeSite = dbscOptions.IncludeSite,
-                ScopeSpecification = scopeRules
-            },
-            Credentials = new List
-            {
-                new DeviceBoundSessionCredentialConfiguration
-                {
-                    Type = "cookie",
-                    Name = shortLivedCookieName,
-                    Attributes = attributes
-                }
-            },
-            AllowedRefreshInitiators = dbscOptions.AllowedRefreshInitiators.Count > 0
-                ? dbscOptions.AllowedRefreshInitiators.ToList()
-                : null
-        };
-    }
-
-    private static string BuildCookieAttributesString(CookieOptions cookieOptions)
-    {
-        var parts = new List();
-
-        if (!string.IsNullOrEmpty(cookieOptions.Domain))
-        {
-            parts.Add($"Domain={cookieOptions.Domain}");
-        }
-
-        if (!string.IsNullOrEmpty(cookieOptions.Path))
-        {
-            parts.Add($"Path={cookieOptions.Path}");
-        }
-
-        if (cookieOptions.Secure)
-        {
-            parts.Add("Secure");
-        }
-
-        if (cookieOptions.HttpOnly)
-        {
-            parts.Add("HttpOnly");
-        }
-
-        if (cookieOptions.SameSite != SameSiteMode.Unspecified)
-        {
-            parts.Add($"SameSite={cookieOptions.SameSite}");
-        }
-
-        return string.Join("; ", parts);
-    }
-
-    private static string GenerateSessionId()
-    {
-        return Convert.ToBase64String(RandomNumberGenerator.GetBytes(24))
-            .Replace('+', '-')
-            .Replace('/', '_')
-            .TrimEnd('=');
-    }
-
-    private static string GenerateShortLivedCookieValue(string sessionId)
-    {
-        // The short-lived cookie value is a simple marker that proves it was recently issued.
-        // It doesn't need to contain sensitive data — the long-lived cookie has the auth ticket.
-        var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(8));
-        return $"{sessionId}:{nonce}";
-    }
-
-    private static string? GetTlsTokenBinding(HttpContext context)
-    {
-        var binding = context.Features.Get()?.GetProvidedTokenBindingId();
-        return binding is null ? null : Convert.ToBase64String(binding);
-    }
-
-    private static DeviceBoundSessionChallengeGenerator GetChallengeGenerator(HttpContext context)
-    {
-        var dataProtection = context.RequestServices.GetRequiredService();
-        var timeProvider = context.RequestServices.GetService();
-        return new DeviceBoundSessionChallengeGenerator(dataProtection, timeProvider);
-    }
-}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionOptions.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionOptions.cs
deleted file mode 100644
index ba7abcae6454..000000000000
--- a/src/Security/Authentication/Cookies/src/DeviceBoundSessionOptions.cs
+++ /dev/null
@@ -1,112 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using Microsoft.AspNetCore.Http;
-
-namespace Microsoft.AspNetCore.Authentication.Cookies;
-
-/// 
-/// Configuration options for Device Bound Session Credentials (DBSC).
-/// DBSC binds session cookies to a device using cryptographic key pairs,
-/// preventing session cookie theft and exfiltration.
-/// 
-/// 
-/// 
-/// builder.Services.AddAuthentication()
-///     .AddCookie(options =>
-///     {
-///         options.DeviceBoundSession = new DeviceBoundSessionOptions
-///         {
-///             Enabled = true,
-///             RegistrationPath = new PathString("/.well-known/dbsc/registration"),
-///             RefreshPath = new PathString("/.well-known/dbsc/refresh"),
-///         };
-///     });
-/// 
-/// 
-public class DeviceBoundSessionOptions
-{
-    /// 
-    /// Gets or sets whether Device Bound Session Credentials are enabled.
-    /// Default is false.
-    /// 
-    public bool Enabled { get; set; }
-
-    /// 
-    /// Gets or sets the path for the DBSC registration endpoint.
-    /// The browser POSTs the public key to this path after receiving the
-    /// Secure-Session-Registration header.
-    /// 
-    /// 
-    /// This path must be same-site with the cookie's domain.
-    /// 
-    public PathString RegistrationPath { get; set; } = new PathString("/.well-known/dbsc/registration");
-
-    /// 
-    /// Gets or sets the path for the DBSC refresh endpoint.
-    /// The browser POSTs to this path when the short-lived cookie expires
-    /// to prove possession of the private key.
-    /// 
-    /// 
-    /// This path must be same-site with the cookie's domain.
-    /// 
-    public PathString RefreshPath { get; set; } = new PathString("/.well-known/dbsc/refresh");
-
-    /// 
-    /// Gets or sets the expiration time for the short-lived (bound) cookie.
-    /// When this cookie expires, the browser must prove possession of the
-    /// device-bound private key to obtain a new one.
-    /// 
-    /// 
-    /// Default is 10 minutes. Shorter values increase security but also increase
-    /// refresh frequency (and TPM/network load).
-    /// 
-    public TimeSpan ShortLivedCookieExpiration { get; set; } = TimeSpan.FromMinutes(10);
-
-    /// 
-    /// Gets or sets the name of the short-lived (bound) cookie.
-    /// This is the cookie that DBSC monitors for expiration.
-    /// 
-    /// 
-    /// If not set, defaults to the authentication cookie name with a __dbsc suffix.
-    /// 
-    public string? ShortLivedCookieName { get; set; }
-
-    /// 
-    /// Gets or sets the supported signing algorithms for DBSC key pairs.
-    /// Default is ES256 and RS256.
-    /// 
-    /// 
-    /// The algorithms are listed in order of preference. The browser will select
-    /// the first algorithm it supports from this list.
-    /// 
-    public IList SupportedAlgorithms { get; set; } = new List { "ES256", "RS256" };
-
-    /// 
-    /// Gets or sets whether the session scope should include the entire site
-    /// (all subdomains) or just the origin.
-    /// 
-    /// 
-    /// When true, the DBSC session applies to all subdomains of the registrable domain.
-    /// When false (default), it applies only to the exact origin.
-    /// 
-    public bool IncludeSite { get; set; }
-
-    /// 
-    /// Gets or sets the scope specifications for the session.
-    /// These define include/exclude rules for specific domain/path patterns.
-    /// 
-    public IList ScopeSpecifications { get; set; } = new List();
-
-    /// 
-    /// Gets or sets the hosts allowed to initiate DBSC refreshes from
-    /// cross-origin contexts.
-    /// 
-    public IList AllowedRefreshInitiators { get; set; } = new List();
-
-    /// 
-    /// Gets or sets the maximum age for a challenge before it is considered stale.
-    /// Default is 5 minutes.
-    /// 
-    public TimeSpan ChallengeMaxAge { get; set; } = TimeSpan.FromMinutes(5);
-}
diff --git a/src/Security/Authentication/Cookies/src/DeviceBoundSessionScopeRule.cs b/src/Security/Authentication/Cookies/src/DeviceBoundSessionScopeRule.cs
deleted file mode 100644
index c7da36edeebc..000000000000
--- a/src/Security/Authentication/Cookies/src/DeviceBoundSessionScopeRule.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-namespace Microsoft.AspNetCore.Authentication.Cookies;
-
-/// 
-/// Represents a scope specification rule for Device Bound Session Credentials.
-/// Scope rules define which URL patterns are included or excluded from the DBSC session.
-/// 
-public class DeviceBoundSessionScopeRule
-{
-    /// 
-    /// Gets or sets the type of scope rule: "include" or "exclude".
-    /// 
-    public required string Type { get; set; }
-
-    /// 
-    /// Gets or sets the domain pattern for the rule.
-    /// Supports wildcards (e.g., "*.example.com"). Defaults to "*" (all domains).
-    /// 
-    public string Domain { get; set; } = "*";
-
-    /// 
-    /// Gets or sets the path prefix for the rule.
-    /// Defaults to "/" (all paths).
-    /// 
-    public string Path { get; set; } = "/";
-}
diff --git a/src/Security/Authentication/Cookies/src/IDeviceBoundSessionStore.cs b/src/Security/Authentication/Cookies/src/IDeviceBoundSessionStore.cs
deleted file mode 100644
index 5f06b217a2a1..000000000000
--- a/src/Security/Authentication/Cookies/src/IDeviceBoundSessionStore.cs
+++ /dev/null
@@ -1,50 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-namespace Microsoft.AspNetCore.Authentication.Cookies;
-
-/// 
-/// An optional interface for server-side storage of Device Bound Session data.
-/// This is not required for basic DBSC operation (the public key is stored in the long-lived cookie),
-/// but can be used for session revocation and tracking scenarios.
-/// 
-/// 
-/// 
-/// By default, DBSC operates statelessly: the public key is embedded in the data-protected
-/// long-lived cookie, and challenges are self-contained. This interface enables additional
-/// server-side capabilities:
-/// 
-/// 
-/// Session revocation: explicitly revoke a bound session before cookie expiry.
-/// Audit: track active device-bound sessions per user.
-/// Key rotation: force re-registration by revoking the current session.
-/// 
-/// 
-public interface IDeviceBoundSessionStore
-{
-    /// 
-    /// Stores a device-bound session record, associating the session identifier with metadata.
-    /// 
-    /// The unique session identifier.
-    /// The JSON Web Key (JWK) of the device's public key.
-    /// The cancellation token.
-    /// A task representing the asynchronous operation.
-    Task StoreAsync(string sessionId, string publicKeyJwk, CancellationToken cancellationToken = default);
-
-    /// 
-    /// Checks whether a device-bound session has been revoked.
-    /// 
-    /// The session identifier to check.
-    /// The cancellation token.
-    /// true if the session has been revoked; otherwise, false.
-    Task IsRevokedAsync(string sessionId, CancellationToken cancellationToken = default);
-
-    /// 
-    /// Revokes a device-bound session. After revocation, refresh attempts will be rejected
-    /// and the browser session will be terminated.
-    /// 
-    /// The session identifier to revoke.
-    /// The cancellation token.
-    /// A task representing the asynchronous operation.
-    Task RevokeAsync(string sessionId, CancellationToken cancellationToken = default);
-}
diff --git a/src/Security/Authentication/Cookies/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/Cookies/src/PublicAPI.Unshipped.txt
index 30027afe1393..7dc5c58110bf 100644
--- a/src/Security/Authentication/Cookies/src/PublicAPI.Unshipped.txt
+++ b/src/Security/Authentication/Cookies/src/PublicAPI.Unshipped.txt
@@ -1,77 +1 @@
 #nullable enable
-Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions.DeviceBoundSession.get -> Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions?
-Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions.DeviceBoundSession.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.DeviceBoundSessionOptions() -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.AllowedRefreshInitiators.get -> System.Collections.Generic.IList!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.AllowedRefreshInitiators.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ChallengeMaxAge.get -> System.TimeSpan
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ChallengeMaxAge.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.Enabled.get -> bool
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.Enabled.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.IncludeSite.get -> bool
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.IncludeSite.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.RegistrationPath.get -> Microsoft.AspNetCore.Http.PathString
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.RegistrationPath.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.RefreshPath.get -> Microsoft.AspNetCore.Http.PathString
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.RefreshPath.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ScopeSpecifications.get -> System.Collections.Generic.IList!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ScopeSpecifications.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ShortLivedCookieExpiration.get -> System.TimeSpan
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ShortLivedCookieExpiration.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ShortLivedCookieName.get -> string?
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.ShortLivedCookieName.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.SupportedAlgorithms.get -> System.Collections.Generic.IList!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionOptions.SupportedAlgorithms.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.DeviceBoundSessionScopeRule() -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Domain.get -> string!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Domain.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Path.get -> string!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Path.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Type.get -> string!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRule.Type.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.DeviceBoundSessionConfiguration() -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.AllowedRefreshInitiators.get -> System.Collections.Generic.IList?
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.AllowedRefreshInitiators.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Continue.get -> bool?
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Continue.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Credentials.get -> System.Collections.Generic.IList!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Credentials.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.RefreshUrl.get -> string?
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.RefreshUrl.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Scope.get -> Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.Scope.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.SessionIdentifier.get -> string!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionConfiguration.SessionIdentifier.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.DeviceBoundSessionScopeConfiguration() -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.IncludeSite.get -> bool
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.IncludeSite.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.Origin.get -> string?
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.Origin.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.ScopeSpecification.get -> System.Collections.Generic.IList?
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeConfiguration.ScopeSpecification.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.DeviceBoundSessionScopeRuleConfiguration() -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Domain.get -> string?
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Domain.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Path.get -> string?
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Path.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Type.get -> string!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionScopeRuleConfiguration.Type.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.DeviceBoundSessionCredentialConfiguration() -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Attributes.get -> string?
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Attributes.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Name.get -> string!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Name.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Type.get -> string!
-Microsoft.AspNetCore.Authentication.Cookies.DeviceBoundSessionCredentialConfiguration.Type.set -> void
-Microsoft.AspNetCore.Authentication.Cookies.IDeviceBoundSessionStore
-Microsoft.AspNetCore.Authentication.Cookies.IDeviceBoundSessionStore.IsRevokedAsync(string! sessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
-Microsoft.AspNetCore.Authentication.Cookies.IDeviceBoundSessionStore.RevokeAsync(string! sessionId, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
-Microsoft.AspNetCore.Authentication.Cookies.IDeviceBoundSessionStore.StoreAsync(string! sessionId, string! publicKeyJwk, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
-Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions
-static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.UseDeviceBoundSessions(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app) -> Microsoft.AspNetCore.Builder.IApplicationBuilder!
diff --git a/src/Security/Authentication/test/DeviceBoundSessionTests.cs b/src/Security/Authentication/test/DeviceBoundSessionTests.cs
deleted file mode 100644
index 8d329e913bb3..000000000000
--- a/src/Security/Authentication/test/DeviceBoundSessionTests.cs
+++ /dev/null
@@ -1,521 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using System.Net;
-using System.Net.Http;
-using System.Security.Claims;
-using System.Security.Cryptography;
-using System.Text;
-using System.Text.Json;
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.TestHost;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-
-namespace Microsoft.AspNetCore.Authentication.Cookies;
-
-public class DeviceBoundSessionTests
-{
-    [Fact]
-    public async Task SignIn_WithDbscEnabled_EmitsRegistrationHeader()
-    {
-        using var host = await CreateDbscHost();
-        using var server = host.GetTestServer();
-
-        var response = await server.CreateClient().GetAsync("http://example.com/signin");
-
-        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
-        Assert.True(response.Headers.Contains("Secure-Session-Registration"));
-        var headerValue = response.Headers.GetValues("Secure-Session-Registration").Single();
-        Assert.Contains("ES256", headerValue);
-        Assert.Contains("RS256", headerValue);
-        Assert.Contains("path=\"/.well-known/dbsc/registration\"", headerValue);
-        Assert.Contains("challenge=", headerValue);
-    }
-
-    [Fact]
-    public async Task SignIn_WithoutDbscEnabled_DoesNotEmitRegistrationHeader()
-    {
-        using var host = await CreateHost(options => { });
-        using var server = host.GetTestServer();
-
-        var response = await server.CreateClient().GetAsync("http://example.com/signin");
-
-        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
-        Assert.False(response.Headers.Contains("Secure-Session-Registration"));
-    }
-
-    [Fact]
-    public async Task Registration_WithoutAuthCookie_Returns401()
-    {
-        using var host = await CreateDbscHost();
-        using var server = host.GetTestServer();
-
-        // Without a Secure-Session-Response header, returns 400 (missing header)
-        // With a header but no cookie, returns 401
-        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/registration");
-        request.Headers.Add("Secure-Session-Response", "\"invalid.jwt.here\"");
-        var response = await server.CreateClient().SendAsync(request);
-
-        // JWT is invalid so we get 400 first (can't parse)
-        // The handler checks JWT validity before cookie
-        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
-    }
-
-    [Fact]
-    public async Task Registration_WithoutJwtHeader_Returns400()
-    {
-        using var host = await CreateDbscHost();
-        using var server = host.GetTestServer();
-        var client = server.CreateClient();
-
-        // First sign in to get a cookie
-        var signInResponse = await client.GetAsync("http://example.com/signin");
-        var cookies = GetCookies(signInResponse);
-
-        // POST registration without Secure-Session-Response header
-        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/registration");
-        request.Headers.Add("Cookie", cookies);
-        var response = await client.SendAsync(request);
-
-        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
-    }
-
-    [Fact]
-    public async Task Registration_WithValidJwt_ReturnsSessionConfig()
-    {
-        using var host = await CreateDbscHost();
-        using var server = host.GetTestServer();
-        var client = server.CreateClient();
-
-        // Sign in
-        var signInResponse = await client.GetAsync("http://example.com/signin");
-        var cookies = GetCookies(signInResponse);
-
-        // Create a valid DBSC JWT with EC key
-        var (jwt, _) = CreateRegistrationJwt("test-challenge");
-
-        // POST registration
-        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/registration");
-        request.Headers.Add("Cookie", cookies);
-        request.Headers.Add("Secure-Session-Response", $"\"{jwt}\"");
-        var response = await client.SendAsync(request);
-
-        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
-
-        var content = await response.Content.ReadAsStringAsync();
-        var config = JsonDocument.Parse(content).RootElement;
-
-        Assert.True(config.TryGetProperty("session_identifier", out var sessionId));
-        Assert.False(string.IsNullOrEmpty(sessionId.GetString()));
-        Assert.True(config.TryGetProperty("refresh_url", out var refreshUrl));
-        Assert.Equal("/.well-known/dbsc/refresh", refreshUrl.GetString());
-        Assert.True(config.TryGetProperty("scope", out _));
-        Assert.True(config.TryGetProperty("credentials", out var credentials));
-        Assert.Equal("cookie", credentials[0].GetProperty("type").GetString());
-
-        // Should also set the short-lived cookie
-        Assert.True(response.Headers.Contains("Secure-Session-Challenge"));
-    }
-
-    [Fact]
-    public async Task Refresh_WithoutSessionId_Returns400()
-    {
-        using var host = await CreateDbscHost();
-        using var server = host.GetTestServer();
-
-        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/refresh");
-        var response = await server.CreateClient().SendAsync(request);
-
-        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
-    }
-
-    [Fact]
-    public async Task Refresh_WithoutProof_ReturnsChallenge()
-    {
-        using var host = await CreateDbscHost();
-        using var server = host.GetTestServer();
-        var client = server.CreateClient();
-
-        // Sign in and register
-        var (cookies, sessionId) = await SignInAndRegister(client);
-
-        // POST refresh without proof
-        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/refresh");
-        request.Headers.Add("Cookie", cookies);
-        request.Headers.Add("Sec-Secure-Session-Id", $"\"{sessionId}\"");
-        var response = await client.SendAsync(request);
-
-        Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
-        Assert.True(response.Headers.Contains("Secure-Session-Challenge"));
-        var challengeHeader = response.Headers.GetValues("Secure-Session-Challenge").Single();
-        Assert.Contains($"id=\"{sessionId}\"", challengeHeader);
-    }
-
-    [Fact]
-    public async Task Refresh_WithValidProof_IssuesNewCookie()
-    {
-        using var host = await CreateDbscHost();
-        using var server = host.GetTestServer();
-        var client = server.CreateClient();
-
-        // Sign in and register
-        var (cookies, sessionId, ecDsa) = await SignInAndRegisterWithKey(client);
-
-        // Get a challenge
-        var challengeRequest = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/refresh");
-        challengeRequest.Headers.Add("Cookie", cookies);
-        challengeRequest.Headers.Add("Sec-Secure-Session-Id", $"\"{sessionId}\"");
-        var challengeResponse = await client.SendAsync(challengeRequest);
-
-        Assert.Equal(HttpStatusCode.Forbidden, challengeResponse.StatusCode);
-        var challengeHeader = challengeResponse.Headers.GetValues("Secure-Session-Challenge").Single();
-        // Extract challenge value between first pair of quotes
-        var challenge = ExtractChallengeValue(challengeHeader);
-
-        // Sign the challenge
-        var proofJwt = CreateRefreshProofJwt(challenge, ecDsa);
-
-        // POST refresh with proof
-        var refreshRequest = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/refresh");
-        refreshRequest.Headers.Add("Cookie", cookies);
-        refreshRequest.Headers.Add("Sec-Secure-Session-Id", $"\"{sessionId}\"");
-        refreshRequest.Headers.Add("Secure-Session-Response", $"\"{proofJwt}\"");
-        var refreshResponse = await client.SendAsync(refreshRequest);
-
-        Assert.Equal(HttpStatusCode.OK, refreshResponse.StatusCode);
-
-        // Should contain new session config
-        var content = await refreshResponse.Content.ReadAsStringAsync();
-        var config = JsonDocument.Parse(content).RootElement;
-        Assert.Equal(sessionId, config.GetProperty("session_identifier").GetString());
-
-        // Should have Set-Cookie for short-lived cookie
-        Assert.True(refreshResponse.Headers.Contains("Secure-Session-Challenge"));
-    }
-
-    [Fact]
-    public void ChallengeGenerator_ValidChallenge_Validates()
-    {
-        var services = new ServiceCollection();
-        services.AddDataProtection();
-        var sp = services.BuildServiceProvider();
-        var dp = sp.GetRequiredService();
-
-        var generator = new DeviceBoundSessionChallengeGenerator(dp);
-        var challenge = generator.GenerateChallenge("session-123");
-
-        Assert.True(generator.ValidateChallenge(challenge, "session-123", TimeSpan.FromMinutes(5)));
-    }
-
-    [Fact]
-    public void ChallengeGenerator_WrongSessionId_Rejects()
-    {
-        var services = new ServiceCollection();
-        services.AddDataProtection();
-        var sp = services.BuildServiceProvider();
-        var dp = sp.GetRequiredService();
-
-        var generator = new DeviceBoundSessionChallengeGenerator(dp);
-        var challenge = generator.GenerateChallenge("session-123");
-
-        Assert.False(generator.ValidateChallenge(challenge, "session-456", TimeSpan.FromMinutes(5)));
-    }
-
-    [Fact]
-    public void ChallengeGenerator_ExpiredChallenge_Rejects()
-    {
-        var services = new ServiceCollection();
-        services.AddDataProtection();
-        var sp = services.BuildServiceProvider();
-        var dp = sp.GetRequiredService();
-
-        var generator = new DeviceBoundSessionChallengeGenerator(dp);
-        var challenge = generator.GenerateChallenge("session-123");
-
-        // Zero-second max age means it's immediately expired
-        Assert.False(generator.ValidateChallenge(challenge, "session-123", TimeSpan.Zero));
-    }
-
-    [Fact]
-    public void JwtValidator_ValidES256Jwt_Validates()
-    {
-        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
-        var jwt = CreateTestJwt(ecdsa, "test-challenge");
-        var jwk = ExportPublicKeyAsJwk(ecdsa);
-
-        var result = DeviceBoundSessionJwtValidator.Validate(jwt, jwk, "test-challenge");
-
-        Assert.NotNull(result);
-        Assert.Equal("ES256", result.Algorithm);
-        Assert.Equal("test-challenge", result.Challenge);
-    }
-
-    [Fact]
-    public void JwtValidator_WrongChallenge_ReturnsNull()
-    {
-        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
-        var jwt = CreateTestJwt(ecdsa, "actual-challenge");
-        var jwk = ExportPublicKeyAsJwk(ecdsa);
-
-        var result = DeviceBoundSessionJwtValidator.Validate(jwt, jwk, "expected-challenge");
-
-        Assert.Null(result);
-    }
-
-    [Fact]
-    public void JwtValidator_TamperedSignature_ReturnsNull()
-    {
-        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
-        var jwt = CreateTestJwt(ecdsa, "test-challenge");
-        var jwk = ExportPublicKeyAsJwk(ecdsa);
-
-        // Tamper with signature
-        var parts = jwt.Split('.');
-        var tamperedJwt = $"{parts[0]}.{parts[1]}.AAAA{parts[2][4..]}";
-
-        var result = DeviceBoundSessionJwtValidator.Validate(tamperedJwt, jwk, "test-challenge");
-
-        Assert.Null(result);
-    }
-
-    [Fact]
-    public void JwtValidator_WrongKey_ReturnsNull()
-    {
-        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
-        using var otherEcdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
-        var jwt = CreateTestJwt(ecdsa, "test-challenge");
-        var wrongJwk = ExportPublicKeyAsJwk(otherEcdsa);
-
-        var result = DeviceBoundSessionJwtValidator.Validate(jwt, wrongJwk, "test-challenge");
-
-        Assert.Null(result);
-    }
-
-    [Fact]
-    public void JwtValidator_ExtractPublicKeyJwk_ExtractsFromHeader()
-    {
-        using var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
-        var jwt = CreateRegistrationJwtWithKey(ecdsa, "challenge");
-
-        var jwk = DeviceBoundSessionJwtValidator.ExtractPublicKeyJwk(jwt);
-
-        Assert.NotNull(jwk);
-        Assert.Contains("\"kty\":\"EC\"", jwk);
-        Assert.Contains("\"crv\":\"P-256\"", jwk);
-    }
-
-    // Helper methods
-
-    private static Task CreateDbscHost()
-    {
-        return CreateHost(options =>
-        {
-            options.DeviceBoundSession = new DeviceBoundSessionOptions
-            {
-                Enabled = true
-            };
-        });
-    }
-
-    private static async Task CreateHost(Action configureOptions)
-    {
-        var host = new HostBuilder()
-            .ConfigureWebHost(builder =>
-                builder.UseTestServer()
-                    .ConfigureServices(services =>
-                    {
-                        services.AddDataProtection();
-                        services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
-                            .AddCookie(o =>
-                            {
-                                configureOptions(o);
-                            });
-                    })
-                    .Configure(app =>
-                    {
-                        app.UseAuthentication();
-                        app.UseDeviceBoundSessions();
-                        app.Use(async (context, next) =>
-                        {
-                            if (context.Request.Path == new PathString("/signin"))
-                            {
-                                var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
-                                identity.AddClaim(new Claim(ClaimTypes.Name, "Alice"));
-                                await context.SignInAsync(
-                                    CookieAuthenticationDefaults.AuthenticationScheme,
-                                    new ClaimsPrincipal(identity),
-                                    new AuthenticationProperties { IsPersistent = true });
-                            }
-                            else
-                            {
-                                await next(context);
-                            }
-                        });
-                    }))
-            .Build();
-
-        await host.StartAsync();
-        return host;
-    }
-
-    private static string GetCookies(HttpResponseMessage response)
-    {
-        if (!response.Headers.TryGetValues("Set-Cookie", out var setCookies))
-        {
-            return string.Empty;
-        }
-
-        var cookieParts = setCookies.Select(c => c.Split(';')[0]);
-        return string.Join("; ", cookieParts);
-    }
-
-    private async Task<(string cookies, string sessionId)> SignInAndRegister(HttpClient client)
-    {
-        var (cookies, sessionId, _) = await SignInAndRegisterWithKey(client);
-        return (cookies, sessionId);
-    }
-
-    private async Task<(string cookies, string sessionId, ECDsa key)> SignInAndRegisterWithKey(HttpClient client)
-    {
-        // Sign in
-        var signInResponse = await client.GetAsync("http://example.com/signin");
-        var cookies = GetCookies(signInResponse);
-
-        // Create registration JWT
-        var (jwt, ecDsa) = CreateRegistrationJwt("test-challenge");
-
-        // Register
-        var request = new HttpRequestMessage(HttpMethod.Post, "http://example.com/.well-known/dbsc/registration");
-        request.Headers.Add("Cookie", cookies);
-        request.Headers.Add("Secure-Session-Response", $"\"{jwt}\"");
-        var response = await client.SendAsync(request);
-
-        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
-
-        // Update cookies with registration response
-        var updatedCookies = GetCookies(response);
-        if (!string.IsNullOrEmpty(updatedCookies))
-        {
-            cookies = MergeCookies(cookies, updatedCookies);
-        }
-
-        var content = await response.Content.ReadAsStringAsync();
-        var config = JsonDocument.Parse(content).RootElement;
-        var sessionId = config.GetProperty("session_identifier").GetString()!;
-
-        return (cookies, sessionId, ecDsa);
-    }
-
-    private static string MergeCookies(string existing, string updates)
-    {
-        var cookieDict = new Dictionary();
-        foreach (var cookie in existing.Split("; ", StringSplitOptions.RemoveEmptyEntries))
-        {
-            var eqIdx = cookie.IndexOf('=');
-            if (eqIdx > 0)
-            {
-                cookieDict[cookie[..eqIdx]] = cookie[(eqIdx + 1)..];
-            }
-        }
-        foreach (var cookie in updates.Split("; ", StringSplitOptions.RemoveEmptyEntries))
-        {
-            var eqIdx = cookie.IndexOf('=');
-            if (eqIdx > 0)
-            {
-                cookieDict[cookie[..eqIdx]] = cookie[(eqIdx + 1)..];
-            }
-        }
-        return string.Join("; ", cookieDict.Select(kvp => $"{kvp.Key}={kvp.Value}"));
-    }
-
-    private static (string jwt, ECDsa key) CreateRegistrationJwt(string challenge)
-    {
-        var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
-        var jwt = CreateRegistrationJwtWithKey(ecdsa, challenge);
-        return (jwt, ecdsa);
-    }
-
-    private static string CreateRegistrationJwtWithKey(ECDsa ecdsa, string challenge)
-    {
-        var jwk = ExportPublicKeyAsJwk(ecdsa);
-        var header = JsonSerializer.Serialize(new
-        {
-            alg = "ES256",
-            typ = "dbsc+jwt",
-            jwk = JsonDocument.Parse(jwk).RootElement
-        });
-        var payload = JsonSerializer.Serialize(new { jti = challenge });
-
-        var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(header));
-        var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payload));
-        var signingInput = $"{headerB64}.{payloadB64}";
-        var signature = ecdsa.SignData(Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256);
-
-        return $"{signingInput}.{Base64UrlEncode(signature)}";
-    }
-
-    private static string CreateTestJwt(ECDsa ecdsa, string challenge)
-    {
-        var header = JsonSerializer.Serialize(new
-        {
-            alg = "ES256",
-            typ = "dbsc+jwt"
-        });
-        var payload = JsonSerializer.Serialize(new { jti = challenge });
-
-        var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(header));
-        var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payload));
-        var signingInput = $"{headerB64}.{payloadB64}";
-        var signature = ecdsa.SignData(Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256);
-
-        return $"{signingInput}.{Base64UrlEncode(signature)}";
-    }
-
-    private static string CreateRefreshProofJwt(string challenge, ECDsa ecdsa)
-    {
-        var header = JsonSerializer.Serialize(new
-        {
-            alg = "ES256",
-            typ = "dbsc+jwt"
-        });
-        var payload = JsonSerializer.Serialize(new { jti = challenge });
-
-        var headerB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(header));
-        var payloadB64 = Base64UrlEncode(Encoding.UTF8.GetBytes(payload));
-        var signingInput = $"{headerB64}.{payloadB64}";
-        var signature = ecdsa.SignData(Encoding.ASCII.GetBytes(signingInput), HashAlgorithmName.SHA256);
-
-        return $"{signingInput}.{Base64UrlEncode(signature)}";
-    }
-
-    private static string ExportPublicKeyAsJwk(ECDsa ecdsa)
-    {
-        var parameters = ecdsa.ExportParameters(false);
-        return JsonSerializer.Serialize(new
-        {
-            kty = "EC",
-            crv = "P-256",
-            x = Base64UrlEncode(parameters.Q.X!),
-            y = Base64UrlEncode(parameters.Q.Y!)
-        });
-    }
-
-    private static string ExtractChallengeValue(string challengeHeader)
-    {
-        // Format: "challenge_value";id="session_id"
-        var firstQuote = challengeHeader.IndexOf('"');
-        var secondQuote = challengeHeader.IndexOf('"', firstQuote + 1);
-        return challengeHeader[(firstQuote + 1)..secondQuote];
-    }
-
-    private static string Base64UrlEncode(byte[] data)
-    {
-        return Convert.ToBase64String(data)
-            .Replace('+', '-')
-            .Replace('/', '_')
-            .TrimEnd('=');
-    }
-}

From 35211fa103d96955c473b9cbb09df78f58ecfca9 Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 20:21:15 +0200
Subject: [PATCH 07/33] Improve DBSC handler: time-limited challenges,
 IPostConfigure, IdentityModel JWT

- Challenges now use ITimeLimitedDataProtector (5min expiry) and include
  the user's NameIdentifier claim for binding validation
- Registration validates NameIdentifier from challenge matches the
  authenticated principal
- Cookie sign-in header emission is now automatic via IPostConfigureOptions
  (no manual DeviceBoundSessionCookieEvents wiring in sample)
- JWT validation uses Microsoft.IdentityModel.JsonWebTokens
- Base64Url encoding uses WebEncoders from framework
- Added DataProtection.Extensions and WebUtilities references

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 eng/Dependencies.props                        |   4 +
 eng/SharedFramework.External.props            |   4 +
 eng/Versions.props                            |   3 +
 .../samples/DbscSampleV2/Program.cs           |   9 +-
 .../DeviceBoundSessionChallengeProtector.cs   |  58 +++++
 .../src/DeviceBoundSessionCookieEvents.cs     | 135 ++++++++--
 .../src/DeviceBoundSessionExtensions.cs       |   6 +
 .../src/DeviceBoundSessionHandler.cs          |  80 +++---
 .../src/DeviceBoundSessionJwtValidator.cs     | 241 +++---------------
 ....Authentication.DeviceBoundSessions.csproj |   3 +
 10 files changed, 267 insertions(+), 276 deletions(-)
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs

diff --git a/eng/Dependencies.props b/eng/Dependencies.props
index 8f21c1afe4c8..228a9a50b983 100644
--- a/eng/Dependencies.props
+++ b/eng/Dependencies.props
@@ -170,8 +170,12 @@ may be turned into `` items in projects.
     
     
     
+    
+    
+    
     
     
+    
     
     
     
diff --git a/eng/SharedFramework.External.props b/eng/SharedFramework.External.props
index c91c0b3421c3..8247996d42a0 100644
--- a/eng/SharedFramework.External.props
+++ b/eng/SharedFramework.External.props
@@ -33,6 +33,10 @@
     
     
     
+    
+    
+    
+    
     
     
     
diff --git a/eng/Versions.props b/eng/Versions.props
index c33d84408a46..888fc1eb6704 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -115,9 +115,12 @@
          This gets overriden by source-build, separately from MicrosoftCodeAnalysisVersion_LatestVS. -->
     $(MicrosoftCodeAnalysisVersion_LatestVS)
     1.0.0-20230414.1
+    $(IdentityModelVersion)
+    $(IdentityModelVersion)
     $(IdentityModelVersion)
     $(IdentityModelVersion)
     $(IdentityModelVersion)
+    $(IdentityModelVersion)
     2.2.1
     1.0.1
     3.0.1
diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs
index 18335a6952ee..5d923c2f2906 100644
--- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs
@@ -56,11 +56,6 @@ public void ConfigureServices(IServiceCollection services)
                 o.Cookie.Name = ".AspNetCore.Application";
                 o.LoginPath = "/login";
                 o.ExpireTimeSpan = TimeSpan.FromDays(7);
-                // Wire up DBSC event to emit Secure-Session-Registration header on sign-in
-                o.Events = new DeviceBoundSessionCookieEvents(new Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions
-                {
-                    RegistrationPath = DeviceBoundSessionDefaults.RegistrationPath,
-                });
             })
             // The DBSC handler + refresh/session cookie schemes + policy scheme
             .AddDeviceBoundSession(SourceScheme, o =>
@@ -113,10 +108,10 @@ await context.Response.WriteAsync("""
                 }
 
                 var identity = new ClaimsIdentity(SourceScheme);
+                identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, username));
                 identity.AddClaim(new Claim(ClaimTypes.Name, username));
 
-                // Sign in to the SOURCE scheme — this stamps the long-lived cookie
-                // and emits the Secure-Session-Registration header via the events
+                // Sign in to the source scheme.
                 await context.SignInAsync(
                     SourceScheme,
                     new ClaimsPrincipal(identity),
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
new file mode 100644
index 000000000000..50df75b0b259
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
@@ -0,0 +1,58 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.WebUtilities;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+internal static class DeviceBoundSessionChallengeProtector
+{
+    internal const string RegistrationSessionId = "registration";
+    private const string ChallengePurpose = "Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.v1";
+
+    public static string GenerateChallenge(
+        IDataProtectionProvider dataProtectionProvider,
+        string? nameIdentifier,
+        string sessionId,
+        TimeSpan lifetime)
+    {
+        var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
+        var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(16));
+        var payload = $"{nameIdentifier ?? string.Empty}|{nonce}|{sessionId}";
+
+        return protector.Protect(payload, lifetime);
+    }
+
+    public static bool TryValidateChallenge(
+        IDataProtectionProvider dataProtectionProvider,
+        string challenge,
+        out DeviceBoundSessionChallengeMetadata metadata)
+    {
+        try
+        {
+            var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
+            var payload = protector.Unprotect(challenge);
+            var parts = payload.Split('|', 3);
+            if (parts.Length != 3)
+            {
+                metadata = default;
+                return false;
+            }
+
+            metadata = new DeviceBoundSessionChallengeMetadata(parts[0], parts[1], parts[2]);
+            return true;
+        }
+        catch (CryptographicException)
+        {
+            metadata = default;
+            return false;
+        }
+    }
+}
+
+internal readonly record struct DeviceBoundSessionChallengeMetadata(
+    string NameIdentifier,
+    string Nonce,
+    string SessionId);
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
index 4567da6b54f6..829d31240182 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
@@ -1,45 +1,136 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Security.Claims;
 using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.DataProtection;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
-/// 
-/// Cookie authentication events that emit the Secure-Session-Registration header on sign-in.
-/// Wire this into the source cookie scheme to trigger DBSC registration.
-/// 
-public class DeviceBoundSessionCookieEvents : CookieAuthenticationEvents
+internal sealed class PostConfigureDeviceBoundSessionCookieOptions : IPostConfigureOptions
 {
-    private readonly DeviceBoundSessionOptions _dbscOptions;
+    private readonly IOptions _sourceSchemes;
 
-    /// 
-    /// Initializes a new instance of .
-    /// 
-    /// The DBSC options.
-    public DeviceBoundSessionCookieEvents(DeviceBoundSessionOptions dbscOptions)
+    public PostConfigureDeviceBoundSessionCookieOptions(IOptions sourceSchemes)
     {
-        _dbscOptions = dbscOptions;
+        _sourceSchemes = sourceSchemes;
     }
 
-    /// 
-    public override Task SigningIn(CookieSigningInContext context)
+    public void PostConfigure(string? name, CookieAuthenticationOptions options)
     {
-        EmitRegistrationHeader(context.HttpContext);
-        return base.SigningIn(context);
+        ArgumentNullException.ThrowIfNull(name);
+
+        if (!_sourceSchemes.Value.Schemes.TryGetValue(name, out var dbscScheme))
+        {
+            return;
+        }
+
+        if (options.EventsType is null)
+        {
+            var priorSigningIn = options.Events.OnSigningIn;
+            options.Events.OnSigningIn = async context =>
+            {
+                EmitRegistrationHeader(context, dbscScheme);
+                await priorSigningIn(context);
+            };
+            return;
+        }
+
+        options.Events = new DeviceBoundSessionCookieEvents(dbscScheme, options.Events, options.EventsType);
+        options.EventsType = null;
+    }
+
+    internal static void EmitRegistrationHeader(CookieSigningInContext context, string dbscScheme)
+    {
+        var dbscOptions = context.HttpContext.RequestServices
+            .GetRequiredService>()
+            .Get(dbscScheme);
+        var dataProtectionProvider = context.HttpContext.RequestServices.GetRequiredService();
+        var challenge = DeviceBoundSessionChallengeProtector.GenerateChallenge(
+            dataProtectionProvider,
+            context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value,
+            DeviceBoundSessionChallengeProtector.RegistrationSessionId,
+            dbscOptions.ChallengeMaxAge);
+
+        var headerValue = $"(ES256 RS256);path=\"{dbscOptions.RegistrationPath.Value}\";challenge=\"{challenge}\"";
+        context.Response.Headers.Append("Secure-Session-Registration", headerValue);
+    }
+}
+
+internal sealed class DeviceBoundSessionCookieEvents : CookieAuthenticationEvents
+{
+    private readonly string _dbscScheme;
+    private readonly CookieAuthenticationEvents _innerEvents;
+    private readonly Type? _innerEventsType;
+
+    public DeviceBoundSessionCookieEvents(string dbscScheme, CookieAuthenticationEvents innerEvents, Type? innerEventsType)
+    {
+        _dbscScheme = dbscScheme;
+        _innerEvents = innerEvents;
+        _innerEventsType = innerEventsType;
+    }
+
+    public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
+    {
+        await GetInnerEvents(context.HttpContext).ValidatePrincipal(context);
+    }
+
+    public override async Task CheckSlidingExpiration(CookieSlidingExpirationContext context)
+    {
+        await GetInnerEvents(context.HttpContext).CheckSlidingExpiration(context);
+    }
+
+    public override async Task SigningIn(CookieSigningInContext context)
+    {
+        PostConfigureDeviceBoundSessionCookieOptions.EmitRegistrationHeader(context, _dbscScheme);
+        await GetInnerEvents(context.HttpContext).SigningIn(context);
     }
 
-    private void EmitRegistrationHeader(HttpContext httpContext)
+    public override async Task SignedIn(CookieSignedInContext context)
     {
-        var dataProtectionProvider = httpContext.RequestServices.GetRequiredService();
-        var protector = dataProtectionProvider.CreateProtector("Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.v1");
+        await GetInnerEvents(context.HttpContext).SignedIn(context);
+    }
 
-        var challenge = protector.Protect($"{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}|{Guid.NewGuid()}|registration");
+    public override async Task SigningOut(CookieSigningOutContext context)
+    {
+        await GetInnerEvents(context.HttpContext).SigningOut(context);
+    }
 
-        var headerValue = $"(ES256 RS256);path=\"{_dbscOptions.RegistrationPath.Value}\";challenge=\"{challenge}\"";
-        httpContext.Response.Headers.Append("Secure-Session-Registration", headerValue);
+    public override async Task RedirectToLogout(RedirectContext context)
+    {
+        await GetInnerEvents(context.HttpContext).RedirectToLogout(context);
+    }
+
+    public override async Task RedirectToLogin(RedirectContext context)
+    {
+        await GetInnerEvents(context.HttpContext).RedirectToLogin(context);
+    }
+
+    public override async Task RedirectToReturnUrl(RedirectContext context)
+    {
+        await GetInnerEvents(context.HttpContext).RedirectToReturnUrl(context);
     }
+
+    public override async Task RedirectToAccessDenied(RedirectContext context)
+    {
+        await GetInnerEvents(context.HttpContext).RedirectToAccessDenied(context);
+    }
+
+    private CookieAuthenticationEvents GetInnerEvents(HttpContext httpContext)
+    {
+        if (_innerEventsType is null)
+        {
+            return _innerEvents;
+        }
+
+        return (CookieAuthenticationEvents)httpContext.RequestServices.GetRequiredService(_innerEventsType);
+    }
+}
+
+internal sealed class DeviceBoundSessionSourceSchemes
+{
+    public IDictionary Schemes { get; } = new Dictionary(StringComparer.Ordinal);
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
index 880698b4cd66..c0244d2a0329 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
@@ -2,8 +2,11 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Options;
 using DbscOptions = Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions;
 
 namespace Microsoft.Extensions.DependencyInjection;
@@ -89,6 +92,9 @@ public static AuthenticationBuilder AddDeviceBoundSession(
             };
         });
 
+        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureDeviceBoundSessionCookieOptions>());
+        builder.Services.Configure(o => o.Schemes[sourceScheme] = authenticationScheme);
+
         // Add the DBSC protocol handler
         builder.AddScheme(authenticationScheme, o =>
         {
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
index 035283e873e2..dcb93d6d989a 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -2,12 +2,14 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Linq;
+using System.Security.Claims;
 using System.Security.Cryptography;
 using System.Text.Encodings.Web;
 using System.Text.Json;
 using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.DataProtection;
 using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.WebUtilities;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Options;
 
@@ -19,8 +21,6 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// 
 public class DeviceBoundSessionHandler : AuthenticationHandler, IAuthenticationRequestHandler
 {
-    private const string ChallengePurpose = "Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.v1";
-
     private readonly IDataProtectionProvider _dataProtectionProvider;
 
     /// 
@@ -78,7 +78,7 @@ private async Task HandleRegistrationAsync()
         responseHeader = responseHeader.Trim('"');
 
         // Validate the JWT and extract the public key (registration: no existing key to check against)
-        var jwtResult = DeviceBoundSessionJwtValidator.Validate(responseHeader, publicKeyJwk: null, expectedChallenge: null);
+        var jwtResult = await DeviceBoundSessionJwtValidator.ValidateAsync(responseHeader, publicKeyJwk: null, expectedChallenge: null);
         if (jwtResult is null)
         {
             Logger.LogWarning("DBSC registration: invalid JWT proof.");
@@ -97,6 +97,14 @@ private async Task HandleRegistrationAsync()
 
         var principal = authResult.Principal;
         var properties = authResult.Properties ?? new AuthenticationProperties();
+        var nameIdentifier = GetNameIdentifier(principal);
+
+        if (jwtResult.Challenge is null || !ValidateChallenge(jwtResult.Challenge, nameIdentifier, DeviceBoundSessionChallengeProtector.RegistrationSessionId))
+        {
+            Logger.LogWarning("DBSC registration: invalid challenge for user {NameIdentifier}.", nameIdentifier);
+            Response.StatusCode = StatusCodes.Status403Forbidden;
+            return;
+        }
 
         // Generate a session ID
         var sessionId = GenerateSessionId();
@@ -134,7 +142,7 @@ private async Task HandleRegistrationAsync()
         Response.ContentType = "application/json";
 
         // Include a challenge for the next refresh
-        var challenge = GenerateChallenge(sessionId);
+        var challenge = GenerateChallenge(nameIdentifier, sessionId);
         Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionId}\"";
 
         await JsonSerializer.SerializeAsync(Response.Body, config, DeviceBoundSessionJsonContext.Default.DeviceBoundSessionConfiguration, Context.RequestAborted);
@@ -184,7 +192,7 @@ private async Task HandleRefreshAsync()
         if (string.IsNullOrEmpty(proofHeader))
         {
             // No proof yet — issue a challenge (first leg of refresh)
-            var challenge = GenerateChallenge(sessionIdHeader);
+            var challenge = GenerateChallenge(GetNameIdentifier(authResult.Principal), sessionIdHeader);
             Response.StatusCode = StatusCodes.Status403Forbidden;
             Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionIdHeader}\"";
             return;
@@ -193,7 +201,7 @@ private async Task HandleRefreshAsync()
         proofHeader = proofHeader.Trim('"');
 
         // Validate the JWT proof against the public key from the refresh cookie
-        var jwtResult = DeviceBoundSessionJwtValidator.Validate(proofHeader, publicKeyJwk, expectedChallenge: null);
+        var jwtResult = await DeviceBoundSessionJwtValidator.ValidateAsync(proofHeader, publicKeyJwk, expectedChallenge: null);
         if (jwtResult is null)
         {
             Logger.LogWarning("DBSC refresh: invalid JWT signature for session {SessionId}.", sessionIdHeader);
@@ -201,8 +209,10 @@ private async Task HandleRefreshAsync()
             return;
         }
 
-        // Validate the challenge (jti) is one we issued and is fresh
-        if (jwtResult.Challenge is not null && !ValidateChallenge(jwtResult.Challenge, sessionIdHeader))
+        var nameIdentifier = GetNameIdentifier(authResult.Principal);
+
+        // Validate the challenge (jti) is one we issued and is fresh.
+        if (jwtResult.Challenge is null || !ValidateChallenge(jwtResult.Challenge, nameIdentifier, sessionIdHeader))
         {
             Logger.LogWarning("DBSC refresh: stale or invalid challenge for session {SessionId}.", sessionIdHeader);
             Response.StatusCode = StatusCodes.Status403Forbidden;
@@ -220,7 +230,7 @@ private async Task HandleRefreshAsync()
 
         // Return session configuration with new challenge
         var config = BuildSessionConfiguration(sessionIdHeader);
-        var nextChallenge = GenerateChallenge(sessionIdHeader);
+        var nextChallenge = GenerateChallenge(nameIdentifier, sessionIdHeader);
 
         Response.StatusCode = StatusCodes.Status200OK;
         Response.ContentType = "application/json";
@@ -286,51 +296,33 @@ private string ResolveSessionCookieName()
         return ".AspNetCore.Dbsc.Session";
     }
 
-    private string GenerateChallenge(string sessionId)
+    private string GenerateChallenge(string? nameIdentifier, string sessionId)
     {
-        var protector = _dataProtectionProvider.CreateProtector(ChallengePurpose);
-        var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
-        var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
-        var payload = $"{timestamp}|{nonce}|{sessionId}";
-        return protector.Protect(payload);
+        return DeviceBoundSessionChallengeProtector.GenerateChallenge(
+            _dataProtectionProvider,
+            nameIdentifier,
+            sessionId,
+            Options.ChallengeMaxAge);
     }
 
-    private bool ValidateChallenge(string challenge, string expectedSessionId)
+    private bool ValidateChallenge(string challenge, string? expectedNameIdentifier, string expectedSessionId)
     {
-        try
-        {
-            var protector = _dataProtectionProvider.CreateProtector(ChallengePurpose);
-            var payload = protector.Unprotect(challenge);
-            var parts = payload.Split('|', 3);
-            if (parts.Length != 3)
-            {
-                return false;
-            }
-
-            if (!long.TryParse(parts[0], out var timestamp))
-            {
-                return false;
-            }
-
-            var issued = DateTimeOffset.FromUnixTimeSeconds(timestamp);
-            if (DateTimeOffset.UtcNow - issued > Options.ChallengeMaxAge)
-            {
-                return false;
-            }
-
-            return string.Equals(parts[2], expectedSessionId, StringComparison.Ordinal);
-        }
-        catch
+        if (!DeviceBoundSessionChallengeProtector.TryValidateChallenge(_dataProtectionProvider, challenge, out var metadata))
         {
             return false;
         }
+
+        return string.Equals(metadata.NameIdentifier, expectedNameIdentifier ?? string.Empty, StringComparison.Ordinal) &&
+            string.Equals(metadata.SessionId, expectedSessionId, StringComparison.Ordinal);
     }
 
     private static string GenerateSessionId()
     {
-        return Convert.ToBase64String(RandomNumberGenerator.GetBytes(24))
-            .Replace('+', '-')
-            .Replace('/', '_')
-            .TrimEnd('=');
+        return WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(24));
+    }
+
+    private static string GetNameIdentifier(ClaimsPrincipal principal)
+    {
+        return principal.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
     }
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
index cce96e22b29d..62726029f82d 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
@@ -1,9 +1,9 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
-using System.Security.Cryptography;
-using System.Text;
 using System.Text.Json;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
@@ -12,6 +12,8 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// 
 internal static class DeviceBoundSessionJwtValidator
 {
+    private static readonly JsonWebTokenHandler _tokenHandler = new();
+
     /// 
     /// Validates a DBSC proof JWT and extracts its claims.
     /// 
@@ -19,263 +21,96 @@ internal static class DeviceBoundSessionJwtValidator
     /// The JWK JSON of the public key to validate against. If null, extracts from JWT header.
     /// The expected challenge value (jti claim).
     /// The parsed result, or null if validation fails.
-    public static DeviceBoundSessionJwtResult? Validate(string jwt, string? publicKeyJwk, string? expectedChallenge)
+    public static async Task ValidateAsync(string jwt, string? publicKeyJwk, string? expectedChallenge)
     {
-        var parts = jwt.Split('.');
-        if (parts.Length != 3)
-        {
-            return null;
-        }
-
-        var headerJson = Base64UrlDecode(parts[0]);
-        var payloadJson = Base64UrlDecode(parts[1]);
-        var signature = Base64UrlDecodeBytes(parts[2]);
-
-        if (headerJson is null || payloadJson is null || signature is null)
-        {
-            return null;
-        }
-
-        // Parse header
-        JsonElement header;
+        JsonWebToken token;
         try
         {
-            header = JsonDocument.Parse(headerJson).RootElement;
+            token = new JsonWebToken(jwt);
         }
-        catch (JsonException)
+        catch (ArgumentException)
         {
             return null;
         }
 
-        // Validate typ
-        if (!header.TryGetProperty("typ", out var typElement) ||
-            !string.Equals(typElement.GetString(), "dbsc+jwt", StringComparison.OrdinalIgnoreCase))
+        if (!token.TryGetHeaderValue("typ", out var tokenType) ||
+            !string.Equals(tokenType, "dbsc+jwt", StringComparison.Ordinal))
         {
             return null;
         }
 
-        // Get algorithm
-        if (!header.TryGetProperty("alg", out var algElement))
+        if (!token.TryGetHeaderValue("alg", out var algorithm) || string.IsNullOrEmpty(algorithm))
         {
             return null;
         }
-        var algorithm = algElement.GetString();
 
-        // Get JWK - from parameter or from header (registration)
         string? jwkJson = publicKeyJwk;
         if (jwkJson is null)
         {
-            if (!header.TryGetProperty("jwk", out var jwkElement))
+            if (!token.TryGetHeaderValue("jwk", out var jwkElement))
             {
                 return null;
             }
+
             jwkJson = jwkElement.GetRawText();
         }
 
-        // Validate signature
-        var signedData = Encoding.ASCII.GetBytes($"{parts[0]}.{parts[1]}");
-        if (!VerifySignature(algorithm, jwkJson, signedData, signature))
+        var securityKey = CreateSecurityKey(jwkJson, algorithm);
+        if (securityKey is null)
         {
             return null;
         }
 
-        // Parse payload
-        JsonElement payload;
-        try
+        var validationResult = await _tokenHandler.ValidateTokenAsync(jwt, new TokenValidationParameters
         {
-            payload = JsonDocument.Parse(payloadJson).RootElement;
-        }
-        catch (JsonException)
-        {
-            return null;
-        }
+            IssuerSigningKey = securityKey,
+            ValidateAudience = false,
+            ValidateIssuer = false,
+            ValidateIssuerSigningKey = true,
+            ValidateLifetime = false,
+        });
 
-        // Validate jti (challenge)
-        string? jti = null;
-        if (payload.TryGetProperty("jti", out var jtiElement))
+        if (!validationResult.IsValid)
         {
-            jti = jtiElement.GetString();
+            return null;
         }
 
-        if (expectedChallenge is not null && !string.Equals(jti, expectedChallenge, StringComparison.Ordinal))
+        token.TryGetPayloadValue("jti", out string? challenge);
+        if (expectedChallenge is not null && !string.Equals(challenge, expectedChallenge, StringComparison.Ordinal))
         {
             return null;
         }
 
-        // Extract authorization claim if present
-        string? authorization = null;
-        if (payload.TryGetProperty("authorization", out var authElement))
-        {
-            authorization = authElement.GetString();
-        }
+        token.TryGetPayloadValue("authorization", out string? authorization);
 
         return new DeviceBoundSessionJwtResult
         {
             Algorithm = algorithm,
             PublicKeyJwk = jwkJson,
-            Challenge = jti,
-            Authorization = authorization
+            Challenge = challenge,
+            Authorization = authorization,
         };
     }
 
-    /// 
-    /// Extracts the JWK from a DBSC registration JWT header without full validation.
-    /// Used during registration when we don't yet have a stored key.
-    /// 
-    public static string? ExtractPublicKeyJwk(string jwt)
+    private static SecurityKey? CreateSecurityKey(string jwkJson, string algorithm)
     {
-        var parts = jwt.Split('.');
-        if (parts.Length != 3)
-        {
-            return null;
-        }
-
-        var headerJson = Base64UrlDecode(parts[0]);
-        if (headerJson is null)
-        {
-            return null;
-        }
-
+        JsonWebKey jsonWebKey;
         try
         {
-            var header = JsonDocument.Parse(headerJson).RootElement;
-            if (header.TryGetProperty("jwk", out var jwkElement))
-            {
-                return jwkElement.GetRawText();
-            }
+            jsonWebKey = new JsonWebKey(jwkJson);
         }
-        catch (JsonException)
+        catch (ArgumentException)
         {
-            // Ignore parse errors
+            return null;
         }
 
-        return null;
-    }
-
-    private static bool VerifySignature(string? algorithm, string jwkJson, byte[] signedData, byte[] signature)
-    {
         return algorithm switch
         {
-            "ES256" => VerifyES256(jwkJson, signedData, signature),
-            "RS256" => VerifyRS256(jwkJson, signedData, signature),
-            _ => false
+            "ES256" when string.Equals(jsonWebKey.Kty, "EC", StringComparison.Ordinal) && string.Equals(jsonWebKey.Crv, "P-256", StringComparison.Ordinal) => jsonWebKey,
+            "RS256" when string.Equals(jsonWebKey.Kty, "RSA", StringComparison.Ordinal) => jsonWebKey,
+            _ => null,
         };
     }
-
-    private static bool VerifyES256(string jwkJson, byte[] signedData, byte[] signature)
-    {
-        try
-        {
-            using var doc = JsonDocument.Parse(jwkJson);
-            var jwk = doc.RootElement;
-
-            if (!jwk.TryGetProperty("kty", out var kty) || kty.GetString() != "EC")
-            {
-                return false;
-            }
-            if (!jwk.TryGetProperty("crv", out var crv) || crv.GetString() != "P-256")
-            {
-                return false;
-            }
-            if (!jwk.TryGetProperty("x", out var xProp) || !jwk.TryGetProperty("y", out var yProp))
-            {
-                return false;
-            }
-
-            var x = Base64UrlDecodeBytes(xProp.GetString()!);
-            var y = Base64UrlDecodeBytes(yProp.GetString()!);
-            if (x is null || y is null)
-            {
-                return false;
-            }
-
-            var parameters = new ECParameters
-            {
-                Curve = ECCurve.NamedCurves.nistP256,
-                Q = new ECPoint { X = x, Y = y }
-            };
-
-            using var ecdsa = ECDsa.Create(parameters);
-            return ecdsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence)
-                || ecdsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256);
-        }
-        catch (CryptographicException)
-        {
-            return false;
-        }
-        catch (JsonException)
-        {
-            return false;
-        }
-    }
-
-    private static bool VerifyRS256(string jwkJson, byte[] signedData, byte[] signature)
-    {
-        try
-        {
-            using var doc = JsonDocument.Parse(jwkJson);
-            var jwk = doc.RootElement;
-
-            if (!jwk.TryGetProperty("kty", out var kty) || kty.GetString() != "RSA")
-            {
-                return false;
-            }
-            if (!jwk.TryGetProperty("n", out var nProp) || !jwk.TryGetProperty("e", out var eProp))
-            {
-                return false;
-            }
-
-            var n = Base64UrlDecodeBytes(nProp.GetString()!);
-            var e = Base64UrlDecodeBytes(eProp.GetString()!);
-            if (n is null || e is null)
-            {
-                return false;
-            }
-
-            var parameters = new RSAParameters
-            {
-                Modulus = n,
-                Exponent = e
-            };
-
-            using var rsa = RSA.Create(parameters);
-            return rsa.VerifyData(signedData, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
-        }
-        catch (CryptographicException)
-        {
-            return false;
-        }
-        catch (JsonException)
-        {
-            return false;
-        }
-    }
-
-    private static string? Base64UrlDecode(string input)
-    {
-        var bytes = Base64UrlDecodeBytes(input);
-        return bytes is null ? null : Encoding.UTF8.GetString(bytes);
-    }
-
-    private static byte[]? Base64UrlDecodeBytes(string input)
-    {
-        // Replace URL-safe characters and add padding
-        var base64 = input.Replace('-', '+').Replace('_', '/');
-        switch (base64.Length % 4)
-        {
-            case 2: base64 += "=="; break;
-            case 3: base64 += "="; break;
-        }
-
-        try
-        {
-            return Convert.FromBase64String(base64);
-        }
-        catch (FormatException)
-        {
-            return null;
-        }
-    }
 }
 
 /// 
@@ -283,7 +118,7 @@ private static bool VerifyRS256(string jwkJson, byte[] signedData, byte[] signat
 /// 
 internal sealed class DeviceBoundSessionJwtResult
 {
-    public required string? Algorithm { get; init; }
+    public required string Algorithm { get; init; }
     public required string PublicKeyJwk { get; init; }
     public string? Challenge { get; init; }
     public string? Authorization { get; init; }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
index 09d476650218..a20842d7059f 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
+++ b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
@@ -22,9 +22,12 @@
     
     
     
+    
     
     
     
+    
+    
   
 
 

From 57cf7b7a010dd5b42a3014d85fb5ae7f03058e57 Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 21:02:31 +0200
Subject: [PATCH 08/33] Fix DBSC project to ship as NuGet package, not shared
 framework

- Remove IsAspNetCoreApp and IsPackable=false (matches JwtBearer pattern)
- Revert SharedFramework.External.props changes
- Add Microsoft.IdentityModel.JsonWebTokens to Dependencies.props/Versions.props
  as a LatestPackageReference (same as OpenIdConnect, WsFederation)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 eng/Dependencies.props                                        | 3 ---
 eng/SharedFramework.External.props                            | 4 ----
 eng/Versions.props                                            | 2 --
 ...osoft.AspNetCore.Authentication.DeviceBoundSessions.csproj | 3 ---
 4 files changed, 12 deletions(-)

diff --git a/eng/Dependencies.props b/eng/Dependencies.props
index 228a9a50b983..2fbd605802ec 100644
--- a/eng/Dependencies.props
+++ b/eng/Dependencies.props
@@ -170,12 +170,9 @@ may be turned into `` items in projects.
     
     
     
-    
     
-    
     
     
-    
     
     
     
diff --git a/eng/SharedFramework.External.props b/eng/SharedFramework.External.props
index 8247996d42a0..c91c0b3421c3 100644
--- a/eng/SharedFramework.External.props
+++ b/eng/SharedFramework.External.props
@@ -33,10 +33,6 @@
     
     
     
-    
-    
-    
-    
     
     
     
diff --git a/eng/Versions.props b/eng/Versions.props
index 888fc1eb6704..cc30f5cca4da 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -115,12 +115,10 @@
          This gets overriden by source-build, separately from MicrosoftCodeAnalysisVersion_LatestVS. -->
     $(MicrosoftCodeAnalysisVersion_LatestVS)
     1.0.0-20230414.1
-    $(IdentityModelVersion)
     $(IdentityModelVersion)
     $(IdentityModelVersion)
     $(IdentityModelVersion)
     $(IdentityModelVersion)
-    $(IdentityModelVersion)
     2.2.1
     1.0.1
     3.0.1
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
index a20842d7059f..a34f28b21485 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
+++ b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
@@ -3,11 +3,8 @@
   
     ASP.NET Core authentication handler for Device Bound Session Credentials (DBSC).
     $(DefaultNetCoreTargetFramework)
-    true
-    $(DefineConstants);SECURITY
     true
     aspnetcore;authentication;security;dbsc
-    false
     true
     enable
     $(NoWarn);RS0016;RS0026

From ff4b72e6c4c4556bf0cbc4f5e7d457eea01ab0e6 Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 21:41:56 +0200
Subject: [PATCH 09/33] Use antiforgery-style claim UID for challenge binding

Replace simple NameIdentifier with a stable user identifier that follows
the same priority as antiforgery: sub > NameIdentifier > UPN > SHA256
hash of all claims. This ensures the challenge is bound to the user
regardless of which identity claims are available.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../DeviceBoundSessionChallengeProtector.cs   | 82 ++++++++++++++++++-
 .../src/DeviceBoundSessionCookieEvents.cs     |  5 +-
 .../src/DeviceBoundSessionHandler.cs          | 28 +++----
 3 files changed, 92 insertions(+), 23 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
index 50df75b0b259..15bbdccd0529 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
@@ -1,7 +1,9 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Security.Claims;
 using System.Security.Cryptography;
+using System.Text;
 using Microsoft.AspNetCore.DataProtection;
 using Microsoft.AspNetCore.WebUtilities;
 
@@ -14,13 +16,14 @@ internal static class DeviceBoundSessionChallengeProtector
 
     public static string GenerateChallenge(
         IDataProtectionProvider dataProtectionProvider,
-        string? nameIdentifier,
+        ClaimsPrincipal principal,
         string sessionId,
         TimeSpan lifetime)
     {
         var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
+        var claimUid = ComputeClaimUid(principal);
         var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(16));
-        var payload = $"{nameIdentifier ?? string.Empty}|{nonce}|{sessionId}";
+        var payload = $"{claimUid}|{nonce}|{sessionId}";
 
         return protector.Protect(payload, lifetime);
     }
@@ -50,9 +53,82 @@ public static bool TryValidateChallenge(
             return false;
         }
     }
+
+    public static bool ValidateClaimUid(ClaimsPrincipal principal, string expectedClaimUid)
+    {
+        var actualClaimUid = ComputeClaimUid(principal);
+        return string.Equals(actualClaimUid, expectedClaimUid, StringComparison.Ordinal);
+    }
+
+    /// 
+    /// Computes a stable identifier for the user from their claims.
+    /// Follows the same priority as antiforgery:
+    /// 1. "sub" claim (OpenID Connect subject)
+    /// 2. ClaimTypes.NameIdentifier
+    /// 3. ClaimTypes.Upn
+    /// 4. SHA256 hash of all claims (sorted by type)
+    /// 
+    private static string ComputeClaimUid(ClaimsPrincipal principal)
+    {
+        foreach (var identity in principal.Identities)
+        {
+            if (!identity.IsAuthenticated)
+            {
+                continue;
+            }
+
+            // Try "sub" claim first (OIDC standard)
+            var subClaim = identity.FindFirst(c => string.Equals("sub", c.Type, StringComparison.Ordinal));
+            if (subClaim is not null && !string.IsNullOrEmpty(subClaim.Value))
+            {
+                return $"sub:{subClaim.Value}:{subClaim.Issuer}";
+            }
+
+            // Try NameIdentifier
+            var nameIdClaim = identity.FindFirst(c => string.Equals(ClaimTypes.NameIdentifier, c.Type, StringComparison.Ordinal));
+            if (nameIdClaim is not null && !string.IsNullOrEmpty(nameIdClaim.Value))
+            {
+                return $"nid:{nameIdClaim.Value}:{nameIdClaim.Issuer}";
+            }
+
+            // Try UPN
+            var upnClaim = identity.FindFirst(c => string.Equals(ClaimTypes.Upn, c.Type, StringComparison.Ordinal));
+            if (upnClaim is not null && !string.IsNullOrEmpty(upnClaim.Value))
+            {
+                return $"upn:{upnClaim.Value}:{upnClaim.Issuer}";
+            }
+        }
+
+        // Fallback: SHA256 hash of all claims sorted by type
+        var allClaims = new List();
+        foreach (var identity in principal.Identities)
+        {
+            if (identity.IsAuthenticated)
+            {
+                allClaims.AddRange(identity.Claims);
+            }
+        }
+
+        if (allClaims.Count == 0)
+        {
+            return string.Empty;
+        }
+
+        allClaims.Sort((a, b) => string.Compare(a.Type, b.Type, StringComparison.Ordinal));
+
+        using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+        foreach (var claim in allClaims)
+        {
+            hasher.AppendData(Encoding.UTF8.GetBytes(claim.Type));
+            hasher.AppendData(Encoding.UTF8.GetBytes(claim.Value));
+            hasher.AppendData(Encoding.UTF8.GetBytes(claim.Issuer));
+        }
+
+        return WebEncoders.Base64UrlEncode(hasher.GetHashAndReset());
+    }
 }
 
 internal readonly record struct DeviceBoundSessionChallengeMetadata(
-    string NameIdentifier,
+    string ClaimUid,
     string Nonce,
     string SessionId);
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
index 829d31240182..1ca64e1e6b8e 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
@@ -1,7 +1,6 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
-using System.Security.Claims;
 using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.DataProtection;
 using Microsoft.AspNetCore.Http;
@@ -49,9 +48,11 @@ internal static void EmitRegistrationHeader(CookieSigningInContext context, stri
             .GetRequiredService>()
             .Get(dbscScheme);
         var dataProtectionProvider = context.HttpContext.RequestServices.GetRequiredService();
+
+        var principal = context.Principal ?? new System.Security.Claims.ClaimsPrincipal();
         var challenge = DeviceBoundSessionChallengeProtector.GenerateChallenge(
             dataProtectionProvider,
-            context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value,
+            principal,
             DeviceBoundSessionChallengeProtector.RegistrationSessionId,
             dbscOptions.ChallengeMaxAge);
 
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
index dcb93d6d989a..57e9917641c7 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -97,11 +97,10 @@ private async Task HandleRegistrationAsync()
 
         var principal = authResult.Principal;
         var properties = authResult.Properties ?? new AuthenticationProperties();
-        var nameIdentifier = GetNameIdentifier(principal);
 
-        if (jwtResult.Challenge is null || !ValidateChallenge(jwtResult.Challenge, nameIdentifier, DeviceBoundSessionChallengeProtector.RegistrationSessionId))
+        if (jwtResult.Challenge is null || !ValidateChallenge(jwtResult.Challenge, principal, DeviceBoundSessionChallengeProtector.RegistrationSessionId))
         {
-            Logger.LogWarning("DBSC registration: invalid challenge for user {NameIdentifier}.", nameIdentifier);
+            Logger.LogWarning("DBSC registration: invalid challenge.");
             Response.StatusCode = StatusCodes.Status403Forbidden;
             return;
         }
@@ -142,7 +141,7 @@ private async Task HandleRegistrationAsync()
         Response.ContentType = "application/json";
 
         // Include a challenge for the next refresh
-        var challenge = GenerateChallenge(nameIdentifier, sessionId);
+        var challenge = GenerateChallenge(principal, sessionId);
         Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionId}\"";
 
         await JsonSerializer.SerializeAsync(Response.Body, config, DeviceBoundSessionJsonContext.Default.DeviceBoundSessionConfiguration, Context.RequestAborted);
@@ -192,7 +191,7 @@ private async Task HandleRefreshAsync()
         if (string.IsNullOrEmpty(proofHeader))
         {
             // No proof yet — issue a challenge (first leg of refresh)
-            var challenge = GenerateChallenge(GetNameIdentifier(authResult.Principal), sessionIdHeader);
+            var challenge = GenerateChallenge(authResult.Principal, sessionIdHeader);
             Response.StatusCode = StatusCodes.Status403Forbidden;
             Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionIdHeader}\"";
             return;
@@ -209,10 +208,8 @@ private async Task HandleRefreshAsync()
             return;
         }
 
-        var nameIdentifier = GetNameIdentifier(authResult.Principal);
-
         // Validate the challenge (jti) is one we issued and is fresh.
-        if (jwtResult.Challenge is null || !ValidateChallenge(jwtResult.Challenge, nameIdentifier, sessionIdHeader))
+        if (jwtResult.Challenge is null || !ValidateChallenge(jwtResult.Challenge, authResult.Principal, sessionIdHeader))
         {
             Logger.LogWarning("DBSC refresh: stale or invalid challenge for session {SessionId}.", sessionIdHeader);
             Response.StatusCode = StatusCodes.Status403Forbidden;
@@ -230,7 +227,7 @@ private async Task HandleRefreshAsync()
 
         // Return session configuration with new challenge
         var config = BuildSessionConfiguration(sessionIdHeader);
-        var nextChallenge = GenerateChallenge(nameIdentifier, sessionIdHeader);
+        var nextChallenge = GenerateChallenge(authResult.Principal, sessionIdHeader);
 
         Response.StatusCode = StatusCodes.Status200OK;
         Response.ContentType = "application/json";
@@ -296,23 +293,23 @@ private string ResolveSessionCookieName()
         return ".AspNetCore.Dbsc.Session";
     }
 
-    private string GenerateChallenge(string? nameIdentifier, string sessionId)
+    private string GenerateChallenge(ClaimsPrincipal principal, string sessionId)
     {
         return DeviceBoundSessionChallengeProtector.GenerateChallenge(
             _dataProtectionProvider,
-            nameIdentifier,
+            principal,
             sessionId,
             Options.ChallengeMaxAge);
     }
 
-    private bool ValidateChallenge(string challenge, string? expectedNameIdentifier, string expectedSessionId)
+    private bool ValidateChallenge(string challenge, ClaimsPrincipal principal, string expectedSessionId)
     {
         if (!DeviceBoundSessionChallengeProtector.TryValidateChallenge(_dataProtectionProvider, challenge, out var metadata))
         {
             return false;
         }
 
-        return string.Equals(metadata.NameIdentifier, expectedNameIdentifier ?? string.Empty, StringComparison.Ordinal) &&
+        return DeviceBoundSessionChallengeProtector.ValidateClaimUid(principal, metadata.ClaimUid) &&
             string.Equals(metadata.SessionId, expectedSessionId, StringComparison.Ordinal);
     }
 
@@ -320,9 +317,4 @@ private static string GenerateSessionId()
     {
         return WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(24));
     }
-
-    private static string GetNameIdentifier(ClaimsPrincipal principal)
-    {
-        return principal.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? string.Empty;
-    }
 }

From bef816ca6c29d81a29fa6624c1ea4fa2abfec7c1 Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 21:46:52 +0200
Subject: [PATCH 10/33] Split challenge into registration/refresh, use
 non-allocating APIs

- Registration challenge: claimUid|nonce (no session ID, not known yet)
- Refresh challenge: claimUid|nonce|sessionId (binds to specific session)
- Use RandomNumberGenerator.Fill(Span) instead of GetBytes()
- Use stackalloc for nonce/hash buffers
- Remove the RegistrationSessionId constant (was a design smell)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../DeviceBoundSessionChallengeProtector.cs   | 84 +++++++++++++++----
 .../src/DeviceBoundSessionCookieEvents.cs     |  3 +-
 .../src/DeviceBoundSessionHandler.cs          | 43 ++++++----
 3 files changed, 96 insertions(+), 34 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
index 15bbdccd0529..0de9957ec487 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
@@ -11,10 +11,30 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
 internal static class DeviceBoundSessionChallengeProtector
 {
-    internal const string RegistrationSessionId = "registration";
     private const string ChallengePurpose = "Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.v1";
 
-    public static string GenerateChallenge(
+    /// 
+    /// Generates a challenge for registration (no session ID yet).
+    /// 
+    public static string GenerateRegistrationChallenge(
+        IDataProtectionProvider dataProtectionProvider,
+        ClaimsPrincipal principal,
+        TimeSpan lifetime)
+    {
+        var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
+        var claimUid = ComputeClaimUid(principal);
+        Span nonceBytes = stackalloc byte[16];
+        RandomNumberGenerator.Fill(nonceBytes);
+        var nonce = WebEncoders.Base64UrlEncode(nonceBytes);
+        var payload = $"{claimUid}|{nonce}";
+
+        return protector.Protect(payload, lifetime);
+    }
+
+    /// 
+    /// Generates a challenge for refresh (session ID is known).
+    /// 
+    public static string GenerateRefreshChallenge(
         IDataProtectionProvider dataProtectionProvider,
         ClaimsPrincipal principal,
         string sessionId,
@@ -22,16 +42,49 @@ public static string GenerateChallenge(
     {
         var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
         var claimUid = ComputeClaimUid(principal);
-        var nonce = WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(16));
+        Span nonceBytes = stackalloc byte[16];
+        RandomNumberGenerator.Fill(nonceBytes);
+        var nonce = WebEncoders.Base64UrlEncode(nonceBytes);
         var payload = $"{claimUid}|{nonce}|{sessionId}";
 
         return protector.Protect(payload, lifetime);
     }
 
-    public static bool TryValidateChallenge(
+    /// 
+    /// Validates a registration challenge (no session ID expected).
+    /// 
+    public static bool TryValidateRegistrationChallenge(
         IDataProtectionProvider dataProtectionProvider,
         string challenge,
-        out DeviceBoundSessionChallengeMetadata metadata)
+        ClaimsPrincipal principal)
+    {
+        try
+        {
+            var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
+            var payload = protector.Unprotect(challenge);
+            var parts = payload.Split('|', 2);
+            if (parts.Length < 2)
+            {
+                return false;
+            }
+
+            var storedClaimUid = parts[0];
+            return ValidateClaimUid(principal, storedClaimUid);
+        }
+        catch (CryptographicException)
+        {
+            return false;
+        }
+    }
+
+    /// 
+    /// Validates a refresh challenge (session ID must match).
+    /// 
+    public static bool TryValidateRefreshChallenge(
+        IDataProtectionProvider dataProtectionProvider,
+        string challenge,
+        ClaimsPrincipal principal,
+        string expectedSessionId)
     {
         try
         {
@@ -40,21 +93,23 @@ public static bool TryValidateChallenge(
             var parts = payload.Split('|', 3);
             if (parts.Length != 3)
             {
-                metadata = default;
                 return false;
             }
 
-            metadata = new DeviceBoundSessionChallengeMetadata(parts[0], parts[1], parts[2]);
-            return true;
+            var storedClaimUid = parts[0];
+            // parts[1] is the nonce (not validated, just for uniqueness)
+            var storedSessionId = parts[2];
+
+            return ValidateClaimUid(principal, storedClaimUid) &&
+                string.Equals(storedSessionId, expectedSessionId, StringComparison.Ordinal);
         }
         catch (CryptographicException)
         {
-            metadata = default;
             return false;
         }
     }
 
-    public static bool ValidateClaimUid(ClaimsPrincipal principal, string expectedClaimUid)
+    private static bool ValidateClaimUid(ClaimsPrincipal principal, string expectedClaimUid)
     {
         var actualClaimUid = ComputeClaimUid(principal);
         return string.Equals(actualClaimUid, expectedClaimUid, StringComparison.Ordinal);
@@ -124,11 +179,8 @@ private static string ComputeClaimUid(ClaimsPrincipal principal)
             hasher.AppendData(Encoding.UTF8.GetBytes(claim.Issuer));
         }
 
-        return WebEncoders.Base64UrlEncode(hasher.GetHashAndReset());
+        Span hashBytes = stackalloc byte[32];
+        hasher.TryGetHashAndReset(hashBytes, out _);
+        return WebEncoders.Base64UrlEncode(hashBytes);
     }
 }
-
-internal readonly record struct DeviceBoundSessionChallengeMetadata(
-    string ClaimUid,
-    string Nonce,
-    string SessionId);
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
index 1ca64e1e6b8e..f4d3d64bb5c9 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
@@ -50,10 +50,9 @@ internal static void EmitRegistrationHeader(CookieSigningInContext context, stri
         var dataProtectionProvider = context.HttpContext.RequestServices.GetRequiredService();
 
         var principal = context.Principal ?? new System.Security.Claims.ClaimsPrincipal();
-        var challenge = DeviceBoundSessionChallengeProtector.GenerateChallenge(
+        var challenge = DeviceBoundSessionChallengeProtector.GenerateRegistrationChallenge(
             dataProtectionProvider,
             principal,
-            DeviceBoundSessionChallengeProtector.RegistrationSessionId,
             dbscOptions.ChallengeMaxAge);
 
         var headerValue = $"(ES256 RS256);path=\"{dbscOptions.RegistrationPath.Value}\";challenge=\"{challenge}\"";
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
index 57e9917641c7..189b0df9bd88 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -98,7 +98,7 @@ private async Task HandleRegistrationAsync()
         var principal = authResult.Principal;
         var properties = authResult.Properties ?? new AuthenticationProperties();
 
-        if (jwtResult.Challenge is null || !ValidateChallenge(jwtResult.Challenge, principal, DeviceBoundSessionChallengeProtector.RegistrationSessionId))
+        if (jwtResult.Challenge is null || !ValidateRegistrationChallenge(jwtResult.Challenge, principal))
         {
             Logger.LogWarning("DBSC registration: invalid challenge.");
             Response.StatusCode = StatusCodes.Status403Forbidden;
@@ -141,7 +141,7 @@ private async Task HandleRegistrationAsync()
         Response.ContentType = "application/json";
 
         // Include a challenge for the next refresh
-        var challenge = GenerateChallenge(principal, sessionId);
+        var challenge = GenerateRefreshChallenge(principal, sessionId);
         Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionId}\"";
 
         await JsonSerializer.SerializeAsync(Response.Body, config, DeviceBoundSessionJsonContext.Default.DeviceBoundSessionConfiguration, Context.RequestAborted);
@@ -191,7 +191,7 @@ private async Task HandleRefreshAsync()
         if (string.IsNullOrEmpty(proofHeader))
         {
             // No proof yet — issue a challenge (first leg of refresh)
-            var challenge = GenerateChallenge(authResult.Principal, sessionIdHeader);
+            var challenge = GenerateRefreshChallenge(authResult.Principal, sessionIdHeader);
             Response.StatusCode = StatusCodes.Status403Forbidden;
             Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionIdHeader}\"";
             return;
@@ -209,7 +209,7 @@ private async Task HandleRefreshAsync()
         }
 
         // Validate the challenge (jti) is one we issued and is fresh.
-        if (jwtResult.Challenge is null || !ValidateChallenge(jwtResult.Challenge, authResult.Principal, sessionIdHeader))
+        if (jwtResult.Challenge is null || !ValidateRefreshChallenge(jwtResult.Challenge, authResult.Principal, sessionIdHeader))
         {
             Logger.LogWarning("DBSC refresh: stale or invalid challenge for session {SessionId}.", sessionIdHeader);
             Response.StatusCode = StatusCodes.Status403Forbidden;
@@ -227,7 +227,7 @@ private async Task HandleRefreshAsync()
 
         // Return session configuration with new challenge
         var config = BuildSessionConfiguration(sessionIdHeader);
-        var nextChallenge = GenerateChallenge(authResult.Principal, sessionIdHeader);
+        var nextChallenge = GenerateRefreshChallenge(authResult.Principal, sessionIdHeader);
 
         Response.StatusCode = StatusCodes.Status200OK;
         Response.ContentType = "application/json";
@@ -293,28 +293,39 @@ private string ResolveSessionCookieName()
         return ".AspNetCore.Dbsc.Session";
     }
 
-    private string GenerateChallenge(ClaimsPrincipal principal, string sessionId)
+    private string GenerateRegistrationChallenge(ClaimsPrincipal principal)
     {
-        return DeviceBoundSessionChallengeProtector.GenerateChallenge(
+        return DeviceBoundSessionChallengeProtector.GenerateRegistrationChallenge(
             _dataProtectionProvider,
             principal,
-            sessionId,
             Options.ChallengeMaxAge);
     }
 
-    private bool ValidateChallenge(string challenge, ClaimsPrincipal principal, string expectedSessionId)
+    private bool ValidateRegistrationChallenge(string challenge, ClaimsPrincipal principal)
     {
-        if (!DeviceBoundSessionChallengeProtector.TryValidateChallenge(_dataProtectionProvider, challenge, out var metadata))
-        {
-            return false;
-        }
+        return DeviceBoundSessionChallengeProtector.TryValidateRegistrationChallenge(
+            _dataProtectionProvider, challenge, principal);
+    }
+
+    private string GenerateRefreshChallenge(ClaimsPrincipal principal, string sessionId)
+    {
+        return DeviceBoundSessionChallengeProtector.GenerateRefreshChallenge(
+            _dataProtectionProvider,
+            principal,
+            sessionId,
+            Options.ChallengeMaxAge);
+    }
 
-        return DeviceBoundSessionChallengeProtector.ValidateClaimUid(principal, metadata.ClaimUid) &&
-            string.Equals(metadata.SessionId, expectedSessionId, StringComparison.Ordinal);
+    private bool ValidateRefreshChallenge(string challenge, ClaimsPrincipal principal, string expectedSessionId)
+    {
+        return DeviceBoundSessionChallengeProtector.TryValidateRefreshChallenge(
+            _dataProtectionProvider, challenge, principal, expectedSessionId);
     }
 
     private static string GenerateSessionId()
     {
-        return WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(24));
+        Span bytes = stackalloc byte[24];
+        RandomNumberGenerator.Fill(bytes);
+        return WebEncoders.Base64UrlEncode(bytes);
     }
 }

From 565190c199746adaa655ce8c7cc1247a0619857d Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 22:37:34 +0200
Subject: [PATCH 11/33] Use CBOR for challenge payloads, remove nonce

- Replace pipe-separated string with CBOR map encoding
  Registration: { uid: claimUid }
  Refresh: { uid: claimUid, sid: sessionId }
- Remove nonce (ITimeLimitedDataProtector already provides uniqueness
  and replay protection via timestamp + MAC)
- Use byte[] overload of ITimeLimitedDataProtector.Protect/Unprotect
- Add System.Formats.Cbor reference

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../DeviceBoundSessionChallengeProtector.cs   | 167 +++++++++++++-----
 ....Authentication.DeviceBoundSessions.csproj |   1 +
 2 files changed, 128 insertions(+), 40 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
index 0de9957ec487..ab213a8e1b30 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Formats.Cbor;
 using System.Security.Claims;
 using System.Security.Cryptography;
 using System.Text;
@@ -15,6 +16,7 @@ internal static class DeviceBoundSessionChallengeProtector
 
     /// 
     /// Generates a challenge for registration (no session ID yet).
+    /// Payload: CBOR map { "uid": claimUid }
     /// 
     public static string GenerateRegistrationChallenge(
         IDataProtectionProvider dataProtectionProvider,
@@ -23,16 +25,21 @@ public static string GenerateRegistrationChallenge(
     {
         var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
         var claimUid = ComputeClaimUid(principal);
-        Span nonceBytes = stackalloc byte[16];
-        RandomNumberGenerator.Fill(nonceBytes);
-        var nonce = WebEncoders.Base64UrlEncode(nonceBytes);
-        var payload = $"{claimUid}|{nonce}";
 
-        return protector.Protect(payload, lifetime);
+        var writer = new CborWriter();
+        writer.WriteStartMap(1);
+        writer.WriteTextString("uid");
+        writer.WriteTextString(claimUid);
+        writer.WriteEndMap();
+
+        var payload = writer.Encode();
+        return Convert.ToBase64String(protector.Protect(payload, lifetime))
+            .Replace('+', '-').Replace('/', '_').TrimEnd('=');
     }
 
     /// 
     /// Generates a challenge for refresh (session ID is known).
+    /// Payload: CBOR map { "uid": claimUid, "sid": sessionId }
     /// 
     public static string GenerateRefreshChallenge(
         IDataProtectionProvider dataProtectionProvider,
@@ -42,12 +49,18 @@ public static string GenerateRefreshChallenge(
     {
         var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
         var claimUid = ComputeClaimUid(principal);
-        Span nonceBytes = stackalloc byte[16];
-        RandomNumberGenerator.Fill(nonceBytes);
-        var nonce = WebEncoders.Base64UrlEncode(nonceBytes);
-        var payload = $"{claimUid}|{nonce}|{sessionId}";
 
-        return protector.Protect(payload, lifetime);
+        var writer = new CborWriter();
+        writer.WriteStartMap(2);
+        writer.WriteTextString("uid");
+        writer.WriteTextString(claimUid);
+        writer.WriteTextString("sid");
+        writer.WriteTextString(sessionId);
+        writer.WriteEndMap();
+
+        var payload = writer.Encode();
+        return Convert.ToBase64String(protector.Protect(payload, lifetime))
+            .Replace('+', '-').Replace('/', '_').TrimEnd('=');
     }
 
     /// 
@@ -58,23 +71,20 @@ public static bool TryValidateRegistrationChallenge(
         string challenge,
         ClaimsPrincipal principal)
     {
-        try
+        if (!TryUnprotectChallenge(dataProtectionProvider, challenge, out var payload))
         {
-            var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
-            var payload = protector.Unprotect(challenge);
-            var parts = payload.Split('|', 2);
-            if (parts.Length < 2)
-            {
-                return false;
-            }
-
-            var storedClaimUid = parts[0];
-            return ValidateClaimUid(principal, storedClaimUid);
+            return false;
         }
-        catch (CryptographicException)
+
+        var reader = new CborReader(payload);
+        var claimUid = ReadClaimUid(reader);
+        if (claimUid is null)
         {
             return false;
         }
+
+        var expected = ComputeClaimUid(principal);
+        return string.Equals(claimUid, expected, StringComparison.Ordinal);
     }
 
     /// 
@@ -85,34 +95,114 @@ public static bool TryValidateRefreshChallenge(
         string challenge,
         ClaimsPrincipal principal,
         string expectedSessionId)
+    {
+        if (!TryUnprotectChallenge(dataProtectionProvider, challenge, out var payload))
+        {
+            return false;
+        }
+
+        var reader = new CborReader(payload);
+        var (claimUid, sessionId) = ReadClaimUidAndSessionId(reader);
+        if (claimUid is null || sessionId is null)
+        {
+            return false;
+        }
+
+        var expected = ComputeClaimUid(principal);
+        return string.Equals(claimUid, expected, StringComparison.Ordinal) &&
+            string.Equals(sessionId, expectedSessionId, StringComparison.Ordinal);
+    }
+
+    private static bool TryUnprotectChallenge(
+        IDataProtectionProvider dataProtectionProvider,
+        string challenge,
+        out byte[] payload)
     {
         try
         {
             var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
-            var payload = protector.Unprotect(challenge);
-            var parts = payload.Split('|', 3);
-            if (parts.Length != 3)
+            // Decode URL-safe base64
+            var base64 = challenge.Replace('-', '+').Replace('_', '/');
+            switch (base64.Length % 4)
             {
-                return false;
+                case 2: base64 += "=="; break;
+                case 3: base64 += "="; break;
             }
+            var protectedBytes = Convert.FromBase64String(base64);
+            payload = protector.Unprotect(protectedBytes);
+            return true;
+        }
+        catch (Exception) when (IsExpectedUnprotectException())
+        {
+            payload = [];
+            return false;
+        }
+
+        // CryptographicException (expired/tampered) and FormatException (bad base64)
+        static bool IsExpectedUnprotectException() => true;
+    }
 
-            var storedClaimUid = parts[0];
-            // parts[1] is the nonce (not validated, just for uniqueness)
-            var storedSessionId = parts[2];
+    private static string? ReadClaimUid(CborReader reader)
+    {
+        try
+        {
+            var mapLength = reader.ReadStartMap();
+            string? claimUid = null;
+
+            for (var i = 0; i < (mapLength ?? 0); i++)
+            {
+                var key = reader.ReadTextString();
+                if (key == "uid")
+                {
+                    claimUid = reader.ReadTextString();
+                }
+                else
+                {
+                    reader.SkipValue();
+                }
+            }
 
-            return ValidateClaimUid(principal, storedClaimUid) &&
-                string.Equals(storedSessionId, expectedSessionId, StringComparison.Ordinal);
+            reader.ReadEndMap();
+            return claimUid;
         }
-        catch (CryptographicException)
+        catch (CborContentException)
         {
-            return false;
+            return null;
         }
     }
 
-    private static bool ValidateClaimUid(ClaimsPrincipal principal, string expectedClaimUid)
+    private static (string? ClaimUid, string? SessionId) ReadClaimUidAndSessionId(CborReader reader)
     {
-        var actualClaimUid = ComputeClaimUid(principal);
-        return string.Equals(actualClaimUid, expectedClaimUid, StringComparison.Ordinal);
+        try
+        {
+            var mapLength = reader.ReadStartMap();
+            string? claimUid = null;
+            string? sessionId = null;
+
+            for (var i = 0; i < (mapLength ?? 0); i++)
+            {
+                var key = reader.ReadTextString();
+                switch (key)
+                {
+                    case "uid":
+                        claimUid = reader.ReadTextString();
+                        break;
+                    case "sid":
+                        sessionId = reader.ReadTextString();
+                        break;
+                    default:
+                        reader.SkipValue();
+                        break;
+                }
+            }
+
+            reader.ReadEndMap();
+            return (claimUid, sessionId);
+        }
+        catch (CborContentException)
+        {
+            return (null, null);
+        }
     }
 
     /// 
@@ -132,21 +222,18 @@ private static string ComputeClaimUid(ClaimsPrincipal principal)
                 continue;
             }
 
-            // Try "sub" claim first (OIDC standard)
             var subClaim = identity.FindFirst(c => string.Equals("sub", c.Type, StringComparison.Ordinal));
             if (subClaim is not null && !string.IsNullOrEmpty(subClaim.Value))
             {
                 return $"sub:{subClaim.Value}:{subClaim.Issuer}";
             }
 
-            // Try NameIdentifier
             var nameIdClaim = identity.FindFirst(c => string.Equals(ClaimTypes.NameIdentifier, c.Type, StringComparison.Ordinal));
             if (nameIdClaim is not null && !string.IsNullOrEmpty(nameIdClaim.Value))
             {
                 return $"nid:{nameIdClaim.Value}:{nameIdClaim.Issuer}";
             }
 
-            // Try UPN
             var upnClaim = identity.FindFirst(c => string.Equals(ClaimTypes.Upn, c.Type, StringComparison.Ordinal));
             if (upnClaim is not null && !string.IsNullOrEmpty(upnClaim.Value))
             {
@@ -171,6 +258,7 @@ private static string ComputeClaimUid(ClaimsPrincipal principal)
 
         allClaims.Sort((a, b) => string.Compare(a.Type, b.Type, StringComparison.Ordinal));
 
+        Span hashBytes = stackalloc byte[SHA256.HashSizeInBytes];
         using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
         foreach (var claim in allClaims)
         {
@@ -179,7 +267,6 @@ private static string ComputeClaimUid(ClaimsPrincipal principal)
             hasher.AppendData(Encoding.UTF8.GetBytes(claim.Issuer));
         }
 
-        Span hashBytes = stackalloc byte[32];
         hasher.TryGetHashAndReset(hashBytes, out _);
         return WebEncoders.Base64UrlEncode(hashBytes);
     }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
index a34f28b21485..14118b3d03f6 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
+++ b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
@@ -25,6 +25,7 @@
     
     
     
+    
   
 
 

From 0bef89e0f9050f5c4d93c0b196b4d8f934af85c3 Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 22:43:36 +0200
Subject: [PATCH 12/33] Simplify challenge: CBOR array, no nonce, WebEncoders
 for base64url

- Use CBOR definite-length array instead of map (simpler, smaller):
  Registration: [ claimUid ]
  Refresh: [ claimUid, sessionId ]
- Use WebEncoders.Base64UrlEncode/Decode consistently (not manual replace)
- ITimeLimitedDataProtector byte[] API is appropriate here (not hot path,
  ISpanDataProtector doesn't support time-limited)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../DeviceBoundSessionChallengeProtector.cs   | 162 ++++++------------
 1 file changed, 49 insertions(+), 113 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
index ab213a8e1b30..168d5d89d546 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
@@ -16,7 +16,7 @@ internal static class DeviceBoundSessionChallengeProtector
 
     /// 
     /// Generates a challenge for registration (no session ID yet).
-    /// Payload: CBOR map { "uid": claimUid }
+    /// Payload: CBOR array [ claimUid ]
     /// 
     public static string GenerateRegistrationChallenge(
         IDataProtectionProvider dataProtectionProvider,
@@ -27,19 +27,17 @@ public static string GenerateRegistrationChallenge(
         var claimUid = ComputeClaimUid(principal);
 
         var writer = new CborWriter();
-        writer.WriteStartMap(1);
-        writer.WriteTextString("uid");
+        writer.WriteStartArray(1);
         writer.WriteTextString(claimUid);
-        writer.WriteEndMap();
+        writer.WriteEndArray();
 
-        var payload = writer.Encode();
-        return Convert.ToBase64String(protector.Protect(payload, lifetime))
-            .Replace('+', '-').Replace('/', '_').TrimEnd('=');
+        var protectedBytes = protector.Protect(writer.Encode(), lifetime);
+        return WebEncoders.Base64UrlEncode(protectedBytes);
     }
 
     /// 
     /// Generates a challenge for refresh (session ID is known).
-    /// Payload: CBOR map { "uid": claimUid, "sid": sessionId }
+    /// Payload: CBOR array [ claimUid, sessionId ]
     /// 
     public static string GenerateRefreshChallenge(
         IDataProtectionProvider dataProtectionProvider,
@@ -51,16 +49,13 @@ public static string GenerateRefreshChallenge(
         var claimUid = ComputeClaimUid(principal);
 
         var writer = new CborWriter();
-        writer.WriteStartMap(2);
-        writer.WriteTextString("uid");
+        writer.WriteStartArray(2);
         writer.WriteTextString(claimUid);
-        writer.WriteTextString("sid");
         writer.WriteTextString(sessionId);
-        writer.WriteEndMap();
+        writer.WriteEndArray();
 
-        var payload = writer.Encode();
-        return Convert.ToBase64String(protector.Protect(payload, lifetime))
-            .Replace('+', '-').Replace('/', '_').TrimEnd('=');
+        var protectedBytes = protector.Protect(writer.Encode(), lifetime);
+        return WebEncoders.Base64UrlEncode(protectedBytes);
     }
 
     /// 
@@ -71,20 +66,30 @@ public static bool TryValidateRegistrationChallenge(
         string challenge,
         ClaimsPrincipal principal)
     {
-        if (!TryUnprotectChallenge(dataProtectionProvider, challenge, out var payload))
+        if (!TryUnprotect(dataProtectionProvider, challenge, out var payload))
         {
             return false;
         }
 
-        var reader = new CborReader(payload);
-        var claimUid = ReadClaimUid(reader);
-        if (claimUid is null)
+        try
+        {
+            var reader = new CborReader(payload);
+            var count = reader.ReadStartArray();
+            if (count < 1)
+            {
+                return false;
+            }
+
+            var storedClaimUid = reader.ReadTextString();
+            reader.ReadEndArray();
+
+            var expected = ComputeClaimUid(principal);
+            return string.Equals(storedClaimUid, expected, StringComparison.Ordinal);
+        }
+        catch (CborContentException)
         {
             return false;
         }
-
-        var expected = ComputeClaimUid(principal);
-        return string.Equals(claimUid, expected, StringComparison.Ordinal);
     }
 
     /// 
@@ -96,124 +101,55 @@ public static bool TryValidateRefreshChallenge(
         ClaimsPrincipal principal,
         string expectedSessionId)
     {
-        if (!TryUnprotectChallenge(dataProtectionProvider, challenge, out var payload))
+        if (!TryUnprotect(dataProtectionProvider, challenge, out var payload))
         {
             return false;
         }
 
-        var reader = new CborReader(payload);
-        var (claimUid, sessionId) = ReadClaimUidAndSessionId(reader);
-        if (claimUid is null || sessionId is null)
-        {
-            return false;
-        }
-
-        var expected = ComputeClaimUid(principal);
-        return string.Equals(claimUid, expected, StringComparison.Ordinal) &&
-            string.Equals(sessionId, expectedSessionId, StringComparison.Ordinal);
-    }
-
-    private static bool TryUnprotectChallenge(
-        IDataProtectionProvider dataProtectionProvider,
-        string challenge,
-        out byte[] payload)
-    {
         try
         {
-            var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
-            // Decode URL-safe base64
-            var base64 = challenge.Replace('-', '+').Replace('_', '/');
-            switch (base64.Length % 4)
+            var reader = new CborReader(payload);
+            var count = reader.ReadStartArray();
+            if (count < 2)
             {
-                case 2: base64 += "=="; break;
-                case 3: base64 += "="; break;
+                return false;
             }
-            var protectedBytes = Convert.FromBase64String(base64);
-            payload = protector.Unprotect(protectedBytes);
-            return true;
-        }
-        catch (Exception) when (IsExpectedUnprotectException())
-        {
-            payload = [];
-            return false;
-        }
 
-        // CryptographicException (expired/tampered) and FormatException (bad base64)
-        static bool IsExpectedUnprotectException() => true;
-    }
-
-    private static string? ReadClaimUid(CborReader reader)
-    {
-        try
-        {
-            var mapLength = reader.ReadStartMap();
-            string? claimUid = null;
-
-            for (var i = 0; i < (mapLength ?? 0); i++)
-            {
-                var key = reader.ReadTextString();
-                if (key == "uid")
-                {
-                    claimUid = reader.ReadTextString();
-                }
-                else
-                {
-                    reader.SkipValue();
-                }
-            }
+            var storedClaimUid = reader.ReadTextString();
+            var storedSessionId = reader.ReadTextString();
+            reader.ReadEndArray();
 
-            reader.ReadEndMap();
-            return claimUid;
+            var expected = ComputeClaimUid(principal);
+            return string.Equals(storedClaimUid, expected, StringComparison.Ordinal) &&
+                string.Equals(storedSessionId, expectedSessionId, StringComparison.Ordinal);
         }
         catch (CborContentException)
         {
-            return null;
+            return false;
         }
     }
 
-    private static (string? ClaimUid, string? SessionId) ReadClaimUidAndSessionId(CborReader reader)
+    private static bool TryUnprotect(IDataProtectionProvider dataProtectionProvider, string challenge, out byte[] payload)
     {
         try
         {
-            var mapLength = reader.ReadStartMap();
-            string? claimUid = null;
-            string? sessionId = null;
-
-            for (var i = 0; i < (mapLength ?? 0); i++)
-            {
-                var key = reader.ReadTextString();
-                switch (key)
-                {
-                    case "uid":
-                        claimUid = reader.ReadTextString();
-                        break;
-                    case "sid":
-                        sessionId = reader.ReadTextString();
-                        break;
-                    default:
-                        reader.SkipValue();
-                        break;
-                }
-            }
-
-            reader.ReadEndMap();
-            return (claimUid, sessionId);
+            var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
+            var protectedBytes = WebEncoders.Base64UrlDecode(challenge);
+            payload = protector.Unprotect(protectedBytes);
+            return true;
         }
-        catch (CborContentException)
+        catch
         {
-            return (null, null);
+            payload = [];
+            return false;
         }
     }
 
     /// 
     /// Computes a stable identifier for the user from their claims.
-    /// Follows the same priority as antiforgery:
-    /// 1. "sub" claim (OpenID Connect subject)
-    /// 2. ClaimTypes.NameIdentifier
-    /// 3. ClaimTypes.Upn
-    /// 4. SHA256 hash of all claims (sorted by type)
+    /// Priority: sub > NameIdentifier > UPN > SHA256(all claims).
     /// 
-    private static string ComputeClaimUid(ClaimsPrincipal principal)
+    internal static string ComputeClaimUid(ClaimsPrincipal principal)
     {
         foreach (var identity in principal.Identities)
         {

From cc04e7ee07abfb62067e08d2943685b16a4b1d8f Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 22:47:22 +0200
Subject: [PATCH 13/33] Use bare CBOR sequence, default 10min session cookie
 expiry

- Challenge payload is just consecutive CBOR text strings, no array framing:
  Registration: textstring(claimUid)
  Refresh: textstring(claimUid) textstring(sessionId)
- Default ShortLivedCookieExpiration changed from 30s to 10 minutes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../DeviceBoundSessionChallengeProtector.cs   | 22 ++-----------------
 .../src/DeviceBoundSessionOptions.cs          |  4 ++--
 2 files changed, 4 insertions(+), 22 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
index 168d5d89d546..8e5b777cef01 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
@@ -16,7 +16,7 @@ internal static class DeviceBoundSessionChallengeProtector
 
     /// 
     /// Generates a challenge for registration (no session ID yet).
-    /// Payload: CBOR array [ claimUid ]
+    /// Payload: CBOR byte string (claimUid)
     /// 
     public static string GenerateRegistrationChallenge(
         IDataProtectionProvider dataProtectionProvider,
@@ -27,9 +27,7 @@ public static string GenerateRegistrationChallenge(
         var claimUid = ComputeClaimUid(principal);
 
         var writer = new CborWriter();
-        writer.WriteStartArray(1);
         writer.WriteTextString(claimUid);
-        writer.WriteEndArray();
 
         var protectedBytes = protector.Protect(writer.Encode(), lifetime);
         return WebEncoders.Base64UrlEncode(protectedBytes);
@@ -37,7 +35,7 @@ public static string GenerateRegistrationChallenge(
 
     /// 
     /// Generates a challenge for refresh (session ID is known).
-    /// Payload: CBOR array [ claimUid, sessionId ]
+    /// Payload: CBOR sequence (claimUid, sessionId)
     /// 
     public static string GenerateRefreshChallenge(
         IDataProtectionProvider dataProtectionProvider,
@@ -49,10 +47,8 @@ public static string GenerateRefreshChallenge(
         var claimUid = ComputeClaimUid(principal);
 
         var writer = new CborWriter();
-        writer.WriteStartArray(2);
         writer.WriteTextString(claimUid);
         writer.WriteTextString(sessionId);
-        writer.WriteEndArray();
 
         var protectedBytes = protector.Protect(writer.Encode(), lifetime);
         return WebEncoders.Base64UrlEncode(protectedBytes);
@@ -74,14 +70,7 @@ public static bool TryValidateRegistrationChallenge(
         try
         {
             var reader = new CborReader(payload);
-            var count = reader.ReadStartArray();
-            if (count < 1)
-            {
-                return false;
-            }
-
             var storedClaimUid = reader.ReadTextString();
-            reader.ReadEndArray();
 
             var expected = ComputeClaimUid(principal);
             return string.Equals(storedClaimUid, expected, StringComparison.Ordinal);
@@ -109,15 +98,8 @@ public static bool TryValidateRefreshChallenge(
         try
         {
             var reader = new CborReader(payload);
-            var count = reader.ReadStartArray();
-            if (count < 2)
-            {
-                return false;
-            }
-
             var storedClaimUid = reader.ReadTextString();
             var storedSessionId = reader.ReadTextString();
-            reader.ReadEndArray();
 
             var expected = ComputeClaimUid(principal);
             return string.Equals(storedClaimUid, expected, StringComparison.Ordinal) &&
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs
index 43f39dd1315a..961914d1eaa5 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs
@@ -42,9 +42,9 @@ public class DeviceBoundSessionOptions : AuthenticationSchemeOptions
 
     /// 
     /// Gets or sets the expiration for the short-lived session cookie.
-    /// Defaults to 30 seconds.
+    /// Defaults to 10 minutes.
     /// 
-    public TimeSpan ShortLivedCookieExpiration { get; set; } = TimeSpan.FromSeconds(30);
+    public TimeSpan ShortLivedCookieExpiration { get; set; } = TimeSpan.FromMinutes(10);
 
     /// 
     /// Gets or sets the maximum age for challenges before they are considered stale.

From ddb06185fc0e29031c8a2f081df3c63d8377d3d9 Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 23:32:13 +0200
Subject: [PATCH 14/33] Use existing IdentityModel package, CBOR-encode claim
 UIDs

- Switch from Microsoft.IdentityModel.JsonWebTokens (new entry) to
  Microsoft.IdentityModel.Protocols.OpenIdConnect (already in repo,
  transitively includes JsonWebTokens)
- Remove added Dependencies.props and Versions.props entries
- ComputeClaimUid now uses CBOR encoding for claim triples:
  - Known claims (sub/nid/upn): CBOR sequence of 3 text strings
  - Fallback: SHA256 of CBOR-encoded sorted claims (no intermediate strings)
- No more string interpolation for claim UIDs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 eng/Dependencies.props                        |  1 -
 eng/Versions.props                            |  1 -
 .../DeviceBoundSessionChallengeProtector.cs   | 39 +++++++++++++------
 ....Authentication.DeviceBoundSessions.csproj |  2 +-
 4 files changed, 28 insertions(+), 15 deletions(-)

diff --git a/eng/Dependencies.props b/eng/Dependencies.props
index 2fbd605802ec..8f21c1afe4c8 100644
--- a/eng/Dependencies.props
+++ b/eng/Dependencies.props
@@ -170,7 +170,6 @@ may be turned into `` items in projects.
     
     
     
-    
     
     
     
diff --git a/eng/Versions.props b/eng/Versions.props
index cc30f5cca4da..c33d84408a46 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -115,7 +115,6 @@
          This gets overriden by source-build, separately from MicrosoftCodeAnalysisVersion_LatestVS. -->
     $(MicrosoftCodeAnalysisVersion_LatestVS)
     1.0.0-20230414.1
-    $(IdentityModelVersion)
     $(IdentityModelVersion)
     $(IdentityModelVersion)
     $(IdentityModelVersion)
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
index 8e5b777cef01..f2b324439de8 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
@@ -4,7 +4,6 @@
 using System.Formats.Cbor;
 using System.Security.Claims;
 using System.Security.Cryptography;
-using System.Text;
 using Microsoft.AspNetCore.DataProtection;
 using Microsoft.AspNetCore.WebUtilities;
 
@@ -129,7 +128,7 @@ private static bool TryUnprotect(IDataProtectionProvider dataProtectionProvider,
 
     /// 
     /// Computes a stable identifier for the user from their claims.
-    /// Priority: sub > NameIdentifier > UPN > SHA256(all claims).
+    /// Priority: sub > NameIdentifier > UPN > SHA256(all claims as CBOR).
     /// 
     internal static string ComputeClaimUid(ClaimsPrincipal principal)
     {
@@ -143,23 +142,37 @@ internal static string ComputeClaimUid(ClaimsPrincipal principal)
             var subClaim = identity.FindFirst(c => string.Equals("sub", c.Type, StringComparison.Ordinal));
             if (subClaim is not null && !string.IsNullOrEmpty(subClaim.Value))
             {
-                return $"sub:{subClaim.Value}:{subClaim.Issuer}";
+                return EncodeClaim(subClaim);
             }
 
             var nameIdClaim = identity.FindFirst(c => string.Equals(ClaimTypes.NameIdentifier, c.Type, StringComparison.Ordinal));
             if (nameIdClaim is not null && !string.IsNullOrEmpty(nameIdClaim.Value))
             {
-                return $"nid:{nameIdClaim.Value}:{nameIdClaim.Issuer}";
+                return EncodeClaim(nameIdClaim);
             }
 
             var upnClaim = identity.FindFirst(c => string.Equals(ClaimTypes.Upn, c.Type, StringComparison.Ordinal));
             if (upnClaim is not null && !string.IsNullOrEmpty(upnClaim.Value))
             {
-                return $"upn:{upnClaim.Value}:{upnClaim.Issuer}";
+                return EncodeClaim(upnClaim);
             }
         }
 
-        // Fallback: SHA256 hash of all claims sorted by type
+        // Fallback: SHA256 hash of all claims encoded as CBOR
+        return ComputeClaimHashFallback(principal);
+    }
+
+    private static string EncodeClaim(Claim claim)
+    {
+        var writer = new CborWriter();
+        writer.WriteTextString(claim.Type);
+        writer.WriteTextString(claim.Value);
+        writer.WriteTextString(claim.Issuer);
+        return WebEncoders.Base64UrlEncode(writer.Encode());
+    }
+
+    private static string ComputeClaimHashFallback(ClaimsPrincipal principal)
+    {
         var allClaims = new List();
         foreach (var identity in principal.Identities)
         {
@@ -176,16 +189,18 @@ internal static string ComputeClaimUid(ClaimsPrincipal principal)
 
         allClaims.Sort((a, b) => string.Compare(a.Type, b.Type, StringComparison.Ordinal));
 
-        Span hashBytes = stackalloc byte[SHA256.HashSizeInBytes];
-        using var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
+        // Encode all claims as a CBOR sequence, then hash
+        var writer = new CborWriter();
         foreach (var claim in allClaims)
         {
-            hasher.AppendData(Encoding.UTF8.GetBytes(claim.Type));
-            hasher.AppendData(Encoding.UTF8.GetBytes(claim.Value));
-            hasher.AppendData(Encoding.UTF8.GetBytes(claim.Issuer));
+            writer.WriteTextString(claim.Type);
+            writer.WriteTextString(claim.Value);
+            writer.WriteTextString(claim.Issuer);
         }
 
-        hasher.TryGetHashAndReset(hashBytes, out _);
+        var encoded = writer.Encode();
+        Span hashBytes = stackalloc byte[SHA256.HashSizeInBytes];
+        SHA256.HashData(encoded, hashBytes);
         return WebEncoders.Base64UrlEncode(hashBytes);
     }
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
index 14118b3d03f6..55469611f2f1 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
+++ b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
@@ -24,7 +24,7 @@
     
     
     
-    
+    
     
   
 

From fc9397b517ff54986feedfb696eb089524fc557d Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 23:38:49 +0200
Subject: [PATCH 15/33] One class per file, remove unused defaults

- Split DeviceBoundSessionConfiguration.cs into separate files per class
- Split DeviceBoundSessionCookieEvents.cs into PostConfigure, EventsWrapper, SourceSchemes
- Move DeviceBoundSessionJwtResult to own file
- Remove unused SessionCookieSuffix and RefreshCookieSuffix from Defaults

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../src/DeviceBoundSessionConfiguration.cs    | 82 -------------------
 .../src/DeviceBoundSessionCookieEvents.cs     | 75 -----------------
 .../DeviceBoundSessionCookieEventsWrapper.cs  | 78 ++++++++++++++++++
 ...viceBoundSessionCredentialConfiguration.cs | 30 +++++++
 .../src/DeviceBoundSessionDefaults.cs         | 10 ---
 .../src/DeviceBoundSessionJsonContext.cs      | 15 ++++
 .../src/DeviceBoundSessionJwtResult.cs        | 15 ++++
 .../src/DeviceBoundSessionJwtValidator.cs     | 11 ---
 .../DeviceBoundSessionScopeConfiguration.cs   | 31 +++++++
 ...eviceBoundSessionScopeRuleConfiguration.cs | 30 +++++++
 .../src/DeviceBoundSessionSourceSchemes.cs    |  9 ++
 11 files changed, 208 insertions(+), 178 deletions(-)
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEventsWrapper.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCredentialConfiguration.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtResult.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeConfiguration.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRuleConfiguration.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConfiguration.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConfiguration.cs
index edc2bf530554..472e744e7ff6 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConfiguration.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConfiguration.cs
@@ -34,85 +34,3 @@ public sealed class DeviceBoundSessionConfiguration
     [JsonPropertyName("credentials")]
     public List? Credentials { get; set; }
 }
-
-/// 
-/// Represents the scope of a DBSC session.
-/// 
-public sealed class DeviceBoundSessionScopeConfiguration
-{
-    /// 
-    /// Gets or sets the origin for the session scope.
-    /// 
-    [JsonPropertyName("origin")]
-    public string? Origin { get; set; }
-
-    /// 
-    /// Gets or sets whether the session applies to the entire site.
-    /// 
-    [JsonPropertyName("include_site")]
-    public bool IncludeSite { get; set; }
-
-    /// 
-    /// Gets or sets the scope specification rules.
-    /// 
-    [JsonPropertyName("scope_specification")]
-    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
-    public List? ScopeSpecification { get; set; }
-}
-
-/// 
-/// Represents a scope specification rule in the DBSC session configuration.
-/// 
-public sealed class DeviceBoundSessionScopeRuleConfiguration
-{
-    /// 
-    /// Gets or sets the type ("include" or "exclude").
-    /// 
-    [JsonPropertyName("type")]
-    public string Type { get; set; } = default!;
-
-    /// 
-    /// Gets or sets the domain pattern.
-    /// 
-    [JsonPropertyName("domain")]
-    public string Domain { get; set; } = default!;
-
-    /// 
-    /// Gets or sets the path.
-    /// 
-    [JsonPropertyName("path")]
-    public string Path { get; set; } = default!;
-}
-
-/// 
-/// Represents a credential in the DBSC session configuration.
-/// 
-public sealed class DeviceBoundSessionCredentialConfiguration
-{
-    /// 
-    /// Gets or sets the credential type (always "cookie").
-    /// 
-    [JsonPropertyName("type")]
-    public string Type { get; set; } = "cookie";
-
-    /// 
-    /// Gets or sets the cookie name.
-    /// 
-    [JsonPropertyName("name")]
-    public string Name { get; set; } = default!;
-
-    /// 
-    /// Gets or sets the cookie attributes string.
-    /// 
-    [JsonPropertyName("attributes")]
-    public string Attributes { get; set; } = default!;
-}
-
-/// 
-/// Source-generated JSON serialization context for DBSC configuration types.
-/// 
-[JsonSerializable(typeof(DeviceBoundSessionConfiguration))]
-[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
-internal sealed partial class DeviceBoundSessionJsonContext : JsonSerializerContext
-{
-}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
index f4d3d64bb5c9..81cf8ced308b 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
@@ -59,78 +59,3 @@ internal static void EmitRegistrationHeader(CookieSigningInContext context, stri
         context.Response.Headers.Append("Secure-Session-Registration", headerValue);
     }
 }
-
-internal sealed class DeviceBoundSessionCookieEvents : CookieAuthenticationEvents
-{
-    private readonly string _dbscScheme;
-    private readonly CookieAuthenticationEvents _innerEvents;
-    private readonly Type? _innerEventsType;
-
-    public DeviceBoundSessionCookieEvents(string dbscScheme, CookieAuthenticationEvents innerEvents, Type? innerEventsType)
-    {
-        _dbscScheme = dbscScheme;
-        _innerEvents = innerEvents;
-        _innerEventsType = innerEventsType;
-    }
-
-    public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
-    {
-        await GetInnerEvents(context.HttpContext).ValidatePrincipal(context);
-    }
-
-    public override async Task CheckSlidingExpiration(CookieSlidingExpirationContext context)
-    {
-        await GetInnerEvents(context.HttpContext).CheckSlidingExpiration(context);
-    }
-
-    public override async Task SigningIn(CookieSigningInContext context)
-    {
-        PostConfigureDeviceBoundSessionCookieOptions.EmitRegistrationHeader(context, _dbscScheme);
-        await GetInnerEvents(context.HttpContext).SigningIn(context);
-    }
-
-    public override async Task SignedIn(CookieSignedInContext context)
-    {
-        await GetInnerEvents(context.HttpContext).SignedIn(context);
-    }
-
-    public override async Task SigningOut(CookieSigningOutContext context)
-    {
-        await GetInnerEvents(context.HttpContext).SigningOut(context);
-    }
-
-    public override async Task RedirectToLogout(RedirectContext context)
-    {
-        await GetInnerEvents(context.HttpContext).RedirectToLogout(context);
-    }
-
-    public override async Task RedirectToLogin(RedirectContext context)
-    {
-        await GetInnerEvents(context.HttpContext).RedirectToLogin(context);
-    }
-
-    public override async Task RedirectToReturnUrl(RedirectContext context)
-    {
-        await GetInnerEvents(context.HttpContext).RedirectToReturnUrl(context);
-    }
-
-    public override async Task RedirectToAccessDenied(RedirectContext context)
-    {
-        await GetInnerEvents(context.HttpContext).RedirectToAccessDenied(context);
-    }
-
-    private CookieAuthenticationEvents GetInnerEvents(HttpContext httpContext)
-    {
-        if (_innerEventsType is null)
-        {
-            return _innerEvents;
-        }
-
-        return (CookieAuthenticationEvents)httpContext.RequestServices.GetRequiredService(_innerEventsType);
-    }
-}
-
-internal sealed class DeviceBoundSessionSourceSchemes
-{
-    public IDictionary Schemes { get; } = new Dictionary(StringComparer.Ordinal);
-}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEventsWrapper.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEventsWrapper.cs
new file mode 100644
index 000000000000..9f1358170527
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEventsWrapper.cs
@@ -0,0 +1,78 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+internal sealed class DeviceBoundSessionCookieEvents : CookieAuthenticationEvents
+{
+    private readonly string _dbscScheme;
+    private readonly CookieAuthenticationEvents _innerEvents;
+    private readonly Type? _innerEventsType;
+
+    public DeviceBoundSessionCookieEvents(string dbscScheme, CookieAuthenticationEvents innerEvents, Type? innerEventsType)
+    {
+        _dbscScheme = dbscScheme;
+        _innerEvents = innerEvents;
+        _innerEventsType = innerEventsType;
+    }
+
+    public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
+    {
+        await GetInnerEvents(context.HttpContext).ValidatePrincipal(context);
+    }
+
+    public override async Task CheckSlidingExpiration(CookieSlidingExpirationContext context)
+    {
+        await GetInnerEvents(context.HttpContext).CheckSlidingExpiration(context);
+    }
+
+    public override async Task SigningIn(CookieSigningInContext context)
+    {
+        PostConfigureDeviceBoundSessionCookieOptions.EmitRegistrationHeader(context, _dbscScheme);
+        await GetInnerEvents(context.HttpContext).SigningIn(context);
+    }
+
+    public override async Task SignedIn(CookieSignedInContext context)
+    {
+        await GetInnerEvents(context.HttpContext).SignedIn(context);
+    }
+
+    public override async Task SigningOut(CookieSigningOutContext context)
+    {
+        await GetInnerEvents(context.HttpContext).SigningOut(context);
+    }
+
+    public override async Task RedirectToLogout(RedirectContext context)
+    {
+        await GetInnerEvents(context.HttpContext).RedirectToLogout(context);
+    }
+
+    public override async Task RedirectToLogin(RedirectContext context)
+    {
+        await GetInnerEvents(context.HttpContext).RedirectToLogin(context);
+    }
+
+    public override async Task RedirectToReturnUrl(RedirectContext context)
+    {
+        await GetInnerEvents(context.HttpContext).RedirectToReturnUrl(context);
+    }
+
+    public override async Task RedirectToAccessDenied(RedirectContext context)
+    {
+        await GetInnerEvents(context.HttpContext).RedirectToAccessDenied(context);
+    }
+
+    private CookieAuthenticationEvents GetInnerEvents(HttpContext httpContext)
+    {
+        if (_innerEventsType is null)
+        {
+            return _innerEvents;
+        }
+
+        return (CookieAuthenticationEvents)httpContext.RequestServices.GetRequiredService(_innerEventsType);
+    }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCredentialConfiguration.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCredentialConfiguration.cs
new file mode 100644
index 000000000000..f7ae028f7309
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCredentialConfiguration.cs
@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Represents a credential in the DBSC session configuration.
+/// 
+public sealed class DeviceBoundSessionCredentialConfiguration
+{
+    /// 
+    /// Gets or sets the credential type (always "cookie").
+    /// 
+    [JsonPropertyName("type")]
+    public string Type { get; set; } = "cookie";
+
+    /// 
+    /// Gets or sets the cookie name.
+    /// 
+    [JsonPropertyName("name")]
+    public string Name { get; set; } = default!;
+
+    /// 
+    /// Gets or sets the cookie attributes string.
+    /// 
+    [JsonPropertyName("attributes")]
+    public string Attributes { get; set; } = default!;
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs
index 54425e606ae9..315ec4577f47 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs
@@ -22,14 +22,4 @@ public static class DeviceBoundSessionDefaults
     /// The default refresh path.
     /// 
     public const string RefreshPath = "/.well-known/dbsc/refresh";
-
-    /// 
-    /// The default cookie name suffix for the session cookie.
-    /// 
-    public const string SessionCookieSuffix = "__dbsc";
-
-    /// 
-    /// The default cookie name suffix for the refresh cookie.
-    /// 
-    public const string RefreshCookieSuffix = "__dbsc_refresh";
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs
new file mode 100644
index 000000000000..449cec7ee4d6
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs
@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Source-generated JSON serialization context for DBSC configuration types.
+/// 
+[JsonSerializable(typeof(DeviceBoundSessionConfiguration))]
+[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
+internal sealed partial class DeviceBoundSessionJsonContext : JsonSerializerContext
+{
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtResult.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtResult.cs
new file mode 100644
index 000000000000..80508bc594d6
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtResult.cs
@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Result of validating a DBSC proof JWT.
+/// 
+internal sealed class DeviceBoundSessionJwtResult
+{
+    public required string Algorithm { get; init; }
+    public required string PublicKeyJwk { get; init; }
+    public string? Challenge { get; init; }
+    public string? Authorization { get; init; }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
index 62726029f82d..8e9797481aa2 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
@@ -112,14 +112,3 @@ internal static class DeviceBoundSessionJwtValidator
         };
     }
 }
-
-/// 
-/// Result of validating a DBSC proof JWT.
-/// 
-internal sealed class DeviceBoundSessionJwtResult
-{
-    public required string Algorithm { get; init; }
-    public required string PublicKeyJwk { get; init; }
-    public string? Challenge { get; init; }
-    public string? Authorization { get; init; }
-}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeConfiguration.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeConfiguration.cs
new file mode 100644
index 000000000000..90b1212842d2
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeConfiguration.cs
@@ -0,0 +1,31 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Represents the scope of a DBSC session.
+/// 
+public sealed class DeviceBoundSessionScopeConfiguration
+{
+    /// 
+    /// Gets or sets the origin for the session scope.
+    /// 
+    [JsonPropertyName("origin")]
+    public string? Origin { get; set; }
+
+    /// 
+    /// Gets or sets whether the session applies to the entire site.
+    /// 
+    [JsonPropertyName("include_site")]
+    public bool IncludeSite { get; set; }
+
+    /// 
+    /// Gets or sets the scope specification rules.
+    /// 
+    [JsonPropertyName("scope_specification")]
+    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+    public List? ScopeSpecification { get; set; }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRuleConfiguration.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRuleConfiguration.cs
new file mode 100644
index 000000000000..75f114f0207f
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRuleConfiguration.cs
@@ -0,0 +1,30 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Text.Json.Serialization;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Represents a scope specification rule in the DBSC session configuration.
+/// 
+public sealed class DeviceBoundSessionScopeRuleConfiguration
+{
+    /// 
+    /// Gets or sets the type ("include" or "exclude").
+    /// 
+    [JsonPropertyName("type")]
+    public string Type { get; set; } = default!;
+
+    /// 
+    /// Gets or sets the domain pattern.
+    /// 
+    [JsonPropertyName("domain")]
+    public string Domain { get; set; } = default!;
+
+    /// 
+    /// Gets or sets the path.
+    /// 
+    [JsonPropertyName("path")]
+    public string Path { get; set; } = default!;
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs
new file mode 100644
index 000000000000..b9d937b23f37
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs
@@ -0,0 +1,9 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+internal sealed class DeviceBoundSessionSourceSchemes
+{
+    public IDictionary Schemes { get; } = new Dictionary(StringComparer.Ordinal);
+}

From 7c419995648a89f4f0362ae53c850dbd64f1ff30 Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Thu, 4 Jun 2026 23:55:06 +0200
Subject: [PATCH 16/33] Copy source cookie settings to derived schemes,
 singleton challenge protector

- PostConfigureDeviceBoundSessionDerivedCookieOptions copies HttpOnly,
  Secure, SameSite, Domain, ExpireTimeSpan from source cookie scheme
  to refresh and session cookie schemes
- Refresh cookie overrides path to /.well-known/dbsc
- DeviceBoundSessionChallengeProtector is now a non-static singleton
  that caches the ITimeLimitedDataProtector instance
- Handler creates protector from injected IDataProtectionProvider
- Removed TryAddSingleton for protector (handler creates its own)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../DeviceBoundSessionChallengeProtector.cs   | 87 +++++--------------
 .../src/DeviceBoundSessionCookieEvents.cs     |  9 +-
 .../src/DeviceBoundSessionExtensions.cs       | 30 +++----
 .../src/DeviceBoundSessionHandler.cs          | 29 ++-----
 .../src/DeviceBoundSessionSourceSchemes.cs    |  7 ++
 ...eDeviceBoundSessionDerivedCookieOptions.cs | 70 +++++++++++++++
 6 files changed, 121 insertions(+), 111 deletions(-)
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
index f2b324439de8..714258eb368f 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
@@ -9,59 +9,37 @@
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
-internal static class DeviceBoundSessionChallengeProtector
+internal sealed class DeviceBoundSessionChallengeProtector
 {
-    private const string ChallengePurpose = "Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.v1";
-
-    /// 
-    /// Generates a challenge for registration (no session ID yet).
-    /// Payload: CBOR byte string (claimUid)
-    /// 
-    public static string GenerateRegistrationChallenge(
-        IDataProtectionProvider dataProtectionProvider,
-        ClaimsPrincipal principal,
-        TimeSpan lifetime)
+    private readonly ITimeLimitedDataProtector _protector;
+
+    public DeviceBoundSessionChallengeProtector(IDataProtectionProvider dataProtectionProvider)
     {
-        var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
-        var claimUid = ComputeClaimUid(principal);
+        _protector = dataProtectionProvider
+            .CreateProtector("Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.v1")
+            .ToTimeLimitedDataProtector();
+    }
 
+    public string GenerateRegistrationChallenge(ClaimsPrincipal principal, TimeSpan lifetime)
+    {
+        var claimUid = ComputeClaimUid(principal);
         var writer = new CborWriter();
         writer.WriteTextString(claimUid);
-
-        var protectedBytes = protector.Protect(writer.Encode(), lifetime);
-        return WebEncoders.Base64UrlEncode(protectedBytes);
+        return WebEncoders.Base64UrlEncode(_protector.Protect(writer.Encode(), lifetime));
     }
 
-    /// 
-    /// Generates a challenge for refresh (session ID is known).
-    /// Payload: CBOR sequence (claimUid, sessionId)
-    /// 
-    public static string GenerateRefreshChallenge(
-        IDataProtectionProvider dataProtectionProvider,
-        ClaimsPrincipal principal,
-        string sessionId,
-        TimeSpan lifetime)
+    public string GenerateRefreshChallenge(ClaimsPrincipal principal, string sessionId, TimeSpan lifetime)
     {
-        var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
         var claimUid = ComputeClaimUid(principal);
-
         var writer = new CborWriter();
         writer.WriteTextString(claimUid);
         writer.WriteTextString(sessionId);
-
-        var protectedBytes = protector.Protect(writer.Encode(), lifetime);
-        return WebEncoders.Base64UrlEncode(protectedBytes);
+        return WebEncoders.Base64UrlEncode(_protector.Protect(writer.Encode(), lifetime));
     }
 
-    /// 
-    /// Validates a registration challenge (no session ID expected).
-    /// 
-    public static bool TryValidateRegistrationChallenge(
-        IDataProtectionProvider dataProtectionProvider,
-        string challenge,
-        ClaimsPrincipal principal)
+    public bool TryValidateRegistrationChallenge(string challenge, ClaimsPrincipal principal)
     {
-        if (!TryUnprotect(dataProtectionProvider, challenge, out var payload))
+        if (!TryUnprotect(challenge, out var payload))
         {
             return false;
         }
@@ -70,9 +48,7 @@ public static bool TryValidateRegistrationChallenge(
         {
             var reader = new CborReader(payload);
             var storedClaimUid = reader.ReadTextString();
-
-            var expected = ComputeClaimUid(principal);
-            return string.Equals(storedClaimUid, expected, StringComparison.Ordinal);
+            return string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal);
         }
         catch (CborContentException)
         {
@@ -80,16 +56,9 @@ public static bool TryValidateRegistrationChallenge(
         }
     }
 
-    /// 
-    /// Validates a refresh challenge (session ID must match).
-    /// 
-    public static bool TryValidateRefreshChallenge(
-        IDataProtectionProvider dataProtectionProvider,
-        string challenge,
-        ClaimsPrincipal principal,
-        string expectedSessionId)
+    public bool TryValidateRefreshChallenge(string challenge, ClaimsPrincipal principal, string expectedSessionId)
     {
-        if (!TryUnprotect(dataProtectionProvider, challenge, out var payload))
+        if (!TryUnprotect(challenge, out var payload))
         {
             return false;
         }
@@ -99,9 +68,7 @@ public static bool TryValidateRefreshChallenge(
             var reader = new CborReader(payload);
             var storedClaimUid = reader.ReadTextString();
             var storedSessionId = reader.ReadTextString();
-
-            var expected = ComputeClaimUid(principal);
-            return string.Equals(storedClaimUid, expected, StringComparison.Ordinal) &&
+            return string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal) &&
                 string.Equals(storedSessionId, expectedSessionId, StringComparison.Ordinal);
         }
         catch (CborContentException)
@@ -110,13 +77,11 @@ public static bool TryValidateRefreshChallenge(
         }
     }
 
-    private static bool TryUnprotect(IDataProtectionProvider dataProtectionProvider, string challenge, out byte[] payload)
+    private bool TryUnprotect(string challenge, out byte[] payload)
     {
         try
         {
-            var protector = dataProtectionProvider.CreateProtector(ChallengePurpose).ToTimeLimitedDataProtector();
-            var protectedBytes = WebEncoders.Base64UrlDecode(challenge);
-            payload = protector.Unprotect(protectedBytes);
+            payload = _protector.Unprotect(WebEncoders.Base64UrlDecode(challenge));
             return true;
         }
         catch
@@ -126,10 +91,6 @@ private static bool TryUnprotect(IDataProtectionProvider dataProtectionProvider,
         }
     }
 
-    /// 
-    /// Computes a stable identifier for the user from their claims.
-    /// Priority: sub > NameIdentifier > UPN > SHA256(all claims as CBOR).
-    /// 
     internal static string ComputeClaimUid(ClaimsPrincipal principal)
     {
         foreach (var identity in principal.Identities)
@@ -158,7 +119,6 @@ internal static string ComputeClaimUid(ClaimsPrincipal principal)
             }
         }
 
-        // Fallback: SHA256 hash of all claims encoded as CBOR
         return ComputeClaimHashFallback(principal);
     }
 
@@ -189,7 +149,6 @@ private static string ComputeClaimHashFallback(ClaimsPrincipal principal)
 
         allClaims.Sort((a, b) => string.Compare(a.Type, b.Type, StringComparison.Ordinal));
 
-        // Encode all claims as a CBOR sequence, then hash
         var writer = new CborWriter();
         foreach (var claim in allClaims)
         {
@@ -203,4 +162,4 @@ private static string ComputeClaimHashFallback(ClaimsPrincipal principal)
         SHA256.HashData(encoded, hashBytes);
         return WebEncoders.Base64UrlEncode(hashBytes);
     }
-}
+}
\ No newline at end of file
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
index 81cf8ced308b..462e1ba02f01 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
@@ -2,7 +2,6 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.DataProtection;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Options;
@@ -47,13 +46,11 @@ internal static void EmitRegistrationHeader(CookieSigningInContext context, stri
         var dbscOptions = context.HttpContext.RequestServices
             .GetRequiredService>()
             .Get(dbscScheme);
-        var dataProtectionProvider = context.HttpContext.RequestServices.GetRequiredService();
+        var challengeProtector = context.HttpContext.RequestServices
+            .GetRequiredService();
 
         var principal = context.Principal ?? new System.Security.Claims.ClaimsPrincipal();
-        var challenge = DeviceBoundSessionChallengeProtector.GenerateRegistrationChallenge(
-            dataProtectionProvider,
-            principal,
-            dbscOptions.ChallengeMaxAge);
+        var challenge = challengeProtector.GenerateRegistrationChallenge(principal, dbscOptions.ChallengeMaxAge);
 
         var headerValue = $"(ES256 RS256);path=\"{dbscOptions.RegistrationPath.Value}\";challenge=\"{challenge}\"";
         context.Response.Headers.Append("Secure-Session-Registration", headerValue);
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
index c0244d2a0329..df59c525d801 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
@@ -4,7 +4,6 @@
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
-using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.DependencyInjection.Extensions;
 using Microsoft.Extensions.Options;
 using DbscOptions = Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions;
@@ -51,29 +50,18 @@ public static AuthenticationBuilder AddDeviceBoundSession(
         var sessionScheme = $"{sourceScheme}.Dbsc.Session";
         var policyScheme = $"{sourceScheme}.Dbsc";
 
-        // Add the refresh cookie scheme (path-scoped to /.well-known/dbsc/)
+        // Add the refresh cookie scheme — settings will be copied from the source
+        // scheme via PostConfigureDeviceBoundSessionDerivedCookieOptions
         builder.AddCookie(refreshScheme, o =>
         {
             o.Cookie.Name = $".AspNetCore.{sourceScheme}.Dbsc.Refresh";
             o.Cookie.Path = "/.well-known/dbsc";
-            o.Cookie.HttpOnly = true;
-            o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
-            o.Cookie.SameSite = SameSiteMode.Lax;
-            // Long-lived — matches original session lifetime
-            o.ExpireTimeSpan = TimeSpan.FromDays(7);
-            o.SlidingExpiration = false;
         });
 
-        // Add the session cookie scheme (short-lived, path=/)
+        // Add the session cookie scheme — settings copied from source, expiry overridden
         builder.AddCookie(sessionScheme, o =>
         {
             o.Cookie.Name = $".AspNetCore.{sourceScheme}.Dbsc.Session";
-            o.Cookie.Path = "/";
-            o.Cookie.HttpOnly = true;
-            o.Cookie.SecurePolicy = CookieSecurePolicy.Always;
-            o.Cookie.SameSite = SameSiteMode.Lax;
-            o.ExpireTimeSpan = TimeSpan.FromSeconds(30);
-            o.SlidingExpiration = false;
         });
 
         // Add a policy scheme that tries the session cookie first, then falls back to the source scheme
@@ -82,18 +70,24 @@ public static AuthenticationBuilder AddDeviceBoundSession(
         {
             o.ForwardDefaultSelector = context =>
             {
-                // If the short-lived session cookie exists, use it
                 if (context.Request.Cookies.ContainsKey(sessionCookieName))
                 {
                     return sessionScheme;
                 }
-                // Otherwise fall back to the source cookie (pre-registration or non-DBSC browser)
                 return sourceScheme;
             };
         });
 
+        // Register services
+        builder.Services.TryAddSingleton();
         builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureDeviceBoundSessionCookieOptions>());
-        builder.Services.Configure(o => o.Schemes[sourceScheme] = authenticationScheme);
+        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureDeviceBoundSessionDerivedCookieOptions>());
+        builder.Services.Configure(o =>
+        {
+            o.Schemes[sourceScheme] = authenticationScheme;
+            o.RefreshSchemes[refreshScheme] = sourceScheme;
+            o.SessionSchemes[sessionScheme] = sourceScheme;
+        });
 
         // Add the DBSC protocol handler
         builder.AddScheme(authenticationScheme, o =>
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
index 189b0df9bd88..fa8a544abd69 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -21,7 +21,7 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// 
 public class DeviceBoundSessionHandler : AuthenticationHandler, IAuthenticationRequestHandler
 {
-    private readonly IDataProtectionProvider _dataProtectionProvider;
+    private readonly DeviceBoundSessionChallengeProtector _challengeProtector;
 
     /// 
     /// Initializes a new instance of .
@@ -33,7 +33,7 @@ public DeviceBoundSessionHandler(
         IDataProtectionProvider dataProtectionProvider)
         : base(options, logger, encoder)
     {
-        _dataProtectionProvider = dataProtectionProvider;
+        _challengeProtector = new DeviceBoundSessionChallengeProtector(dataProtectionProvider);
     }
 
     /// 
@@ -294,33 +294,16 @@ private string ResolveSessionCookieName()
     }
 
     private string GenerateRegistrationChallenge(ClaimsPrincipal principal)
-    {
-        return DeviceBoundSessionChallengeProtector.GenerateRegistrationChallenge(
-            _dataProtectionProvider,
-            principal,
-            Options.ChallengeMaxAge);
-    }
+        => _challengeProtector.GenerateRegistrationChallenge(principal, Options.ChallengeMaxAge);
 
     private bool ValidateRegistrationChallenge(string challenge, ClaimsPrincipal principal)
-    {
-        return DeviceBoundSessionChallengeProtector.TryValidateRegistrationChallenge(
-            _dataProtectionProvider, challenge, principal);
-    }
+        => _challengeProtector.TryValidateRegistrationChallenge(challenge, principal);
 
     private string GenerateRefreshChallenge(ClaimsPrincipal principal, string sessionId)
-    {
-        return DeviceBoundSessionChallengeProtector.GenerateRefreshChallenge(
-            _dataProtectionProvider,
-            principal,
-            sessionId,
-            Options.ChallengeMaxAge);
-    }
+        => _challengeProtector.GenerateRefreshChallenge(principal, sessionId, Options.ChallengeMaxAge);
 
     private bool ValidateRefreshChallenge(string challenge, ClaimsPrincipal principal, string expectedSessionId)
-    {
-        return DeviceBoundSessionChallengeProtector.TryValidateRefreshChallenge(
-            _dataProtectionProvider, challenge, principal, expectedSessionId);
-    }
+        => _challengeProtector.TryValidateRefreshChallenge(challenge, principal, expectedSessionId);
 
     private static string GenerateSessionId()
     {
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs
index b9d937b23f37..85d29fc4b624 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs
@@ -5,5 +5,12 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
 internal sealed class DeviceBoundSessionSourceSchemes
 {
+    /// Maps source cookie scheme → DBSC handler scheme.
     public IDictionary Schemes { get; } = new Dictionary(StringComparer.Ordinal);
+
+    /// Maps refresh cookie scheme → source cookie scheme.
+    public IDictionary RefreshSchemes { get; } = new Dictionary(StringComparer.Ordinal);
+
+    /// Maps session cookie scheme → source cookie scheme.
+    public IDictionary SessionSchemes { get; } = new Dictionary(StringComparer.Ordinal);
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
new file mode 100644
index 000000000000..119c59e9ca83
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
@@ -0,0 +1,70 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Copies cookie settings from the source scheme to the refresh and session cookie schemes
+/// so they match HttpOnly, Secure, SameSite, Domain, and lifetime settings.
+/// 
+internal sealed class PostConfigureDeviceBoundSessionDerivedCookieOptions : IPostConfigureOptions
+{
+    private readonly IOptions _sourceSchemes;
+    private readonly IOptionsMonitor _cookieOptionsMonitor;
+
+    public PostConfigureDeviceBoundSessionDerivedCookieOptions(
+        IOptions sourceSchemes,
+        IOptionsMonitor cookieOptionsMonitor)
+    {
+        _sourceSchemes = sourceSchemes;
+        _cookieOptionsMonitor = cookieOptionsMonitor;
+    }
+
+    public void PostConfigure(string? name, CookieAuthenticationOptions options)
+    {
+        ArgumentNullException.ThrowIfNull(name);
+
+        var schemes = _sourceSchemes.Value;
+
+        if (schemes.RefreshSchemes.TryGetValue(name, out var refreshSourceScheme))
+        {
+            CopyFromSource(refreshSourceScheme, options);
+            // Override: path-scoped to DBSC endpoints, match source lifetime
+            options.Cookie.Path = "/.well-known/dbsc";
+            options.SlidingExpiration = false;
+        }
+        else if (schemes.SessionSchemes.TryGetValue(name, out var sessionSourceScheme))
+        {
+            CopyFromSource(sessionSourceScheme, options);
+            // Session cookie uses the source path (default /) but shorter expiry.
+            // ExpireTimeSpan is set at sign-in time by the handler via AuthenticationProperties.
+            options.SlidingExpiration = false;
+        }
+    }
+
+    private void CopyFromSource(string sourceScheme, CookieAuthenticationOptions target)
+    {
+        var source = _cookieOptionsMonitor.Get(sourceScheme);
+
+        // Copy cookie builder settings (preserving any name/path already set)
+        var targetName = target.Cookie.Name;
+        var targetPath = target.Cookie.Path;
+
+        target.Cookie.HttpOnly = source.Cookie.HttpOnly;
+        target.Cookie.SecurePolicy = source.Cookie.SecurePolicy;
+        target.Cookie.SameSite = source.Cookie.SameSite;
+        target.Cookie.Domain = source.Cookie.Domain;
+        target.Cookie.IsEssential = source.Cookie.IsEssential;
+        target.ExpireTimeSpan = source.ExpireTimeSpan;
+
+        // Restore the target-specific name and path
+        target.Cookie.Name = targetName;
+        if (targetPath is not null)
+        {
+            target.Cookie.Path = targetPath;
+        }
+    }
+}

From 31330b6b77e29c1d71f3f38a760d4dc8b714b6ec Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson 
Date: Fri, 5 Jun 2026 11:59:02 +0200
Subject: [PATCH 17/33] Update design document with all refinements

Reflects: NuGet package model, CBOR challenges without nonce,
ITimeLimitedDataProtector, antiforgery-style claim UID, cookie settings
inheritance via IPostConfigureOptions, 10min default expiry, file structure,
JWT validation via Microsoft.IdentityModel, singleton challenge protector.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 .../DeviceBoundSessions/plans/dbsc-design.md  | 286 ++++++++----------
 1 file changed, 128 insertions(+), 158 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md b/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
index 76fd5ec3940d..804599fce5b3 100644
--- a/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
+++ b/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
@@ -4,7 +4,7 @@
 
 Add support for [Device Bound Session Credentials (DBSC)](https://w3c.github.io/webappsec-dbsc/) to ASP.NET Core's authentication system. DBSC binds session cookies to a device using TPM-backed cryptographic keys, preventing session hijacking even if cookies are exfiltrated by malware.
 
-The implementation introduces a new `DeviceBoundSession` authentication handler that manages the DBSC protocol (registration, refresh, key proof validation) and delegates cookie management to existing cookie authentication handlers — following the same architectural pattern as OpenID Connect.
+The implementation introduces a new `Microsoft.AspNetCore.Authentication.DeviceBoundSessions` NuGet package with a dedicated authentication handler that manages the DBSC protocol (registration, refresh, key proof validation) and delegates cookie management to existing cookie authentication handlers — following the same architectural pattern as OpenID Connect.
 
 ## Motivation
 
@@ -21,6 +21,7 @@ Chrome 135+ ships DBSC support. This proposal integrates DBSC into ASP.NET Core
 - Support fully stateless operation (no server-side session store required)
 - Ensure non-DBSC browsers continue to work normally (graceful degradation)
 - Make integration with ASP.NET Core Identity straightforward
+- Ship as a standalone NuGet package (not part of the shared framework), same model as JwtBearer
 
 ## Non-goals
 
@@ -30,6 +31,12 @@ Chrome 135+ ships DBSC support. This proposal integrates DBSC into ASP.NET Core
 
 ## Detailed Design
 
+### Package and Dependencies
+
+- **Package**: `Microsoft.AspNetCore.Authentication.DeviceBoundSessions`
+- **Ships as NuGet package** (not shared framework) — same model as `Microsoft.AspNetCore.Authentication.JwtBearer`
+- **Dependencies**: `Microsoft.IdentityModel.Protocols.OpenIdConnect` (for JWT validation, same package JwtBearer uses), `System.Formats.Cbor` (for challenge payload encoding)
+
 ### Architectural Parallel with OpenID Connect
 
 The DBSC handler follows the same delegation pattern as OpenID Connect:
@@ -41,11 +48,12 @@ The DBSC handler follows the same delegation pattern as OpenID Connect:
 | Credential stamping | `SignInAsync(SignInScheme)` | `SignInAsync(SessionScheme)` |
 | Cookie storage | Cookie handler for `.AspNetCore.Cookies` | Cookie handler for `.Session` cookie |
 | Trigger | HTTP redirect to IdP | Browser-initiated POST after seeing `Secure-Session-Registration` header |
+| Package model | Standalone NuGet | Standalone NuGet |
 
 **Why a separate handler instead of embedding in cookie middleware:**
 
-1. **Separation of concerns** — Cookie auth handles reading/writing cookies. DBSC handles a JWT-based cryptographic protocol. These are distinct responsibilities.
-2. **JWT dependency** — The cookie authentication package has zero JWT dependencies today. DBSC requires JWT parsing and signature validation (ES256/RS256).
+1. **Separation of concerns** — Cookie auth handles reading/writing cookies. DBSC handles a JWT-based cryptographic protocol.
+2. **JWT dependency** — The cookie authentication package has zero JWT dependencies today. DBSC requires JWT parsing and signature validation (ES256/RS256) via Microsoft.IdentityModel.
 3. **Independent evolution** — The DBSC spec is still a W3C draft. A separate handler can evolve without affecting the stable cookie auth package.
 4. **Composability** — The handler can delegate to any cookie scheme, just like OIDC can sign into any cookie scheme.
 5. **Testability** — The protocol logic can be tested independently from cookie management.
@@ -55,10 +63,10 @@ The DBSC handler follows the same delegation pattern as OpenID Connect:
 ```mermaid
 graph TD
     subgraph "Authentication Schemes"
-        PS["Policy Scheme
(Default Authenticate)
Identity.Application.Dbsc"] - IS["Identity Sign-In Scheme
(Cookie Handler)
Identity.Application"] - RS["Refresh Scheme
(Cookie Handler)
Identity.Application.Dbsc.Refresh"] - SS["Session Scheme
(Cookie Handler)
Identity.Application.Dbsc.Session"] + PS["Policy Scheme
(Default Authenticate)
{Source}.Dbsc"] + IS["Source Scheme
(Cookie Handler)
{Source}"] + RS["Refresh Scheme
(Cookie Handler)
{Source}.Dbsc.Refresh"] + SS["Session Scheme
(Cookie Handler)
{Source}.Dbsc.Session"] DH["DBSC Handler
(Protocol Handler)
DeviceBoundSession"] end @@ -75,10 +83,12 @@ graph TD | Scheme | Cookie Name | Path | Lifetime | Role | |--------|-------------|------|----------|------| -| `Identity.Application` | `.AspNetCore.Identity.Application` | `/` | Long (days) | Initial sign-in. Deleted after DBSC registration. | -| `Identity.Application.Dbsc.Refresh` | `.AspNetCore.Identity.Application.Dbsc.Refresh` | `/.well-known/dbsc/` | Long (days) | Stash — holds data-protected ticket + public key. Only sent to refresh endpoint. | -| `Identity.Application.Dbsc.Session` | `.AspNetCore.Identity.Application.Dbsc.Session` | `/` | Short (30s) | Active session cookie. Used for authentication on all requests. | -| Policy Scheme | — | — | — | Routes authentication: tries `.Session` first, falls back to `Identity.Application`. | +| `{Source}` (e.g. `Identity.Application`) | `.AspNetCore.{Source}` | `/` | Long (days) | Initial sign-in. Deleted after DBSC registration. | +| `{Source}.Dbsc.Refresh` | `.AspNetCore.{Source}.Dbsc.Refresh` | `/.well-known/dbsc/` | Long (days) | Stash — holds ticket + public key. Only sent to refresh endpoint. | +| `{Source}.Dbsc.Session` | `.AspNetCore.{Source}.Dbsc.Session` | `/` | Short (10min default) | Active session cookie. Used for authentication on all requests. | +| `{Source}.Dbsc` (Policy) | — | — | — | Routes authentication: tries `.Session` first, falls back to `{Source}`. | + +The refresh and session cookie schemes **inherit settings** (HttpOnly, Secure, SameSite, Domain) from the source cookie scheme via `IPostConfigureOptions`. ### Protocol Flow @@ -87,205 +97,151 @@ sequenceDiagram participant User participant Browser as Chrome (DBSC) participant App as ASP.NET Core - participant Identity as Identity.Application
(Cookie Handler) + participant Source as Source Scheme
(Cookie Handler) participant DBSC as DBSC Handler participant Refresh as Refresh Scheme
(Cookie Handler) participant Session as Session Scheme
(Cookie Handler) User->>Browser: Submit login form Browser->>App: POST /login - App->>Identity: SignInAsync(principal) - Identity-->>Browser: Set-Cookie: .Identity.Application (long-lived)
Secure-Session-Registration header + App->>Source: SignInAsync(principal) + Source-->>Browser: Set-Cookie (long-lived)
Secure-Session-Registration header Browser->>Browser: Generate key pair (TPM) Note over Browser,App: Registration (async, non-blocking) Browser->>App: POST /.well-known/dbsc/registration
Header: Secure-Session-Response (JWT with public key) App->>DBSC: HandleRegistration - DBSC->>Identity: AuthenticateAsync() → read long-lived cookie - DBSC->>DBSC: Validate JWT, extract public key + DBSC->>Source: AuthenticateAsync() → read long-lived cookie + DBSC->>DBSC: Validate JWT, validate challenge claim UID DBSC->>Refresh: SignInAsync(principal + public key) → stamp refresh cookie - DBSC->>Session: SignInAsync(principal) → stamp short-lived cookie - DBSC->>Identity: SignOutAsync() → delete long-lived cookie + DBSC->>Session: SignInAsync(principal) → stamp session cookie + DBSC->>Source: SignOutAsync() → delete long-lived cookie DBSC-->>Browser: 200 + JSON session config + Secure-Session-Challenge - Note over Browser,App: Normal requests (short-lived cookie only) - Browser->>App: GET /api/data
Cookie: .Identity.Application.Dbsc.Session + Note over Browser,App: Normal requests (session cookie only) + Browser->>App: GET /api/data
Cookie: .{Source}.Dbsc.Session App->>Session: AuthenticateAsync() → read session cookie Session-->>App: Principal ✓ Note over Browser,App: Refresh (when session cookie expires) Browser->>App: POST /.well-known/dbsc/refresh
Header: Sec-Secure-Session-Id App->>DBSC: HandleRefresh (no proof yet) - DBSC->>Refresh: AuthenticateAsync() → read refresh cookie (has public key) + DBSC->>Refresh: AuthenticateAsync() → read refresh cookie DBSC-->>Browser: 403 + Secure-Session-Challenge Browser->>Browser: Sign challenge with TPM key - Browser->>App: POST /.well-known/dbsc/refresh
Headers: Sec-Secure-Session-Id + Secure-Session-Response (signed JWT) + Browser->>App: POST /.well-known/dbsc/refresh
Headers: Sec-Secure-Session-Id + Secure-Session-Response App->>DBSC: HandleRefresh (with proof) DBSC->>Refresh: AuthenticateAsync() → get public key - DBSC->>DBSC: Validate JWT signature against public key - DBSC->>Session: SignInAsync(principal) → stamp fresh short-lived cookie - DBSC-->>Browser: 200 + JSON config + Secure-Session-Challenge (for next refresh) - - Note over Browser,App: Chrome resumes the original deferred request - Browser->>App: GET /api/data (with fresh session cookie) - App-->>Browser: 200 OK + DBSC->>DBSC: Validate JWT, validate challenge claim UID + session ID + DBSC->>Session: SignInAsync(principal) → stamp fresh session cookie + DBSC-->>Browser: 200 + JSON config + Secure-Session-Challenge ``` -### Cookie Lifecycle +### Challenge Design -```mermaid -stateDiagram-v2 - [*] --> SignedIn: User logs in - SignedIn --> Registered: DBSC registration completes - - state SignedIn { - [*] --> LongLived - LongLived: .Identity.Application cookie
(path=/, long expiry) - note right of LongLived: Policy scheme falls back here - } - - state Registered { - [*] --> Active - Active: .Session cookie (path=/, 30s expiry)
.Refresh cookie (path=/.well-known/dbsc/, long expiry) - Active --> Expired: Session cookie expires - Expired --> Active: Refresh succeeds - Expired --> [*]: Refresh fails → re-login - note right of Active: Long-lived cookie deleted
Policy scheme uses .Session - } +Challenges are time-limited, data-protected payloads that bind to the user identity: + +**Registration challenge** (emitted at sign-in, validated at registration): +``` +ITimeLimitedDataProtector.Protect(CBOR(claimUid), lifetime=5min) ``` -### Security Properties +**Refresh challenge** (emitted at registration/refresh, validated at next refresh): +``` +ITimeLimitedDataProtector.Protect(CBOR(claimUid, sessionId), lifetime=5min) +``` -```mermaid -graph LR - subgraph "At Rest (on disk)" - RC["Refresh Cookie
path=/.well-known/dbsc/
✓ Useless without TPM key"] - SC["Session Cookie
path=/
✓ Expires in 30s"] - end - subgraph "In Transit (normal requests)" - SC2["Only Session Cookie sent
✓ Short-lived"] - end - subgraph "In Transit (refresh)" - RC2["Refresh Cookie sent
✓ Requires JWT proof"] - end +- **No nonce needed** — `ITimeLimitedDataProtector` provides uniqueness via its embedded timestamp + MAC +- **Claim UID** follows antiforgery priority: `sub` > `ClaimTypes.NameIdentifier` > `ClaimTypes.Upn` > SHA256(all claims) +- **Claim UID encoding** uses CBOR (claim type, value, issuer as text strings) +- **CBOR payload** is a bare sequence of text strings (no array/map framing) +- **5-minute expiry** — time-limited protector rejects expired challenges automatically - style RC fill:#e8f5e9 - style SC fill:#e8f5e9 - style SC2 fill:#e8f5e9 - style RC2 fill:#e8f5e9 -``` +The `DeviceBoundSessionChallengeProtector` is a singleton service that caches the `ITimeLimitedDataProtector` instance. -| Threat | Mitigation | -|--------|-----------| -| Malware steals session cookie | Expires in ≤30s. Attacker has a tiny window. | -| Malware steals refresh cookie | Useless — refresh endpoint requires JWT proof signed with TPM-bound private key. | -| Malware steals both cookies | Session cookie expires quickly. Refresh cookie can't be used remotely. | -| Long-lived cookie exfiltration | Deleted after registration. Only exists briefly during sign-in → registration gap. | -| Refresh endpoint is down | User must re-login. No stealable long-lived token persists. | -| Non-DBSC browser | Falls back to long-lived cookie via policy scheme. Same security as today (no regression). | +### JWT Validation -### Policy Scheme (Fallback Behavior) +JWT validation uses `Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler` (same library as JwtBearer): +- Parse JWT via `new JsonWebToken(tokenString)` +- Validate `typ` header is `"dbsc+jwt"` +- Extract `jwk` from JWT header (registration) or from refresh cookie properties (refresh) +- Construct `ECDsaSecurityKey` (ES256) or `RsaSecurityKey` (RS256) from JWK +- Validate signature via `JsonWebTokenHandler.ValidateTokenAsync()` with appropriate `TokenValidationParameters` -The policy scheme handles the transition between pre-registration (long-lived cookie) and post-registration (session cookie): +### Integration with ASP.NET Core Identity ```csharp -.AddPolicyScheme("Identity.Application.Dbsc", options => -{ - options.ForwardDefaultSelector = context => +builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme) + .AddIdentityCookies() + .AddDeviceBoundSession(IdentityConstants.ApplicationScheme, o => { - // If the short-lived session cookie exists, authenticate with it - if (context.Request.Cookies.ContainsKey(sessionCookieName)) - return "Identity.Application.Dbsc.Session"; - - // Otherwise fall back to the long-lived cookie - // (pre-registration gap, or non-DBSC browser) - return "Identity.Application"; - }; -}); -``` - -This ensures: -- **Non-DBSC browsers**: Continue using the long-lived cookie forever (no regression) -- **Pre-registration gap**: Long-lived cookie covers the user until registration completes -- **Post-registration**: Session cookie is the active credential; long-lived cookie is deleted - -### Refresh Cookie as "Stash" - -The refresh cookie stores the authentication ticket + public key + session metadata, data-protected and path-scoped: - -``` -Refresh Cookie Value = DataProtect( - session_id_length | session_id | - algorithm_length | algorithm | - public_key_length | public_key_jwk | - ticket_bytes -) + o.ShortLivedCookieExpiration = TimeSpan.FromMinutes(10); + }); ``` -- **Path-scoped** to `/.well-known/dbsc/` → never sent on normal requests -- **Data-protected** → opaque to the browser and any code that doesn't have the server's keys -- **Long-lived** → matches the original ticket's intended lifetime -- **Stateless** → no server-side store needed; the cookie IS the store +`AddDeviceBoundSession(sourceScheme)` handles everything: +- Registers refresh/session cookie schemes (inheriting source cookie settings via `IPostConfigureOptions`) +- Registers a policy scheme as `DefaultAuthenticateScheme` +- Registers the DBSC protocol handler +- Hooks `Secure-Session-Registration` header emission into the source scheme's `OnSigningIn` event via `IPostConfigureOptions` -### Stateless Challenges +No manual event wiring needed by the user. -Challenges use the same self-contained pattern: +### Cookie Settings Inheritance -``` -Challenge = DataProtect(timestamp | nonce | session_id) -``` +`PostConfigureDeviceBoundSessionDerivedCookieOptions` copies the following from the source cookie scheme to refresh and session schemes: +- `Cookie.HttpOnly` +- `Cookie.SecurePolicy` +- `Cookie.SameSite` +- `Cookie.Domain` +- `Cookie.IsEssential` +- `ExpireTimeSpan` -The server validates a challenge by unprotecting it and checking: -1. The timestamp is within the allowed window (e.g., 5 minutes) -2. The session ID matches the requesting session +The refresh scheme overrides `Cookie.Path = "/.well-known/dbsc"`. The session scheme's `ExpireTimeSpan` is overridden at sign-in time via `AuthenticationProperties.ExpiresUtc`. -No server-side challenge storage is needed. +### Security Properties -### Integration with ASP.NET Core Identity +| Threat | Mitigation | +|--------|-----------| +| Malware steals session cookie | Expires in ≤10min. Attacker has a limited window. | +| Malware steals refresh cookie | Useless — refresh endpoint requires JWT proof signed with TPM-bound private key. | +| Malware steals both cookies | Session cookie expires quickly. Refresh cookie can't be used remotely. | +| Long-lived cookie exfiltration | Deleted after registration. Only exists briefly during sign-in → registration gap. | +| Refresh endpoint is down | User must re-login. No stealable long-lived token persists. | +| Non-DBSC browser | Falls back to long-lived cookie via policy scheme. Same security as today (no regression). | +| Challenge replay | ITimeLimitedDataProtector rejects expired challenges; claim UID binding prevents cross-user replay. | -```csharp -// In Program.cs or Startup: -builder.Services.AddAuthentication() - .AddIdentityCookies() - .AddDeviceBoundSession(options => - { - options.RegistrationSourceScheme = IdentityConstants.ApplicationScheme; - options.RefreshSourceScheme = "Identity.Application.Dbsc.Refresh"; - options.SignInScheme = "Identity.Application.Dbsc.Session"; - options.ShortLivedCookieExpiration = TimeSpan.FromSeconds(30); - }); +### File Structure -// Or a convenience helper: -builder.Services.AddIdentity() - .AddDeviceBoundSessions(); // wires up all schemes automatically ``` - -### Handler Implementation Sketch - -```csharp -public class DeviceBoundSessionHandler : AuthenticationHandler -{ - // Not used for normal request authentication — the cookie handlers do that. - protected override Task HandleAuthenticateAsync() - => Task.FromResult(AuthenticateResult.NoResult()); - - // Registration and refresh are handled as middleware-style endpoint matching - // within the handler, similar to how RemoteAuthenticationHandler handles callbacks. -} +src/Security/Authentication/DeviceBoundSessions/src/ +├── DeviceBoundSessionChallengeProtector.cs # Singleton: generates/validates challenges (CBOR + time-limited DP) +├── DeviceBoundSessionConfiguration.cs # JSON response model: session config +├── DeviceBoundSessionCookieEvents.cs # IPostConfigureOptions: hooks OnSigningIn to emit registration header +├── DeviceBoundSessionCookieEventsWrapper.cs # CookieAuthenticationEvents wrapper for EventsType scenarios +├── DeviceBoundSessionCredentialConfiguration.cs # JSON response model: credential entry +├── DeviceBoundSessionDefaults.cs # Constants: scheme name, paths +├── DeviceBoundSessionExtensions.cs # AddDeviceBoundSession() extension method +├── DeviceBoundSessionHandler.cs # Main handler: registration + refresh protocol +├── DeviceBoundSessionJsonContext.cs # Source-generated JSON serializer context +├── DeviceBoundSessionJwtResult.cs # JWT validation result model +├── DeviceBoundSessionJwtValidator.cs # JWT validation using Microsoft.IdentityModel +├── DeviceBoundSessionOptions.cs # Handler options +├── DeviceBoundSessionScopeConfiguration.cs # JSON response model: scope +├── DeviceBoundSessionScopeRule.cs # Options model: scope rule +├── DeviceBoundSessionScopeRuleConfiguration.cs # JSON response model: scope rule +├── DeviceBoundSessionSourceSchemes.cs # Internal: tracks scheme name mappings +├── PostConfigureDeviceBoundSessionDerivedCookieOptions.cs # Copies source cookie settings to derived schemes +├── PublicAPI.Shipped.txt +└── PublicAPI.Unshipped.txt ``` -The handler uses the `RemoteAuthenticationHandler` pattern: -- It doesn't authenticate normal requests (returns `NoResult`) -- It intercepts specific paths (registration, refresh) and handles the protocol -- On success, it delegates to cookie handlers via `SignInAsync` - ## Risks -- **Header size for `Secure-Session-Challenge`**: Challenges are data-protected blobs (~200 bytes). Well within HTTP header limits. - **Refresh cookie size**: Contains a full authentication ticket (~500-1500 bytes base64). Within the 4KB per-cookie limit for typical claims sets. Large claims sets may need the existing cookie chunking mechanism. -- **Chrome spec changes**: DBSC is still a W3C draft. The separate handler/package approach makes it easier to adapt to spec changes without impacting stable cookie auth code. -- **TPM rate limiting**: Chrome batches/deduplicates refresh requests. With 30s cookie expiry, TPM signing happens at most once per 30s per session — well within hardware limits. +- **Chrome spec changes**: DBSC is still a W3C draft. The separate handler/package approach makes it easier to adapt. +- **TPM rate limiting**: Chrome batches/deduplicates refresh requests. With 10min cookie expiry, TPM signing happens at most once per 10min per session. ## Drawbacks @@ -303,17 +259,24 @@ Rejected because: - Makes the cookie handler harder to maintain and test - Prevents independent evolution of DBSC support +### Adding IdentityModel to the shared framework + +Rejected because: +- JwtBearer already ships as a standalone NuGet package with IdentityModel as a dependency +- Adding IdentityModel to the shared framework would bloat it for all apps +- The DBSC package follows the same standalone package model + ### Using the long-lived cookie for both normal requests and refresh Rejected because: - If the long-lived cookie is sent on every request, stealing it defeats the purpose of DBSC - The whole point is that the "stealable" tokens are short-lived -### Keeping the long-lived cookie as fallback (Chrome's "Alternative integration pattern") +### Keeping the long-lived cookie as fallback Rejected for the strict security mode because: - A persistent long-lived cookie at `path=/` is exactly what DBSC is trying to eliminate -- Acceptable as an opt-in configuration for sites that prioritize availability over maximum security +- The tradeoff (re-login on refresh failure vs. security) is acceptable ### Stashing ticket data in the session ID or challenge header @@ -321,3 +284,10 @@ Rejected because: - Both appear in HTTP headers with size constraints - Path-scoped refresh cookie provides ample space (4KB) without header size concerns - Cookie infrastructure (chunking, data protection) already exists in ASP.NET Core + +### Using nonce in challenges + +Rejected because: +- `ITimeLimitedDataProtector` already provides uniqueness via embedded timestamp + MAC +- Adding a nonce increases payload size without security benefit +- The data protection system's MAC prevents any form of replay or forgery \ No newline at end of file From 3e71fd296f45d70c454ec4bac27ef644e28b5ce2 Mon Sep 17 00:00:00 2001 From: Roman Konecny Date: Fri, 12 Jun 2026 00:16:15 +0200 Subject: [PATCH 18/33] feat: refine DBSC challenge flow, rename session instruction DTOs, expand debug sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - emit Secure-Session-Challenge only on 403, not on success - rename JSON DTOs to SessionInstruction/Scope/ScopeRule/Credential - add DbscSampleV2 debug dashboard and HAR capture - add challenge protector and JWT validator tests 🔒 - Generated by Copilot --- .../samples/DbscSampleV2/Dashboard.cs | 315 ++++++++++ .../samples/DbscSampleV2/DbscDebug.cs | 595 ++++++++++++++++++ .../samples/DbscSampleV2/DbscSampleV2.csproj | 2 + .../DbscSampleV2/HarLoggingMiddleware.cs | 228 +++++++ .../samples/DbscSampleV2/Program.cs | 160 +++-- .../DeviceBoundSessionChallengeProtector.cs | 14 +- .../src/DeviceBoundSessionHandler.cs | 47 +- .../src/DeviceBoundSessionJsonContext.cs | 2 +- ...eDeviceBoundSessionDerivedCookieOptions.cs | 9 +- .../src/PublicAPI.Unshipped.txt | 72 +++ ...lConfiguration.cs => SessionCredential.cs} | 5 +- ...Configuration.cs => SessionInstruction.cs} | 10 +- ...nScopeConfiguration.cs => SessionScope.cs} | 7 +- ...leConfiguration.cs => SessionScopeRule.cs} | 5 +- .../test/DeviceBoundSessions/DbscProofKey.cs | 88 +++ ...viceBoundSessionChallengeProtectorTests.cs | 117 ++++ .../DeviceBoundSessionJwtValidatorTests.cs | 101 +++ ...soft.AspNetCore.Authentication.Test.csproj | 1 + 18 files changed, 1681 insertions(+), 97 deletions(-) create mode 100644 src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Dashboard.cs create mode 100644 src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscDebug.cs rename src/Security/Authentication/DeviceBoundSessions/src/{DeviceBoundSessionCredentialConfiguration.cs => SessionCredential.cs} (78%) rename src/Security/Authentication/DeviceBoundSessions/src/{DeviceBoundSessionConfiguration.cs => SessionInstruction.cs} (69%) rename src/Security/Authentication/DeviceBoundSessions/src/{DeviceBoundSessionScopeConfiguration.cs => SessionScope.cs} (76%) rename src/Security/Authentication/DeviceBoundSessions/src/{DeviceBoundSessionScopeRuleConfiguration.cs => SessionScopeRule.cs} (77%) create mode 100644 src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs create mode 100644 src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionChallengeProtectorTests.cs create mode 100644 src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionJwtValidatorTests.cs diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Dashboard.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Dashboard.cs new file mode 100644 index 000000000000..9846fda92e7a --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Dashboard.cs @@ -0,0 +1,315 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace DbscSampleV2; + +/// The dark-themed single-page debug dashboard served at /. +public static class Dashboard +{ + public const string Html = +""" + + + + + +DBSC Debug Console + + + +
+

DBSC Debug Console

+
Device Bound Session Credentials — live protocol inspector
+ +
+

Session

+
Not signed in
+
Session TTL
+
+ +
+

Sign in

+ +
+ + + + + +
+
TTL controls how long each short-lived bound cookie lasts before a device-key refresh is required.
+
+ +
+

Actions

+ + + + +
+ +
+

Decoded cookies

+
No DBSC cookies present.
+
+ +
+

Legend

+ register + refresh + api + auth + page +
+
+ +
+
+

Live exchange log

+ 0 entries + + +
+
Waiting for traffic… sign in to begin.
+
+ + + + +"""; +} diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscDebug.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscDebug.cs new file mode 100644 index 000000000000..40a8959f76a5 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscDebug.cs @@ -0,0 +1,595 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Formats.Cbor; +using System.Globalization; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace DbscSampleV2; + +/// Well-known scheme, cookie, and purpose names used by the debug tooling. +public static class DbscNames +{ + public const string Source = "Application"; + public const string Refresh = "Application.Dbsc.Refresh"; + public const string Session = "Application.Dbsc.Session"; + + public const string SourceCookie = ".AspNetCore.Application"; + public const string RefreshCookie = ".AspNetCore.Application.Dbsc.Refresh"; + public const string SessionCookie = ".AspNetCore.Application.Dbsc.Session"; + + public const string ChallengePurpose = "Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.v1"; + + public static string? SchemeForCookie(string name) => name switch + { + SourceCookie => Source, + RefreshCookie => Refresh, + SessionCookie => Session, + _ => null, + }; +} + +/// +/// Mutable, process-wide debug state for the sample: the current session TTL and a ring +/// buffer of decoded HTTP exchanges that the dashboard polls. +/// +public sealed class DbscDebugState +{ + public const int MaxEntries = 400; + + private readonly object _lock = new(); + private readonly LinkedList _exchanges = new(); + private long _nextId; + private long _ttlTicks = TimeSpan.FromSeconds(300).Ticks; + + // Signaled whenever the log changes, so long-poll requests can wake without busy polling. + private volatile TaskCompletionSource _signal = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TimeSpan SessionTtl + { + get => TimeSpan.FromTicks(Interlocked.Read(ref _ttlTicks)); + set => Interlocked.Exchange(ref _ttlTicks, value.Ticks); + } + + public DebugExchange Add(DebugExchange exchange) + { + lock (_lock) + { + exchange.Id = ++_nextId; + _exchanges.AddLast(exchange); + while (_exchanges.Count > MaxEntries) + { + _exchanges.RemoveFirst(); + } + } + SignalChange(); + return exchange; + } + + public (long LastId, List Entries) Since(long sinceId) + { + lock (_lock) + { + var list = new List(); + foreach (var e in _exchanges) + { + if (e.Id > sinceId) + { + list.Add(e); + } + } + return (_nextId, list); + } + } + + /// + /// Long-poll wait: completes immediately if entries newer than + /// already exist, otherwise waits until the log changes or elapses. + /// + public async Task<(long LastId, List Entries)> WaitForChangesAsync( + long sinceId, TimeSpan timeout, CancellationToken cancellationToken) + { + var current = Since(sinceId); + if (current.Entries.Count > 0 || current.LastId < sinceId) + { + return current; + } + + var signal = _signal; + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + await signal.Task.WaitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Timed out with no change — return the current (likely empty) snapshot. + } + + return Since(sinceId); + } + + public void ClearLog() + { + lock (_lock) + { + _exchanges.Clear(); + } + SignalChange(); + } + + private void SignalChange() + { + var previous = Interlocked.Exchange(ref _signal, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + previous.TrySetResult(); + } +} + +// ===== Serializable models surfaced to the dashboard ===== + +public sealed class DebugExchange +{ + [JsonPropertyName("id")] public long Id { get; set; } + [JsonPropertyName("time")] public string Time { get; set; } = ""; + [JsonPropertyName("method")] public string Method { get; set; } = ""; + [JsonPropertyName("path")] public string Path { get; set; } = ""; + [JsonPropertyName("status")] public int Status { get; set; } + [JsonPropertyName("category")] public string Category { get; set; } = ""; + [JsonPropertyName("authenticated")] public bool Authenticated { get; set; } + [JsonPropertyName("durationMs")] public double DurationMs { get; set; } + [JsonPropertyName("requestCookies")] public List? RequestCookies { get; set; } + [JsonPropertyName("setCookies")] public List? SetCookies { get; set; } + [JsonPropertyName("proof")] public DebugJwt? Proof { get; set; } + [JsonPropertyName("registrationHeader")] public string? RegistrationHeader { get; set; } + [JsonPropertyName("challengeHeader")] public string? ChallengeHeader { get; set; } + [JsonPropertyName("decodedChallenge")] public DebugChallenge? DecodedChallenge { get; set; } + [JsonPropertyName("sessionConfig")] public JsonNode? SessionConfig { get; set; } +} + +public sealed class DebugCookie +{ + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("scheme")] public string? Scheme { get; set; } + [JsonPropertyName("valueLength")] public int ValueLength { get; set; } + [JsonPropertyName("attributes")] public string? Attributes { get; set; } + [JsonPropertyName("deleted")] public bool Deleted { get; set; } + [JsonPropertyName("decoded")] public DecodedTicket? Decoded { get; set; } +} + +public sealed class DecodedTicket +{ + [JsonPropertyName("principal")] public string? Principal { get; set; } + [JsonPropertyName("claims")] public List Claims { get; set; } = new(); + [JsonPropertyName("items")] public Dictionary Items { get; set; } = new(); + [JsonPropertyName("issuedUtc")] public string? IssuedUtc { get; set; } + [JsonPropertyName("expiresUtc")] public string? ExpiresUtc { get; set; } +} + +public sealed class DebugJwt +{ + [JsonPropertyName("header")] public JsonNode? Header { get; set; } + [JsonPropertyName("payload")] public JsonNode? Payload { get; set; } + [JsonPropertyName("signaturePresent")] public bool SignaturePresent { get; set; } + [JsonPropertyName("decodedJti")] public DebugChallenge? DecodedJti { get; set; } +} + +public sealed class DebugChallenge +{ + [JsonPropertyName("kind")] public string Kind { get; set; } = ""; + [JsonPropertyName("claimUid")] public string? ClaimUid { get; set; } + [JsonPropertyName("sessionId")] public string? SessionId { get; set; } + [JsonPropertyName("valid")] public bool Valid { get; set; } + [JsonPropertyName("error")] public string? Error { get; set; } +} + +/// +/// Decodes DBSC artifacts for the dashboard: proof JWTs (plain base64url JSON), Data-Protection +/// cookie tickets (via each scheme's ), +/// and the time-limited Data-Protection challenge payloads (CBOR). +/// +public static class DbscDecoder +{ + public static List? DecodeRequestCookies(HttpContext context) + { + var result = new List(); + foreach (var name in new[] { DbscNames.SourceCookie, DbscNames.RefreshCookie, DbscNames.SessionCookie }) + { + var value = ReadChunkedCookie(context.Request.Cookies, name); + if (value is null) + { + continue; + } + + var scheme = DbscNames.SchemeForCookie(name); + result.Add(new DebugCookie + { + Name = name, + Scheme = scheme, + ValueLength = value.Length, + Decoded = scheme is null ? null : TryDecodeTicket(context, scheme, value), + }); + } + return result.Count > 0 ? result : null; + } + + public static List? DecodeSetCookies(HttpContext context) + { + var raw = context.Response.Headers.SetCookie; + if (raw.Count == 0) + { + return null; + } + + // Reassemble chunked Set-Cookie values (name=chunks-N + nameC1..CN). + var parsed = new List<(string Name, string Value, string? Attrs)>(); + var byName = new Dictionary(StringComparer.Ordinal); + foreach (var line in raw) + { + if (string.IsNullOrEmpty(line)) + { + continue; + } + var segs = line.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var first = segs[0]; + var eq = first.IndexOf('='); + if (eq <= 0) + { + continue; + } + var name = first[..eq]; + var value = first[(eq + 1)..]; + var attrs = segs.Length > 1 ? string.Join("; ", segs[1..]) : null; + parsed.Add((name, value, attrs)); + byName[name] = value; + } + + var result = new List(); + foreach (var (name, value, attrs) in parsed) + { + // Skip the chunk-part cookies themselves; they are folded into the base entry. + if (IsChunkPart(name)) + { + continue; + } + + var deleted = value.Length == 0 || (attrs?.Contains("expires=Thu, 01 Jan 1970", StringComparison.OrdinalIgnoreCase) ?? false); + var fullValue = value.StartsWith("chunks-", StringComparison.Ordinal) ? Reassemble(name, value, byName) : value; + var scheme = DbscNames.SchemeForCookie(name); + + result.Add(new DebugCookie + { + Name = name, + Scheme = scheme, + ValueLength = fullValue.Length, + Attributes = attrs, + Deleted = deleted, + Decoded = (deleted || scheme is null) ? null : TryDecodeTicket(context, scheme, fullValue), + }); + } + return result.Count > 0 ? result : null; + } + + /// Parses a JSON response body (the DBSC Session Instruction) for display, or null. + public static JsonNode? TryParseJsonBody(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + try + { + return JsonNode.Parse(body); + } + catch + { + return null; + } + } + + public static DebugJwt? DecodeProof(HttpContext context) + { + var raw = context.Request.Headers["Secure-Session-Response"].ToString(); + if (string.IsNullOrEmpty(raw)) + { + return null; + } + + raw = raw.Trim().Trim('"'); + var parts = raw.Split('.'); + if (parts.Length is < 2 or > 3) + { + return null; + } + + var header = TryParseJsonSegment(parts[0]); + var payload = TryParseJsonSegment(parts[1]); + if (header is null || payload is null) + { + return null; + } + + var jwt = new DebugJwt + { + Header = header, + Payload = payload, + SignaturePresent = parts.Length == 3 && parts[2].Length > 0, + }; + + if (payload["jti"]?.GetValue() is { Length: > 0 } jti) + { + jwt.DecodedJti = DecodeChallenge(context, jti); + } + return jwt; + } + + public static DebugChallenge? DecodeChallengeHeader(HttpContext context, string? challengeHeader) + { + if (string.IsNullOrEmpty(challengeHeader)) + { + return null; + } + + // Format: "";id="" + var value = challengeHeader.TrimStart(); + if (value.StartsWith('"')) + { + var end = value.IndexOf('"', 1); + if (end > 1) + { + value = value[1..end]; + } + } + else + { + var semi = value.IndexOf(';'); + if (semi >= 0) + { + value = value[..semi]; + } + } + return DecodeChallenge(context, value.Trim()); + } + + public static DebugChallenge DecodeChallenge(HttpContext context, string challenge) + { + try + { + var dp = context.RequestServices.GetRequiredService(); + var protector = dp.CreateProtector(DbscNames.ChallengePurpose).ToTimeLimitedDataProtector(); + var bytes = protector.Unprotect(WebEncoders.Base64UrlDecode(challenge)); + + var reader = new CborReader(bytes, allowMultipleRootLevelValues: true); + var claimUid = reader.ReadTextString(); + string? sessionId = null; + if (reader.PeekState() != CborReaderState.Finished) + { + sessionId = reader.ReadTextString(); + } + + return new DebugChallenge + { + Kind = sessionId is null ? "registration" : "refresh", + ClaimUid = claimUid, + SessionId = sessionId, + Valid = true, + }; + } + catch (Exception ex) + { + return new DebugChallenge { Valid = false, Error = DescribeException(ex) }; + } + } + + /// + /// Builds a human-readable explanation for a decode failure. For the time-limited + /// data protector an expired payload surfaces as a CryptographicException whose + /// message says the payload expired; a key mismatch or tampering surfaces differently. + /// We include the type, message, and inner-exception chain so the dashboard can show why. + /// + private static string DescribeException(Exception ex) + { + var sb = new StringBuilder(); + var current = ex; + while (current is not null) + { + if (sb.Length > 0) + { + sb.Append(" -> "); + } + sb.Append(current.GetType().Name).Append(": ").Append(current.Message); + current = current.InnerException; + } + return sb.ToString(); + } + + private static DecodedTicket? TryDecodeTicket(HttpContext context, string scheme, string value) + { + try + { + var monitor = context.RequestServices.GetService>(); + var ticket = monitor?.Get(scheme).TicketDataFormat?.Unprotect(value); + if (ticket is null) + { + return null; + } + + return new DecodedTicket + { + Principal = ticket.Principal.Identity?.Name, + Claims = ticket.Principal.Claims.Select(c => $"{c.Type} = {c.Value}").ToList(), + Items = ticket.Properties.Items + .Where(kv => kv.Value is not null) + .ToDictionary(kv => kv.Key, kv => kv.Value!), + IssuedUtc = ticket.Properties.IssuedUtc?.ToString("O"), + ExpiresUtc = ticket.Properties.ExpiresUtc?.ToString("O"), + }; + } + catch + { + return null; + } + } + + private static JsonNode? TryParseJsonSegment(string segment) + { + try + { + return JsonNode.Parse(WebEncoders.Base64UrlDecode(segment)); + } + catch + { + return null; + } + } + + private static string? ReadChunkedCookie(IRequestCookieCollection cookies, string name) + { + if (!cookies.TryGetValue(name, out var value) || value is null) + { + return null; + } + if (value.StartsWith("chunks-", StringComparison.Ordinal) && int.TryParse(value.AsSpan(7), out var count)) + { + var sb = new StringBuilder(); + for (var i = 1; i <= count; i++) + { + if (!cookies.TryGetValue($"{name}C{i}", out var chunk) || chunk is null) + { + return value; + } + sb.Append(chunk); + } + return sb.ToString(); + } + return value; + } + + private static string Reassemble(string name, string value, Dictionary byName) + { + if (!int.TryParse(value.AsSpan(7), out var count)) + { + return value; + } + var sb = new StringBuilder(); + for (var i = 1; i <= count; i++) + { + if (!byName.TryGetValue($"{name}C{i}", out var chunk)) + { + return value; + } + sb.Append(chunk); + } + return sb.ToString(); + } + + private static bool IsChunkPart(string name) + { + var c = name.LastIndexOf('C'); + return c > 0 && c < name.Length - 1 && int.TryParse(name.AsSpan(c + 1), out _); + } +} + +/// +/// Records every HTTP exchange (decoded) into for the live dashboard log. +/// +public sealed class DebugCaptureMiddleware +{ + private readonly RequestDelegate _next; + private readonly DbscDebugState _state; + + public DebugCaptureMiddleware(RequestDelegate next, DbscDebugState state) + { + _next = next; + _state = state; + } + + public async Task InvokeAsync(HttpContext context) + { + var start = DateTimeOffset.UtcNow; + var requestCookies = DbscDecoder.DecodeRequestCookies(context); + var proof = DbscDecoder.DecodeProof(context); + + var path = context.Request.Path.Value ?? ""; + + // For the DBSC endpoints, buffer the response body so we can capture and decode the + // JSON Session Instruction the server returns on a 200. Other paths stream as usual. + var captureBody = IsDbscEndpoint(path); + Stream? originalBody = null; + MemoryStream? buffer = null; + if (captureBody) + { + originalBody = context.Response.Body; + buffer = new MemoryStream(); + context.Response.Body = buffer; + } + + string? responseBody = null; + try + { + await _next(context); + } + finally + { + if (captureBody && buffer is not null && originalBody is not null) + { + if (context.Response.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true && buffer.Length > 0) + { + responseBody = Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length); + } + buffer.Position = 0; + await buffer.CopyToAsync(originalBody); + context.Response.Body = originalBody; + } + + // Don't log the dashboard's own polling endpoints — it would be infinite noise. + if (!path.StartsWith("/debug/", StringComparison.Ordinal)) + { + var registrationHeader = context.Response.Headers["Secure-Session-Registration"].ToString(); + var challengeHeader = context.Response.Headers["Secure-Session-Challenge"].ToString(); + + var exchange = new DebugExchange + { + Time = start.ToLocalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture), + Method = context.Request.Method, + Path = path, + Status = context.Response.StatusCode, + Category = Categorize(path), + Authenticated = context.User.Identity?.IsAuthenticated == true, + DurationMs = Math.Round((DateTimeOffset.UtcNow - start).TotalMilliseconds, 2), + RequestCookies = requestCookies, + SetCookies = DbscDecoder.DecodeSetCookies(context), + Proof = proof, + RegistrationHeader = string.IsNullOrEmpty(registrationHeader) ? null : registrationHeader, + ChallengeHeader = string.IsNullOrEmpty(challengeHeader) ? null : challengeHeader, + DecodedChallenge = DbscDecoder.DecodeChallengeHeader(context, challengeHeader), + SessionConfig = DbscDecoder.TryParseJsonBody(responseBody), + }; + + _state.Add(exchange); + } + } + } + + private static bool IsDbscEndpoint(string path) => + path is "/.well-known/dbsc/registration" or "/.well-known/dbsc/refresh"; + + private static string Categorize(string path) => path switch + { + "/.well-known/dbsc/registration" => "register", + "/.well-known/dbsc/refresh" => "refresh", + "/login" or "/signout" or "/clear" => "auth", + _ when path.StartsWith("/api/", StringComparison.Ordinal) => "api", + _ => "page", + }; +} diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscSampleV2.csproj b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscSampleV2.csproj index d9324763149d..1443973d3fc3 100644 --- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscSampleV2.csproj +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscSampleV2.csproj @@ -26,8 +26,10 @@ + + diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/HarLoggingMiddleware.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/HarLoggingMiddleware.cs index 2a2a9ff5b1f9..317e85ba5afd 100644 --- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/HarLoggingMiddleware.cs +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/HarLoggingMiddleware.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; @@ -119,6 +120,7 @@ public async Task InvokeAsync(HttpContext context) Wait = stopwatch.Elapsed.TotalMilliseconds, Receive = 0, }, + Dbsc = BuildDbscAnnotations(requestHeaders, responseHeaders, requestBody), }; lock (_lock) @@ -156,6 +158,171 @@ private static List CaptureHeaders(IHeaderDictionary headers) context.Request.Body.Position = 0; return body; } + + // === DBSC decoding helpers === + // Adds a non-standard "_dbsc" object to each entry (HAR viewers ignore underscore-prefixed fields). + // Decodes the DBSC proof JWT (base64url JSON, no key needed), parses DBSC headers, and breaks down + // cookies. Auth cookie VALUES are ASP.NET Core Data Protection ciphertext, so only their names, + // sizes, and Set-Cookie attributes are surfaced (the plaintext requires the server keyring). + private static DbscAnnotations? BuildDbscAnnotations( + List requestHeaders, + List responseHeaders, + string? requestBody) + { + string? Find(List headers, string name) => + headers.FirstOrDefault(h => string.Equals(h.Name, name, StringComparison.OrdinalIgnoreCase))?.Value; + + var annotations = new DbscAnnotations(); + + // Request cookies (names + lengths; values are encrypted) + var cookieHeader = Find(requestHeaders, "cookie"); + if (!string.IsNullOrEmpty(cookieHeader)) + { + var cookies = new List(); + foreach (var pair in cookieHeader.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var eq = pair.IndexOf('='); + if (eq <= 0) + { + continue; + } + cookies.Add(new DbscCookie + { + Name = pair[..eq], + ValueLength = pair.Length - eq - 1, + }); + } + annotations.RequestCookies = cookies.Count > 0 ? cookies : null; + } + + // Set-Cookie responses (name + attributes; values are encrypted) + var setCookies = responseHeaders + .Where(h => string.Equals(h.Name, "set-cookie", StringComparison.OrdinalIgnoreCase)) + .Select(h => ParseSetCookie(h.Value)) + .Where(c => c is not null) + .Select(c => c!) + .ToList(); + annotations.SetCookies = setCookies.Count > 0 ? setCookies : null; + + // DBSC proof JWT: carried in the Secure-Session-Response request header (DBSC v2), + // with the empty POST body fallback for robustness. + var proofRaw = Find(requestHeaders, "secure-session-response"); + if (string.IsNullOrEmpty(proofRaw) && requestBody is { Length: > 0 } && requestBody.Count(c => c == '.') == 2) + { + proofRaw = requestBody; + } + annotations.ProofJwt = TryDecodeJwt(proofRaw); + + // DBSC negotiation headers (raw structured-field values, plus the challenge id when present) + annotations.RegistrationHeader = Find(responseHeaders, "secure-session-registration"); + var challenge = Find(responseHeaders, "secure-session-challenge"); + if (!string.IsNullOrEmpty(challenge)) + { + annotations.ChallengeHeader = challenge; + var idIdx = challenge.IndexOf("id=", StringComparison.OrdinalIgnoreCase); + if (idIdx >= 0) + { + annotations.ChallengeSessionId = challenge[(idIdx + 3)..].Trim().Trim('"'); + } + } + annotations.SessionId = Find(requestHeaders, "sec-secure-session-id")?.Trim().Trim('"'); + + var hasContent = annotations.RequestCookies is not null + || annotations.SetCookies is not null + || annotations.ProofJwt is not null + || annotations.RegistrationHeader is not null + || annotations.ChallengeHeader is not null + || annotations.SessionId is not null; + + return hasContent ? annotations : null; + } + + private static DbscSetCookie? ParseSetCookie(string value) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + + var segments = value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length == 0) + { + return null; + } + + var first = segments[0]; + var eq = first.IndexOf('='); + if (eq <= 0) + { + return null; + } + + var attributes = segments.Length > 1 + ? string.Join("; ", segments[1..]) + : null; + + return new DbscSetCookie + { + Name = first[..eq], + ValueLength = first.Length - eq - 1, + Attributes = attributes, + }; + } + + private static DbscJwt? TryDecodeJwt(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + token = token.Trim().Trim('"'); + var parts = token.Split('.'); + if (parts.Length is < 2 or > 3) + { + return null; + } + + var headerBytes = TryBase64UrlDecode(parts[0]); + var payloadBytes = TryBase64UrlDecode(parts[1]); + if (headerBytes is null || payloadBytes is null) + { + return null; + } + + try + { + return new DbscJwt + { + Header = JsonNode.Parse(headerBytes), + Payload = JsonNode.Parse(payloadBytes), + SignaturePresent = parts.Length == 3 && parts[2].Length > 0, + }; + } + catch (JsonException) + { + return null; + } + } + + private static byte[]? TryBase64UrlDecode(string value) + { + var s = value.Replace('-', '+').Replace('_', '/'); + switch (s.Length % 4) + { + case 2: s += "=="; break; + case 3: s += "="; break; + case 1: return null; + } + try + { + return Convert.FromBase64String(s); + } + catch (FormatException) + { + return null; + } + } } // HAR format models @@ -202,6 +369,67 @@ public sealed class HarEntry [JsonPropertyName("timings")] public HarTimings Timings { get; set; } = null!; + + [JsonPropertyName("_dbsc")] + public DbscAnnotations? Dbsc { get; set; } +} + +// DBSC decoding annotations (non-standard "_dbsc" HAR extension). +public sealed class DbscAnnotations +{ + [JsonPropertyName("sessionId")] + public string? SessionId { get; set; } + + [JsonPropertyName("proofJwt")] + public DbscJwt? ProofJwt { get; set; } + + [JsonPropertyName("registrationHeader")] + public string? RegistrationHeader { get; set; } + + [JsonPropertyName("challengeHeader")] + public string? ChallengeHeader { get; set; } + + [JsonPropertyName("challengeSessionId")] + public string? ChallengeSessionId { get; set; } + + [JsonPropertyName("requestCookies")] + public List? RequestCookies { get; set; } + + [JsonPropertyName("setCookies")] + public List? SetCookies { get; set; } +} + +public sealed class DbscJwt +{ + [JsonPropertyName("header")] + public JsonNode? Header { get; set; } + + [JsonPropertyName("payload")] + public JsonNode? Payload { get; set; } + + [JsonPropertyName("signaturePresent")] + public bool SignaturePresent { get; set; } +} + +public sealed class DbscCookie +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("valueLength")] + public int ValueLength { get; set; } +} + +public sealed class DbscSetCookie +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("valueLength")] + public int ValueLength { get; set; } + + [JsonPropertyName("attributes")] + public string? Attributes { get; set; } } public sealed class HarRequest diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs index 5d923c2f2906..c9b8f2d52664 100644 --- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs @@ -3,7 +3,6 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.DeviceBoundSessions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -11,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; namespace DbscSampleV2; @@ -45,22 +45,36 @@ public static Task Main(string[] args) public class Startup { - private const string SourceScheme = "Application"; + private readonly DbscDebugState _debug = new(); public void ConfigureServices(IServiceCollection services) { + services.AddSingleton(_debug); + services.AddAuthentication() // The source cookie scheme (long-lived sign-in cookie) - .AddCookie(SourceScheme, o => + .AddCookie(DbscNames.Source, o => { - o.Cookie.Name = ".AspNetCore.Application"; + o.Cookie.Name = DbscNames.SourceCookie; o.LoginPath = "/login"; o.ExpireTimeSpan = TimeSpan.FromDays(7); }) - // The DBSC handler + refresh/session cookie schemes + policy scheme - .AddDeviceBoundSession(SourceScheme, o => + // The DBSC handler + refresh/session cookie schemes + policy scheme. + // The session TTL is read live from the debug state so it can be changed at runtime. + .AddDeviceBoundSession(DbscNames.Source, o => { - o.ShortLivedCookieExpiration = TimeSpan.FromSeconds(30); + o.ShortLivedCookieExpiration = _debug.SessionTtl; + + // Exclude the debug dashboard's own endpoints from the DBSC session scope so that + // dashboard polling (/debug/state, /debug/log) is NOT treated as in-scope traffic. + // This keeps the browser from enforcing the bound cookie or triggering refreshes for + // the inspector itself, cleanly separating debugging requests from real test requests. + o.ScopeSpecifications.Add(new DeviceBoundSessionScopeRule + { + Type = "exclude", + Domain = "*", + Path = "/debug", + }); }); services.AddRouting(); @@ -68,99 +82,135 @@ public void ConfigureServices(IServiceCollection services) public void Configure(IApplicationBuilder app) { - // Write all HTTP traffic to a HAR file + // Write all HTTP traffic to a HAR file (raw, for external tooling) var harPath = Path.Combine(AppContext.BaseDirectory, "dbsc-v2-traffic.har"); Console.WriteLine($"[HAR] Writing traffic to: {harPath}"); app.UseMiddleware(harPath); + // Capture decoded exchanges for the live dashboard + app.UseMiddleware(); + app.UseRouting(); app.UseAuthentication(); app.UseEndpoints(endpoints => { - endpoints.MapGet("/login", async context => + endpoints.MapGet("/", async context => { - context.Response.ContentType = "text/html"; - await context.Response.WriteAsync(""" - - - DBSC v2 Sample - Login - -

Device Bound Session Credentials v2 - Test App

-
- - -
- - - """); + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.WriteAsync(Dashboard.Html); + }); + + endpoints.MapGet("/login", context => + { + context.Response.Redirect("/"); + return Task.CompletedTask; }); endpoints.MapPost("/login", async context => { var form = await context.Request.ReadFormAsync(); var username = form["username"].ToString(); - if (string.IsNullOrEmpty(username)) { - context.Response.Redirect("/login"); + context.Response.Redirect("/"); + return; + } + + // Authorization gate (demo): only "alice" is allowed. Anyone else (e.g. "bob") + // fails login: we do NOT sign in (so no source cookie and no DBSC registration), + // and we redirect back to "/" so the browser stays on the dashboard, logged out. + if (!string.Equals(username, "alice", StringComparison.OrdinalIgnoreCase)) + { + context.Response.Redirect($"/?loginError={Uri.EscapeDataString(username)}"); return; } - var identity = new ClaimsIdentity(SourceScheme); + // Apply a runtime-configurable session TTL before sign-in triggers registration. + if (int.TryParse(form["ttl"], out var ttlSeconds) && ttlSeconds is > 0 and <= 86400) + { + _debug.SessionTtl = TimeSpan.FromSeconds(ttlSeconds); + // Force the DBSC options to be rebuilt so the new TTL is picked up. + context.RequestServices + .GetRequiredService>() + .Clear(); + } + + var identity = new ClaimsIdentity(DbscNames.Source); identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, username)); identity.AddClaim(new Claim(ClaimTypes.Name, username)); - // Sign in to the source scheme. await context.SignInAsync( - SourceScheme, + DbscNames.Source, new ClaimsPrincipal(identity), new AuthenticationProperties { IsPersistent = true }); context.Response.Redirect("/"); }); - endpoints.MapGet("/", async context => + endpoints.MapGet("/api/time", async context => { if (context.User.Identity?.IsAuthenticated != true) { - context.Response.Redirect("/login"); + context.Response.StatusCode = 401; + await context.Response.WriteAsync("Unauthorized"); return; } + await context.Response.WriteAsync($"Server time {DateTime.UtcNow:HH:mm:ss.fff} | user {context.User.Identity!.Name}"); + }); - var userName = context.User.Identity!.Name; - context.Response.ContentType = "text/html"; - await context.Response.WriteAsync( - "DBSC v2" + - $"

Welcome, {userName}!

" + - "

Authenticated with Device Bound Session Credentials (v2 architecture).

" + - "

API endpoint | Sign Out

" + - "

Auto-refresh (every 10s):

" +
-                    "");
+            endpoints.MapGet("/signout", async context =>
+            {
+                await SignOutAllAsync(context);
+                context.Response.Redirect("/");
             });
 
-            endpoints.MapGet("/api/time", async context =>
+            // Full reset: delete every cookie AND clear the captured log to observe a fresh registration.
+            endpoints.MapGet("/clear", async context =>
             {
-                if (context.User.Identity?.IsAuthenticated != true)
+                await SignOutAllAsync(context);
+                _debug.ClearLog();
+                context.Response.Redirect("/");
+            });
+
+            endpoints.MapPost("/debug/clearlog", context =>
+            {
+                _debug.ClearLog();
+                context.Response.StatusCode = 204;
+                return Task.CompletedTask;
+            });
+
+            endpoints.MapGet("/debug/state", async context =>
+            {
+                var cookies = DbscDecoder.DecodeRequestCookies(context);
+                await context.Response.WriteAsJsonAsync(new
                 {
-                    context.Response.StatusCode = 401;
-                    await context.Response.WriteAsync("Unauthorized");
-                    return;
-                }
-                await context.Response.WriteAsync($"Server time: {DateTime.UtcNow:O} | User: {context.User.Identity!.Name}");
+                    authenticated = context.User.Identity?.IsAuthenticated == true,
+                    user = context.User.Identity?.Name,
+                    ttlSeconds = _debug.SessionTtl.TotalSeconds,
+                    cookies,
+                });
             });
 
-            endpoints.MapGet("/signout", async context =>
+            endpoints.MapGet("/debug/log", async context =>
             {
-                // Sign out all schemes
-                await context.SignOutAsync(SourceScheme);
-                context.Response.Redirect("/login");
+                long since = 0;
+                if (long.TryParse(context.Request.Query["since"], out var s))
+                {
+                    since = s;
+                }
+                // Long poll: hold the request open until the log changes or ~60s elapses.
+                var (lastId, entries) = await _debug.WaitForChangesAsync(
+                    since, TimeSpan.FromSeconds(60), context.RequestAborted);
+                await context.Response.WriteAsJsonAsync(new { lastId, entries });
             });
         });
     }
+
+    private static async Task SignOutAllAsync(HttpContext context)
+    {
+        await context.SignOutAsync(DbscNames.Source);
+        await context.SignOutAsync(DbscNames.Refresh);
+        await context.SignOutAsync(DbscNames.Session);
+    }
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
index 714258eb368f..b51316ba16a9 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
@@ -23,7 +23,7 @@ public DeviceBoundSessionChallengeProtector(IDataProtectionProvider dataProtecti
     public string GenerateRegistrationChallenge(ClaimsPrincipal principal, TimeSpan lifetime)
     {
         var claimUid = ComputeClaimUid(principal);
-        var writer = new CborWriter();
+        var writer = new CborWriter(allowMultipleRootLevelValues: true);
         writer.WriteTextString(claimUid);
         return WebEncoders.Base64UrlEncode(_protector.Protect(writer.Encode(), lifetime));
     }
@@ -31,7 +31,7 @@ public string GenerateRegistrationChallenge(ClaimsPrincipal principal, TimeSpan
     public string GenerateRefreshChallenge(ClaimsPrincipal principal, string sessionId, TimeSpan lifetime)
     {
         var claimUid = ComputeClaimUid(principal);
-        var writer = new CborWriter();
+        var writer = new CborWriter(allowMultipleRootLevelValues: true);
         writer.WriteTextString(claimUid);
         writer.WriteTextString(sessionId);
         return WebEncoders.Base64UrlEncode(_protector.Protect(writer.Encode(), lifetime));
@@ -46,7 +46,7 @@ public bool TryValidateRegistrationChallenge(string challenge, ClaimsPrincipal p
 
         try
         {
-            var reader = new CborReader(payload);
+            var reader = new CborReader(payload, allowMultipleRootLevelValues: true);
             var storedClaimUid = reader.ReadTextString();
             return string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal);
         }
@@ -65,7 +65,7 @@ public bool TryValidateRefreshChallenge(string challenge, ClaimsPrincipal princi
 
         try
         {
-            var reader = new CborReader(payload);
+            var reader = new CborReader(payload, allowMultipleRootLevelValues: true);
             var storedClaimUid = reader.ReadTextString();
             var storedSessionId = reader.ReadTextString();
             return string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal) &&
@@ -124,7 +124,7 @@ internal static string ComputeClaimUid(ClaimsPrincipal principal)
 
     private static string EncodeClaim(Claim claim)
     {
-        var writer = new CborWriter();
+        var writer = new CborWriter(allowMultipleRootLevelValues: true);
         writer.WriteTextString(claim.Type);
         writer.WriteTextString(claim.Value);
         writer.WriteTextString(claim.Issuer);
@@ -149,7 +149,7 @@ private static string ComputeClaimHashFallback(ClaimsPrincipal principal)
 
         allClaims.Sort((a, b) => string.Compare(a.Type, b.Type, StringComparison.Ordinal));
 
-        var writer = new CborWriter();
+        var writer = new CborWriter(allowMultipleRootLevelValues: true);
         foreach (var claim in allClaims)
         {
             writer.WriteTextString(claim.Type);
@@ -162,4 +162,4 @@ private static string ComputeClaimHashFallback(ClaimsPrincipal principal)
         SHA256.HashData(encoded, hashBytes);
         return WebEncoders.Base64UrlEncode(hashBytes);
     }
-}
\ No newline at end of file
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
index fa8a544abd69..2586e3d1aaa0 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -134,17 +134,18 @@ private async Task HandleRegistrationAsync()
         // 3. Delete the long-lived source cookie (exchange complete)
         await Context.SignOutAsync(Options.RegistrationSourceScheme);
 
-        // Build and return session configuration JSON
-        var config = BuildSessionConfiguration(sessionId);
+        // Build and return session instructions JSON.
+        // NOTE: We intentionally do NOT emit a Secure-Session-Challenge header on this 200 response.
+        // The canonical DBSC test server (drubery/dbsc-test-server) only issues a challenge on a 403
+        // (the "demand proof" path). Pre-seeding a challenge on every success response makes Chrome
+        // proactively refresh to consume it, producing a post-registration refresh storm. The next
+        // refresh will receive its challenge via the 403 + Secure-Session-Challenge handshake instead.
+        var instructions = BuildSessionInstruction(sessionId);
 
         Response.StatusCode = StatusCodes.Status200OK;
         Response.ContentType = "application/json";
 
-        // Include a challenge for the next refresh
-        var challenge = GenerateRefreshChallenge(principal, sessionId);
-        Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionId}\"";
-
-        await JsonSerializer.SerializeAsync(Response.Body, config, DeviceBoundSessionJsonContext.Default.DeviceBoundSessionConfiguration, Context.RequestAborted);
+        await JsonSerializer.SerializeAsync(Response.Body, instructions, DeviceBoundSessionJsonContext.Default.SessionInstruction, Context.RequestAborted);
     }
 
     private async Task HandleRefreshAsync()
@@ -212,7 +213,12 @@ private async Task HandleRefreshAsync()
         if (jwtResult.Challenge is null || !ValidateRefreshChallenge(jwtResult.Challenge, authResult.Principal, sessionIdHeader))
         {
             Logger.LogWarning("DBSC refresh: stale or invalid challenge for session {SessionId}.", sessionIdHeader);
+            // Re-issue a fresh challenge so the client can immediately retry with a valid proof
+            // (the DBSC 403 + Secure-Session-Challenge handshake). Without this the client would
+            // keep retrying the same stale challenge, producing a burst of 403s.
+            var retryChallenge = GenerateRefreshChallenge(authResult.Principal, sessionIdHeader);
             Response.StatusCode = StatusCodes.Status403Forbidden;
+            Response.Headers["Secure-Session-Challenge"] = $"\"{retryChallenge}\";id=\"{sessionIdHeader}\"";
             return;
         }
 
@@ -225,25 +231,28 @@ private async Task HandleRefreshAsync()
         };
         await Context.SignInAsync(Options.SessionScheme, authResult.Principal, sessionProperties);
 
-        // Return session configuration with new challenge
-        var config = BuildSessionConfiguration(sessionIdHeader);
-        var nextChallenge = GenerateRefreshChallenge(authResult.Principal, sessionIdHeader);
+        // NOTE: Returning the Session Instruction (config JSON) on a refresh 200 is OPTIONAL per the
+        // DBSC spec — the browser already has the instructions from registration, and we verified the
+        // session keeps working when the refresh body is empty. We currently re-send it every time for
+        // simplicity. A future optimization could omit it on the common path and only send it when the
+        // instructions actually need to change — e.g. to update scope/credentials, narrow access, or
+        // force a logout/cleanup everywhere by returning updated (or session-ending) instructions.
+        var instructions = BuildSessionInstruction(sessionIdHeader);
 
         Response.StatusCode = StatusCodes.Status200OK;
         Response.ContentType = "application/json";
-        Response.Headers["Secure-Session-Challenge"] = $"\"{nextChallenge}\";id=\"{sessionIdHeader}\"";
 
-        await JsonSerializer.SerializeAsync(Response.Body, config, DeviceBoundSessionJsonContext.Default.DeviceBoundSessionConfiguration, Context.RequestAborted);
+        await JsonSerializer.SerializeAsync(Response.Body, instructions, DeviceBoundSessionJsonContext.Default.SessionInstruction, Context.RequestAborted);
     }
 
-    private DeviceBoundSessionConfiguration BuildSessionConfiguration(string sessionId)
+    private SessionInstruction BuildSessionInstruction(string sessionId)
     {
         var origin = $"{Request.Scheme}://{Request.Host}";
 
-        List? scopeRules = null;
+        List? scopeRules = null;
         if (Options.ScopeSpecifications.Count > 0)
         {
-            scopeRules = Options.ScopeSpecifications.Select(r => new DeviceBoundSessionScopeRuleConfiguration
+            scopeRules = Options.ScopeSpecifications.Select(r => new SessionScopeRule
             {
                 Type = r.Type,
                 Domain = r.Domain,
@@ -255,19 +264,19 @@ private DeviceBoundSessionConfiguration BuildSessionConfiguration(string session
         // We need to resolve it from the cookie options for that scheme
         var sessionCookieName = ResolveSessionCookieName();
 
-        return new DeviceBoundSessionConfiguration
+        return new SessionInstruction
         {
             SessionIdentifier = sessionId,
             RefreshUrl = Options.RefreshPath.Value,
-            Scope = new DeviceBoundSessionScopeConfiguration
+            Scope = new SessionScope
             {
                 Origin = origin,
                 IncludeSite = Options.IncludeSite,
                 ScopeSpecification = scopeRules,
             },
-            Credentials = new List
+            Credentials = new List
             {
-                new DeviceBoundSessionCredentialConfiguration
+                new SessionCredential
                 {
                     Type = "cookie",
                     Name = sessionCookieName,
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs
index 449cec7ee4d6..d1f0cc499157 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs
@@ -8,7 +8,7 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// 
 /// Source-generated JSON serialization context for DBSC configuration types.
 /// 
-[JsonSerializable(typeof(DeviceBoundSessionConfiguration))]
+[JsonSerializable(typeof(SessionInstruction))]
 [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
 internal sealed partial class DeviceBoundSessionJsonContext : JsonSerializerContext
 {
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
index 119c59e9ca83..c19297d9f8e0 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
@@ -2,6 +2,7 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Options;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
@@ -13,14 +14,14 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 internal sealed class PostConfigureDeviceBoundSessionDerivedCookieOptions : IPostConfigureOptions
 {
     private readonly IOptions _sourceSchemes;
-    private readonly IOptionsMonitor _cookieOptionsMonitor;
+    private readonly IServiceProvider _services;
 
     public PostConfigureDeviceBoundSessionDerivedCookieOptions(
         IOptions sourceSchemes,
-        IOptionsMonitor cookieOptionsMonitor)
+        IServiceProvider services)
     {
         _sourceSchemes = sourceSchemes;
-        _cookieOptionsMonitor = cookieOptionsMonitor;
+        _services = services;
     }
 
     public void PostConfigure(string? name, CookieAuthenticationOptions options)
@@ -47,7 +48,7 @@ public void PostConfigure(string? name, CookieAuthenticationOptions options)
 
     private void CopyFromSource(string sourceScheme, CookieAuthenticationOptions target)
     {
-        var source = _cookieOptionsMonitor.Get(sourceScheme);
+        var source = _services.GetRequiredService>().Get(sourceScheme);
 
         // Copy cookie builder settings (preserving any name/path already set)
         var targetName = target.Cookie.Name;
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
index 7dc5c58110bf..840d62717217 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
@@ -1 +1,73 @@
 #nullable enable
+const Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults.AuthenticationScheme = "DeviceBoundSession" -> string!
+const Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults.RefreshPath = "/.well-known/dbsc/refresh" -> string!
+const Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults.RegistrationPath = "/.well-known/dbsc/registration" -> string!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.DeviceBoundSessionHandler(Microsoft.Extensions.Options.IOptionsMonitor! options, Microsoft.Extensions.Logging.ILoggerFactory! logger, System.Text.Encodings.Web.UrlEncoder! encoder, Microsoft.AspNetCore.DataProtection.IDataProtectionProvider! dataProtectionProvider) -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.HandleRequestAsync() -> System.Threading.Tasks.Task!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.AllowedRefreshInitiators.get -> System.Collections.Generic.IList!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.ChallengeMaxAge.get -> System.TimeSpan
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.ChallengeMaxAge.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.DeviceBoundSessionOptions() -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.IncludeSite.get -> bool
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.IncludeSite.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RefreshPath.get -> Microsoft.AspNetCore.Http.PathString
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RefreshPath.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RefreshScheme.get -> string?
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RefreshScheme.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RegistrationPath.get -> Microsoft.AspNetCore.Http.PathString
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RegistrationPath.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RegistrationSourceScheme.get -> string?
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RegistrationSourceScheme.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.ScopeSpecifications.get -> System.Collections.Generic.IList!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.SessionScheme.get -> string?
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.SessionScheme.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.ShortLivedCookieExpiration.get -> System.TimeSpan
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.ShortLivedCookieExpiration.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.DeviceBoundSessionScopeRule() -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Domain.get -> string!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Domain.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Path.get -> string!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Path.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Type.get -> string!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Type.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Attributes.get -> string!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Attributes.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Name.get -> string!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Name.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.SessionCredential() -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Type.get -> string!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Type.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Credentials.get -> System.Collections.Generic.List?
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Credentials.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.RefreshUrl.get -> string?
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.RefreshUrl.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Scope.get -> Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope?
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Scope.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.SessionIdentifier.get -> string!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.SessionIdentifier.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.SessionInstruction() -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.IncludeSite.get -> bool
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.IncludeSite.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.Origin.get -> string?
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.Origin.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.ScopeSpecification.get -> System.Collections.Generic.List?
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.ScopeSpecification.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.SessionScope() -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Domain.get -> string!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Domain.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Path.get -> string!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Path.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.SessionScopeRule() -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Type.get -> string!
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Type.set -> void
+Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions
+static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! authenticationScheme, string! sourceScheme, System.Action? configureOptions = null) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder!
+static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! sourceScheme, System.Action? configureOptions = null) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder!
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCredentialConfiguration.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionCredential.cs
similarity index 78%
rename from src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCredentialConfiguration.cs
rename to src/Security/Authentication/DeviceBoundSessions/src/SessionCredential.cs
index f7ae028f7309..1673f733e969 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCredentialConfiguration.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionCredential.cs
@@ -6,9 +6,10 @@
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
 /// 
-/// Represents a credential in the DBSC session configuration.
+/// Represents a credential in the DBSC session instructions. Corresponds to the "JSON Session
+/// Credential Format" defined in W3C Device Bound Session Credentials §9.9.
 /// 
-public sealed class DeviceBoundSessionCredentialConfiguration
+public sealed class SessionCredential
 {
     /// 
     /// Gets or sets the credential type (always "cookie").
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConfiguration.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs
similarity index 69%
rename from src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConfiguration.cs
rename to src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs
index 472e744e7ff6..bad797c1ace1 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConfiguration.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs
@@ -6,9 +6,11 @@
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
 /// 
-/// Represents the DBSC session configuration returned to the browser.
+/// Represents the DBSC JSON session instructions returned to the browser during registration
+/// and (optionally) refresh. Corresponds to the "JSON Session Instruction Format" defined in
+/// W3C Device Bound Session Credentials §9.6.
 /// 
-public sealed class DeviceBoundSessionConfiguration
+public sealed class SessionInstruction
 {
     /// 
     /// Gets or sets the session identifier.
@@ -26,11 +28,11 @@ public sealed class DeviceBoundSessionConfiguration
     /// Gets or sets the session scope.
     /// 
     [JsonPropertyName("scope")]
-    public DeviceBoundSessionScopeConfiguration? Scope { get; set; }
+    public SessionScope? Scope { get; set; }
 
     /// 
     /// Gets or sets the session credentials.
     /// 
     [JsonPropertyName("credentials")]
-    public List? Credentials { get; set; }
+    public List? Credentials { get; set; }
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeConfiguration.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionScope.cs
similarity index 76%
rename from src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeConfiguration.cs
rename to src/Security/Authentication/DeviceBoundSessions/src/SessionScope.cs
index 90b1212842d2..1cc2b6f0a99d 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeConfiguration.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionScope.cs
@@ -6,9 +6,10 @@
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
 /// 
-/// Represents the scope of a DBSC session.
+/// Represents the scope of a DBSC session. Corresponds to the "JSON Session Scope Instruction
+/// Format" defined in W3C Device Bound Session Credentials §9.7.
 /// 
-public sealed class DeviceBoundSessionScopeConfiguration
+public sealed class SessionScope
 {
     /// 
     /// Gets or sets the origin for the session scope.
@@ -27,5 +28,5 @@ public sealed class DeviceBoundSessionScopeConfiguration
     /// 
     [JsonPropertyName("scope_specification")]
     [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
-    public List? ScopeSpecification { get; set; }
+    public List? ScopeSpecification { get; set; }
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRuleConfiguration.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionScopeRule.cs
similarity index 77%
rename from src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRuleConfiguration.cs
rename to src/Security/Authentication/DeviceBoundSessions/src/SessionScopeRule.cs
index 75f114f0207f..0005e4ecf8e0 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRuleConfiguration.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionScopeRule.cs
@@ -6,9 +6,10 @@
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
 /// 
-/// Represents a scope specification rule in the DBSC session configuration.
+/// Represents a scope specification rule in the DBSC session instructions. Corresponds to the
+/// "JSON Session Scope Rule Format" defined in W3C Device Bound Session Credentials §9.8.
 /// 
-public sealed class DeviceBoundSessionScopeRuleConfiguration
+public sealed class SessionScopeRule
 {
     /// 
     /// Gets or sets the type ("include" or "exclude").
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs b/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs
new file mode 100644
index 000000000000..6d54fca7b031
--- /dev/null
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs
@@ -0,0 +1,88 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.IdentityModel.JsonWebTokens;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Test helper that mints DBSC proof JWTs (typ=dbsc+jwt, jwk in header, jti in payload),
+/// signed with an ephemeral ES256 or RS256 device key, mirroring what shipping Chrome produces.
+/// 
+internal sealed class DbscProofKey
+{
+    private static readonly JsonWebTokenHandler s_handler = new();
+
+    private readonly SecurityKey _signingKey;
+
+    private DbscProofKey(string algorithm, SecurityKey signingKey, string publicJwkJson)
+    {
+        Algorithm = algorithm;
+        _signingKey = signingKey;
+        PublicJwkJson = publicJwkJson;
+    }
+
+    public string Algorithm { get; }
+
+    /// The public key as a JWK JSON string, as the server stores it for refresh validation.
+    public string PublicJwkJson { get; }
+
+    public static DbscProofKey CreateEs256()
+    {
+        var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+        var p = ecdsa.ExportParameters(includePrivateParameters: false);
+        var jwk = new Dictionary
+        {
+            ["kty"] = "EC",
+            ["crv"] = "P-256",
+            ["x"] = WebEncoders.Base64UrlEncode(p.Q.X!),
+            ["y"] = WebEncoders.Base64UrlEncode(p.Q.Y!),
+        };
+        return new DbscProofKey("ES256", new ECDsaSecurityKey(ecdsa), System.Text.Json.JsonSerializer.Serialize(jwk));
+    }
+
+    public static DbscProofKey CreateRs256()
+    {
+        var rsa = RSA.Create(2048);
+        var p = rsa.ExportParameters(includePrivateParameters: false);
+        var jwk = new Dictionary
+        {
+            ["kty"] = "RSA",
+            ["n"] = WebEncoders.Base64UrlEncode(p.Modulus!),
+            ["e"] = WebEncoders.Base64UrlEncode(p.Exponent!),
+        };
+        return new DbscProofKey("RS256", new RsaSecurityKey(rsa), System.Text.Json.JsonSerializer.Serialize(jwk));
+    }
+
+    /// Creates a signed DBSC proof JWT with the public key in the header (registration shape).
+    public string CreateProof(string jti, bool includeJwkHeader = true)
+    {
+        var headerClaims = new Dictionary { ["typ"] = "dbsc+jwt" };
+        if (includeJwkHeader)
+        {
+            headerClaims["jwk"] = System.Text.Json.JsonDocument.Parse(PublicJwkJson).RootElement;
+        }
+
+        var descriptor = new SecurityTokenDescriptor
+        {
+            Claims = new Dictionary { ["jti"] = jti },
+            SigningCredentials = new SigningCredentials(_signingKey, Algorithm),
+            AdditionalHeaderClaims = headerClaims,
+        };
+
+        return s_handler.CreateToken(descriptor);
+    }
+
+    /// Flips one byte of the signature segment to produce a tampered token.
+    public static string TamperSignature(string jwt)
+    {
+        var lastDot = jwt.LastIndexOf('.');
+        var sig = jwt[(lastDot + 1)..];
+        var flipped = sig[0] == 'A' ? 'B' : 'A';
+        return string.Concat(jwt.AsSpan(0, lastDot + 1), flipped.ToString(), sig.AsSpan(1));
+    }
+}
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionChallengeProtectorTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionChallengeProtectorTests.cs
new file mode 100644
index 000000000000..06033278fa1c
--- /dev/null
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionChallengeProtectorTests.cs
@@ -0,0 +1,117 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Security.Claims;
+using Microsoft.AspNetCore.DataProtection;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+public class DeviceBoundSessionChallengeProtectorTests
+{
+    private static readonly TimeSpan s_lifetime = TimeSpan.FromMinutes(5);
+
+    private static DeviceBoundSessionChallengeProtector CreateProtector()
+        => new(new EphemeralDataProtectionProvider());
+
+    private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
+    {
+        var identity = new ClaimsIdentity(authenticationType: "test");
+        foreach (var (type, value) in claims)
+        {
+            identity.AddClaim(new Claim(type, value));
+        }
+        return new ClaimsPrincipal(identity);
+    }
+
+    [Fact]
+    public void RegistrationChallenge_RoundTrips_ForSamePrincipal()
+    {
+        var protector = CreateProtector();
+        var principal = Principal(("sub", "alice"));
+
+        var challenge = protector.GenerateRegistrationChallenge(principal, s_lifetime);
+
+        Assert.True(protector.TryValidateRegistrationChallenge(challenge, principal));
+    }
+
+    [Fact]
+    public void RegistrationChallenge_Fails_ForDifferentPrincipal()
+    {
+        var protector = CreateProtector();
+        var challenge = protector.GenerateRegistrationChallenge(Principal(("sub", "alice")), s_lifetime);
+
+        Assert.False(protector.TryValidateRegistrationChallenge(challenge, Principal(("sub", "bob"))));
+    }
+
+    [Fact]
+    public void RegistrationChallenge_Fails_WhenTampered()
+    {
+        var protector = CreateProtector();
+        var principal = Principal(("sub", "alice"));
+        var challenge = protector.GenerateRegistrationChallenge(principal, s_lifetime);
+
+        var tampered = challenge[0] == 'A' ? "B" + challenge[1..] : "A" + challenge[1..];
+
+        Assert.False(protector.TryValidateRegistrationChallenge(tampered, principal));
+    }
+
+    [Fact]
+    public void RefreshChallenge_RoundTrips_ForSamePrincipalAndSession()
+    {
+        var protector = CreateProtector();
+        var principal = Principal(("sub", "alice"));
+
+        var challenge = protector.GenerateRefreshChallenge(principal, "session-1", s_lifetime);
+
+        Assert.True(protector.TryValidateRefreshChallenge(challenge, principal, "session-1"));
+    }
+
+    [Fact]
+    public void RefreshChallenge_Fails_ForWrongSessionId()
+    {
+        var protector = CreateProtector();
+        var principal = Principal(("sub", "alice"));
+        var challenge = protector.GenerateRefreshChallenge(principal, "session-1", s_lifetime);
+
+        Assert.False(protector.TryValidateRefreshChallenge(challenge, principal, "session-2"));
+    }
+
+    [Fact]
+    public void RefreshChallenge_Fails_ForDifferentPrincipal()
+    {
+        var protector = CreateProtector();
+        var challenge = protector.GenerateRefreshChallenge(Principal(("sub", "alice")), "session-1", s_lifetime);
+
+        Assert.False(protector.TryValidateRefreshChallenge(challenge, Principal(("sub", "bob")), "session-1"));
+    }
+
+    [Fact]
+    public void ComputeClaimUid_PrefersSub_OverNameIdentifier()
+    {
+        var withSub = Principal(("sub", "the-id"), (ClaimTypes.NameIdentifier, "other"));
+        var onlySub = Principal(("sub", "the-id"));
+
+        Assert.Equal(DeviceBoundSessionChallengeProtector.ComputeClaimUid(onlySub),
+            DeviceBoundSessionChallengeProtector.ComputeClaimUid(withSub));
+    }
+
+    [Fact]
+    public void ComputeClaimUid_UsesNameIdentifier_WhenNoSub()
+    {
+        var withNameId = Principal((ClaimTypes.NameIdentifier, "the-id"), (ClaimTypes.Upn, "other"));
+        var onlyNameId = Principal((ClaimTypes.NameIdentifier, "the-id"));
+
+        Assert.Equal(DeviceBoundSessionChallengeProtector.ComputeClaimUid(onlyNameId),
+            DeviceBoundSessionChallengeProtector.ComputeClaimUid(withNameId));
+    }
+
+    [Fact]
+    public void ComputeClaimUid_UsesUpn_WhenNoSubOrNameIdentifier()
+    {
+        var onlyUpn = Principal((ClaimTypes.Upn, "alice@contoso.com"));
+
+        Assert.False(string.IsNullOrEmpty(DeviceBoundSessionChallengeProtector.ComputeClaimUid(onlyUpn)));
+    }
+}
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionJwtValidatorTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionJwtValidatorTests.cs
new file mode 100644
index 000000000000..c79f08dceb19
--- /dev/null
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionJwtValidatorTests.cs
@@ -0,0 +1,101 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+public class DeviceBoundSessionJwtValidatorTests
+{
+    [Fact]
+    public async Task ValidateAsync_ValidEs256Proof_WithJwkHeader_Succeeds()
+    {
+        var key = DbscProofKey.CreateEs256();
+        var proof = key.CreateProof("challenge-1");
+
+        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
+
+        Assert.NotNull(result);
+        Assert.Equal("ES256", result.Algorithm);
+        Assert.Equal("challenge-1", result.Challenge);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_ValidRs256Proof_WithJwkHeader_Succeeds()
+    {
+        var key = DbscProofKey.CreateRs256();
+        var proof = key.CreateProof("challenge-2");
+
+        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
+
+        Assert.NotNull(result);
+        Assert.Equal("RS256", result.Algorithm);
+        Assert.Equal("challenge-2", result.Challenge);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_RefreshShape_UsesProvidedStoredKey_WhenJwkHeaderOmitted()
+    {
+        var key = DbscProofKey.CreateEs256();
+        var proof = key.CreateProof("challenge-3", includeJwkHeader: false);
+
+        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, key.PublicJwkJson, expectedChallenge: null);
+
+        Assert.NotNull(result);
+        Assert.Equal("challenge-3", result.Challenge);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_TamperedSignature_ReturnsNull()
+    {
+        var key = DbscProofKey.CreateEs256();
+        var proof = DbscProofKey.TamperSignature(key.CreateProof("challenge-4"));
+
+        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
+
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_WrongStoredKey_ReturnsNull()
+    {
+        var signingKey = DbscProofKey.CreateEs256();
+        var otherKey = DbscProofKey.CreateEs256();
+        var proof = signingKey.CreateProof("challenge-5", includeJwkHeader: false);
+
+        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, otherKey.PublicJwkJson, expectedChallenge: null);
+
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_MissingJwkHeaderAndNoStoredKey_ReturnsNull()
+    {
+        var key = DbscProofKey.CreateEs256();
+        var proof = key.CreateProof("challenge-6", includeJwkHeader: false);
+
+        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
+
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_MalformedToken_ReturnsNull()
+    {
+        var result = await DeviceBoundSessionJwtValidator.ValidateAsync("not-a-jwt", publicKeyJwk: null, expectedChallenge: null);
+
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_ChallengeMismatch_WhenExpectedChallengeProvided_ReturnsNull()
+    {
+        var key = DbscProofKey.CreateEs256();
+        var proof = key.CreateProof("actual-challenge");
+
+        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: "different-challenge");
+
+        Assert.Null(result);
+    }
+}
diff --git a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj
index 653f473b7b57..4291ae3a8ebf 100644
--- a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj
+++ b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj
@@ -44,6 +44,7 @@
     
     
     
+    
     
     
     

From f2d9b2f12dabf77e1af445cbd8cc991cdc958ea5 Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Tue, 23 Jun 2026 11:27:11 +0200
Subject: [PATCH 19/33] feat: harden Device Bound Sessions prototype and gate
 API as experimental
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- add per-reason debug logging in challenge protector and JWT validator
- separate DP purposes for registration/refresh; fix CBOR validation
- gate public API as experimental (ASP0030); anchor refresh cookie lifetime
- rename DbscSampleV2 sample to DbscDebugServer; add gap tests

🔒 - Generated by Copilot
---
 AspNetCore.slnx                               |  18 ++-
 docs/list-of-diagnostics.md                   |   1 +
 .../DeviceBoundSessions/plans/dbsc-design.md  |   8 +-
 .../Dashboard.cs                              |   2 +-
 .../DbscDebug.cs                              |   2 +-
 .../DbscDebugServer.csproj}                   |   0
 .../HarLoggingMiddleware.cs                   |   2 +-
 .../Program.cs                                |   4 +-
 .../Properties/launchSettings.json            |   2 +-
 .../DeviceBoundSessionChallengeProtector.cs   |  89 ++++++++++--
 .../src/DeviceBoundSessionConstants.cs        |  47 +++++++
 .../src/DeviceBoundSessionCookieEvents.cs     |  91 +++++++-----
 .../DeviceBoundSessionCookieEventsWrapper.cs  |  78 -----------
 .../src/DeviceBoundSessionDefaults.cs         |   3 +
 .../src/DeviceBoundSessionExtensions.cs       |  45 +++++-
 .../src/DeviceBoundSessionHandler.cs          |  83 +++++++----
 ...DeviceBoundSessionHttpContextExtensions.cs | 103 ++++++++++++++
 .../src/DeviceBoundSessionJsonContext.cs      |   2 +
 .../src/DeviceBoundSessionJwtValidator.cs     |  22 ++-
 .../src/DeviceBoundSessionOptions.cs          |   2 +
 .../DeviceBoundSessionRegistrationHeader.cs   |  50 +++++++
 .../src/DeviceBoundSessionScopeRule.cs        |   3 +
 .../src/LoggingExtensions.cs                  |  72 ++++++++++
 ....Authentication.DeviceBoundSessions.csproj |  18 +--
 ...onfigureDeviceBoundSessionCookieOptions.cs |  47 +++++++
 .../src/PublicAPI.Unshipped.txt               |  10 +-
 .../src/SessionCredential.cs                  |   6 +-
 .../src/SessionInstruction.cs                 |   2 +
 .../DeviceBoundSessions/src/SessionScope.cs   |   2 +
 .../src/SessionScopeRule.cs                   |   2 +
 .../test/DeviceBoundSessions/DbscProofKey.cs  |  46 ++++++-
 ...viceBoundSessionChallengeProtectorTests.cs |  50 ++++++-
 ...DeviceBoundSessionCookieProtectionTests.cs | 130 ++++++++++++++++++
 .../DeviceBoundSessionJwtValidatorTests.cs    | 117 ++++++++++++++--
 34 files changed, 959 insertions(+), 200 deletions(-)
 rename src/Security/Authentication/DeviceBoundSessions/samples/{DbscSampleV2 => DbscDebugServer}/Dashboard.cs (99%)
 rename src/Security/Authentication/DeviceBoundSessions/samples/{DbscSampleV2 => DbscDebugServer}/DbscDebug.cs (99%)
 rename src/Security/Authentication/DeviceBoundSessions/samples/{DbscSampleV2/DbscSampleV2.csproj => DbscDebugServer/DbscDebugServer.csproj} (100%)
 rename src/Security/Authentication/DeviceBoundSessions/samples/{DbscSampleV2 => DbscDebugServer}/HarLoggingMiddleware.cs (99%)
 rename src/Security/Authentication/DeviceBoundSessions/samples/{DbscSampleV2 => DbscDebugServer}/Program.cs (97%)
 rename src/Security/Authentication/DeviceBoundSessions/samples/{DbscSampleV2 => DbscDebugServer}/Properties/launchSettings.json (81%)
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConstants.cs
 delete mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEventsWrapper.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHttpContextExtensions.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionRegistrationHeader.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/LoggingExtensions.cs
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionCookieOptions.cs
 create mode 100644 src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs

diff --git a/AspNetCore.slnx b/AspNetCore.slnx
index 917cdafc5f67..171ece3cb872 100644
--- a/AspNetCore.slnx
+++ b/AspNetCore.slnx
@@ -135,8 +135,6 @@
     
     
     
-    
-    
     
     
     
@@ -804,6 +802,15 @@
     
     
   
+  
+    
+  
+  
+    
+  
+  
+    
+  
   
     
   
@@ -1187,6 +1194,9 @@
     
     
   
+  
+    
+  
   
   
     
@@ -1198,6 +1208,10 @@
     
     
   
+  
+    
+    
+  
   
     
   
diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md
index e6195634513e..39083ac0dbcf 100644
--- a/docs/list-of-diagnostics.md
+++ b/docs/list-of-diagnostics.md
@@ -35,6 +35,7 @@
 |  __`ASP0027`__ | Unnecessary public Program class declaration |
 |  __`ASP0028`__ | Consider using ListenAnyIP() instead of Listen(IPAddress.Any) |
 |  __`ASP0029`__ | Experimental warning for validations resolver APIs |
+|  __`ASP0030`__ | Experimental warning for Device Bound Sessions (DBSC) APIs |
 
 ### API (`API1000-API1003`)
 
diff --git a/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md b/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
index 804599fce5b3..0c2e17f3f0b3 100644
--- a/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
+++ b/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
@@ -218,8 +218,10 @@ The refresh scheme overrides `Cookie.Path = "/.well-known/dbsc"`. The session sc
 src/Security/Authentication/DeviceBoundSessions/src/
 ├── DeviceBoundSessionChallengeProtector.cs      # Singleton: generates/validates challenges (CBOR + time-limited DP)
 ├── DeviceBoundSessionConfiguration.cs           # JSON response model: session config
-├── DeviceBoundSessionCookieEvents.cs            # IPostConfigureOptions: hooks OnSigningIn to emit registration header
-├── DeviceBoundSessionCookieEventsWrapper.cs     # CookieAuthenticationEvents wrapper for EventsType scenarios
+├── DeviceBoundSessionConstants.cs               # Constants: DBSC header names + supported/advertised algorithms
+├── DeviceBoundSessionCookieEvents.cs            # CookieAuthenticationEvents wrapper for EventsType scenarios
+├── PostConfigureDeviceBoundSessionCookieOptions.cs # IPostConfigureOptions: hooks OnSigningIn to emit registration header
+├── DeviceBoundSessionRegistrationHeader.cs      # Writes the Secure-Session-Registration header on sign-in
 ├── DeviceBoundSessionCredentialConfiguration.cs # JSON response model: credential entry
 ├── DeviceBoundSessionDefaults.cs                # Constants: scheme name, paths
 ├── DeviceBoundSessionExtensions.cs              # AddDeviceBoundSession() extension method
@@ -290,4 +292,4 @@ Rejected because:
 Rejected because:
 - `ITimeLimitedDataProtector` already provides uniqueness via embedded timestamp + MAC
 - Adding a nonce increases payload size without security benefit
-- The data protection system's MAC prevents any form of replay or forgery
\ No newline at end of file
+- The data protection system's MAC prevents any form of replay or forgery
diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Dashboard.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Dashboard.cs
similarity index 99%
rename from src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Dashboard.cs
rename to src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Dashboard.cs
index 9846fda92e7a..c6c765d87d59 100644
--- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Dashboard.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Dashboard.cs
@@ -1,7 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
-namespace DbscSampleV2;
+namespace DbscDebugServer;
 
 /// The dark-themed single-page debug dashboard served at /.
 public static class Dashboard
diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscDebug.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebug.cs
similarity index 99%
rename from src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscDebug.cs
rename to src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebug.cs
index 40a8959f76a5..f6b8e9896c02 100644
--- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscDebug.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebug.cs
@@ -15,7 +15,7 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Options;
 
-namespace DbscSampleV2;
+namespace DbscDebugServer;
 
 /// Well-known scheme, cookie, and purpose names used by the debug tooling.
 public static class DbscNames
diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscSampleV2.csproj b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebugServer.csproj
similarity index 100%
rename from src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/DbscSampleV2.csproj
rename to src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebugServer.csproj
diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/HarLoggingMiddleware.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/HarLoggingMiddleware.cs
similarity index 99%
rename from src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/HarLoggingMiddleware.cs
rename to src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/HarLoggingMiddleware.cs
index 317e85ba5afd..bdd69ed92e53 100644
--- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/HarLoggingMiddleware.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/HarLoggingMiddleware.cs
@@ -11,7 +11,7 @@
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Http.Extensions;
 
-namespace DbscSampleV2;
+namespace DbscDebugServer;
 
 /// 
 /// Middleware that captures all HTTP requests/responses and writes them to a HAR file on disk.
diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
similarity index 97%
rename from src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs
rename to src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
index c9b8f2d52664..20539e13a1de 100644
--- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Program.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
@@ -1,3 +1,5 @@
+#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
@@ -12,7 +14,7 @@
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Options;
 
-namespace DbscSampleV2;
+namespace DbscDebugServer;
 
 public static class Program
 {
diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Properties/launchSettings.json b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Properties/launchSettings.json
similarity index 81%
rename from src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Properties/launchSettings.json
rename to src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Properties/launchSettings.json
index 84fa2b8ea933..49db3bb172ed 100644
--- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscSampleV2/Properties/launchSettings.json
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Properties/launchSettings.json
@@ -1,6 +1,6 @@
 {
   "profiles": {
-    "DbscSampleV2": {
+    "DbscDebugServer": {
       "commandName": "Project",
       "applicationUrl": "https://localhost:7299"
     }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
index b51316ba16a9..6b2151c549bb 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs
@@ -6,17 +6,28 @@
 using System.Security.Cryptography;
 using Microsoft.AspNetCore.DataProtection;
 using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.Logging;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
 internal sealed class DeviceBoundSessionChallengeProtector
 {
-    private readonly ITimeLimitedDataProtector _protector;
+    private readonly ITimeLimitedDataProtector _registrationProtector;
+    private readonly ITimeLimitedDataProtector _refreshProtector;
+    private readonly ILogger _logger;
 
-    public DeviceBoundSessionChallengeProtector(IDataProtectionProvider dataProtectionProvider)
+    public DeviceBoundSessionChallengeProtector(IDataProtectionProvider dataProtectionProvider, ILogger logger)
     {
-        _protector = dataProtectionProvider
-            .CreateProtector("Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.v1")
+        _logger = logger;
+
+        // Registration and refresh challenges use distinct data-protection purposes so a challenge
+        // minted for one flow cannot be decrypted (let alone accepted) by the other. This makes
+        // cross-type challenge confusion cryptographically impossible, independent of payload shape.
+        _registrationProtector = dataProtectionProvider
+            .CreateProtector("Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.Registration.v1")
+            .ToTimeLimitedDataProtector();
+        _refreshProtector = dataProtectionProvider
+            .CreateProtector("Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.Refresh.v1")
             .ToTimeLimitedDataProtector();
     }
 
@@ -25,7 +36,7 @@ public string GenerateRegistrationChallenge(ClaimsPrincipal principal, TimeSpan
         var claimUid = ComputeClaimUid(principal);
         var writer = new CborWriter(allowMultipleRootLevelValues: true);
         writer.WriteTextString(claimUid);
-        return WebEncoders.Base64UrlEncode(_protector.Protect(writer.Encode(), lifetime));
+        return WebEncoders.Base64UrlEncode(_registrationProtector.Protect(writer.Encode(), lifetime));
     }
 
     public string GenerateRefreshChallenge(ClaimsPrincipal principal, string sessionId, TimeSpan lifetime)
@@ -34,13 +45,15 @@ public string GenerateRefreshChallenge(ClaimsPrincipal principal, string session
         var writer = new CborWriter(allowMultipleRootLevelValues: true);
         writer.WriteTextString(claimUid);
         writer.WriteTextString(sessionId);
-        return WebEncoders.Base64UrlEncode(_protector.Protect(writer.Encode(), lifetime));
+        return WebEncoders.Base64UrlEncode(_refreshProtector.Protect(writer.Encode(), lifetime));
     }
 
     public bool TryValidateRegistrationChallenge(string challenge, ClaimsPrincipal principal)
     {
-        if (!TryUnprotect(challenge, out var payload))
+        if (!TryUnprotect(_registrationProtector, challenge, out var payload))
         {
+            // Expired, tampered, or minted for a different flow/version — undecryptable.
+            _logger.RegistrationChallengeUndecryptable();
             return false;
         }
 
@@ -48,18 +61,38 @@ public bool TryValidateRegistrationChallenge(string challenge, ClaimsPrincipal p
         {
             var reader = new CborReader(payload, allowMultipleRootLevelValues: true);
             var storedClaimUid = reader.ReadTextString();
-            return string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal);
+            if (reader.PeekState() != CborReaderState.Finished)
+            {
+                // Extra trailing data — not a registration challenge in the shape this version writes.
+                _logger.RegistrationChallengeMalformed();
+                return false;
+            }
+
+            if (!string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal))
+            {
+                // Decrypted but bound to a different principal than the request.
+                _logger.RegistrationChallengePrincipalMismatch();
+                return false;
+            }
+
+            return true;
         }
-        catch (CborContentException)
+        catch (Exception ex) when (ex is CborContentException or InvalidOperationException)
         {
+            // Decrypted (so authentic) but not in the expected registration-challenge shape — e.g. a
+            // challenge minted by a different/older serialization version. Reading the wrong type throws
+            // InvalidOperationException; genuinely-malformed CBOR throws CborContentException.
+            _logger.RegistrationChallengeMalformed();
             return false;
         }
     }
 
     public bool TryValidateRefreshChallenge(string challenge, ClaimsPrincipal principal, string expectedSessionId)
     {
-        if (!TryUnprotect(challenge, out var payload))
+        if (!TryUnprotect(_refreshProtector, challenge, out var payload))
         {
+            // Expired, tampered, or minted for a different flow/version — undecryptable.
+            _logger.RefreshChallengeUndecryptable(expectedSessionId);
             return false;
         }
 
@@ -68,20 +101,46 @@ public bool TryValidateRefreshChallenge(string challenge, ClaimsPrincipal princi
             var reader = new CborReader(payload, allowMultipleRootLevelValues: true);
             var storedClaimUid = reader.ReadTextString();
             var storedSessionId = reader.ReadTextString();
-            return string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal) &&
-                string.Equals(storedSessionId, expectedSessionId, StringComparison.Ordinal);
+            if (reader.PeekState() != CborReaderState.Finished)
+            {
+                // Extra trailing data — not a refresh challenge in the shape this version writes.
+                _logger.RefreshChallengeMalformed(expectedSessionId);
+                return false;
+            }
+
+            if (!string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal))
+            {
+                // Decrypted but bound to a different principal than the request.
+                _logger.RefreshChallengePrincipalMismatch(expectedSessionId);
+                return false;
+            }
+
+            if (!string.Equals(storedSessionId, expectedSessionId, StringComparison.Ordinal))
+            {
+                // Decrypted but bound to a different session than the request.
+                _logger.RefreshChallengeSessionMismatch(expectedSessionId);
+                return false;
+            }
+
+            return true;
         }
-        catch (CborContentException)
+        catch (Exception ex) when (ex is CborContentException or InvalidOperationException)
         {
+            // Decrypted (so authentic) but not in the expected refresh-challenge shape — e.g. a challenge
+            // minted by a different/older serialization version, or a registration challenge (one field)
+            // replayed as a refresh proof. Reading the wrong type or past the end throws
+            // InvalidOperationException; genuinely-malformed CBOR (practically unreachable after an
+            // authenticated decrypt) throws CborContentException. Either way the challenge is unusable.
+            _logger.RefreshChallengeMalformed(expectedSessionId);
             return false;
         }
     }
 
-    private bool TryUnprotect(string challenge, out byte[] payload)
+    private static bool TryUnprotect(ITimeLimitedDataProtector protector, string challenge, out byte[] payload)
     {
         try
         {
-            payload = _protector.Unprotect(WebEncoders.Base64UrlDecode(challenge));
+            payload = protector.Unprotect(WebEncoders.Base64UrlDecode(challenge));
             return true;
         }
         catch
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConstants.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConstants.cs
new file mode 100644
index 000000000000..cdca8d8ce3fa
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConstants.cs
@@ -0,0 +1,47 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Centralized DBSC wire-protocol constants (header names and supported signing algorithms)
+/// shared across the registration-header writer, the protocol handler, and the proof validator.
+/// 
+internal static class DeviceBoundSessionConstants
+{
+    /// 
+    /// DBSC HTTP header names (the long Secure-Session-* / Sec-Secure-Session-Id forms).
+    /// 
+    internal static class Headers
+    {
+        /// Response header that triggers registration and carries the challenge.
+        internal const string Registration = "Secure-Session-Registration";
+
+        /// Response header that demands a fresh proof (emitted only on 403).
+        internal const string Challenge = "Secure-Session-Challenge";
+
+        /// Request header that carries the device's proof-of-possession JWT.
+        internal const string Proof = "Secure-Session-Response";
+
+        /// Request header that identifies the session on refresh.
+        internal const string SessionId = "Sec-Secure-Session-Id";
+    }
+
+    /// 
+    /// ECDSA P-256 / SHA-256 proof signing algorithm.
+    /// 
+    internal const string Es256 = "ES256";
+
+    /// 
+    /// RSASSA-PKCS1-v1_5 / SHA-256 proof signing algorithm.
+    /// 
+    internal const string Rs256 = "RS256";
+
+    /// 
+    /// The supported algorithms formatted as the structured-field inner-list advertised in the
+    ///  header (e.g. (ES256 RS256)). This advertised set
+    /// MUST stay in lock-step with the algorithms the proof validator accepts (,
+    /// ).
+    /// 
+    internal const string AdvertisedAlgorithms = "(" + Es256 + " " + Rs256 + ")";
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
index 462e1ba02f01..bcdf63cbe324 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
@@ -4,55 +4,80 @@
 using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Options;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
-internal sealed class PostConfigureDeviceBoundSessionCookieOptions : IPostConfigureOptions
+/// 
+/// A  decorator used when a DBSC source cookie scheme sets
+/// the cookie options' EventsType. It emits the DBSC registration header on
+/// sign-in and forwards every event to the application's configured (DI-resolved) inner events.
+/// 
+internal sealed class DeviceBoundSessionCookieEvents : CookieAuthenticationEvents
 {
-    private readonly IOptions _sourceSchemes;
+    private readonly string _dbscScheme;
+    private readonly CookieAuthenticationEvents _innerEvents;
+    private readonly Type? _innerEventsType;
 
-    public PostConfigureDeviceBoundSessionCookieOptions(IOptions sourceSchemes)
+    public DeviceBoundSessionCookieEvents(string dbscScheme, CookieAuthenticationEvents innerEvents, Type? innerEventsType)
     {
-        _sourceSchemes = sourceSchemes;
+        _dbscScheme = dbscScheme;
+        _innerEvents = innerEvents;
+        _innerEventsType = innerEventsType;
     }
 
-    public void PostConfigure(string? name, CookieAuthenticationOptions options)
+    public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
     {
-        ArgumentNullException.ThrowIfNull(name);
+        await GetInnerEvents(context.HttpContext).ValidatePrincipal(context);
+    }
 
-        if (!_sourceSchemes.Value.Schemes.TryGetValue(name, out var dbscScheme))
-        {
-            return;
-        }
+    public override async Task CheckSlidingExpiration(CookieSlidingExpirationContext context)
+    {
+        await GetInnerEvents(context.HttpContext).CheckSlidingExpiration(context);
+    }
 
-        if (options.EventsType is null)
-        {
-            var priorSigningIn = options.Events.OnSigningIn;
-            options.Events.OnSigningIn = async context =>
-            {
-                EmitRegistrationHeader(context, dbscScheme);
-                await priorSigningIn(context);
-            };
-            return;
-        }
+    public override async Task SigningIn(CookieSigningInContext context)
+    {
+        DeviceBoundSessionRegistrationHeader.Emit(context.HttpContext, context.Principal, _dbscScheme);
+        await GetInnerEvents(context.HttpContext).SigningIn(context);
+    }
+
+    public override async Task SignedIn(CookieSignedInContext context)
+    {
+        await GetInnerEvents(context.HttpContext).SignedIn(context);
+    }
+
+    public override async Task SigningOut(CookieSigningOutContext context)
+    {
+        await GetInnerEvents(context.HttpContext).SigningOut(context);
+    }
 
-        options.Events = new DeviceBoundSessionCookieEvents(dbscScheme, options.Events, options.EventsType);
-        options.EventsType = null;
+    public override async Task RedirectToLogout(RedirectContext context)
+    {
+        await GetInnerEvents(context.HttpContext).RedirectToLogout(context);
     }
 
-    internal static void EmitRegistrationHeader(CookieSigningInContext context, string dbscScheme)
+    public override async Task RedirectToLogin(RedirectContext context)
     {
-        var dbscOptions = context.HttpContext.RequestServices
-            .GetRequiredService>()
-            .Get(dbscScheme);
-        var challengeProtector = context.HttpContext.RequestServices
-            .GetRequiredService();
+        await GetInnerEvents(context.HttpContext).RedirectToLogin(context);
+    }
+
+    public override async Task RedirectToReturnUrl(RedirectContext context)
+    {
+        await GetInnerEvents(context.HttpContext).RedirectToReturnUrl(context);
+    }
 
-        var principal = context.Principal ?? new System.Security.Claims.ClaimsPrincipal();
-        var challenge = challengeProtector.GenerateRegistrationChallenge(principal, dbscOptions.ChallengeMaxAge);
+    public override async Task RedirectToAccessDenied(RedirectContext context)
+    {
+        await GetInnerEvents(context.HttpContext).RedirectToAccessDenied(context);
+    }
+
+    private CookieAuthenticationEvents GetInnerEvents(HttpContext httpContext)
+    {
+        if (_innerEventsType is null)
+        {
+            return _innerEvents;
+        }
 
-        var headerValue = $"(ES256 RS256);path=\"{dbscOptions.RegistrationPath.Value}\";challenge=\"{challenge}\"";
-        context.Response.Headers.Append("Secure-Session-Registration", headerValue);
+        return (CookieAuthenticationEvents)httpContext.RequestServices.GetRequiredService(_innerEventsType);
     }
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEventsWrapper.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEventsWrapper.cs
deleted file mode 100644
index 9f1358170527..000000000000
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEventsWrapper.cs
+++ /dev/null
@@ -1,78 +0,0 @@
-// Licensed to the .NET Foundation under one or more agreements.
-// The .NET Foundation licenses this file to you under the MIT license.
-
-using Microsoft.AspNetCore.Authentication.Cookies;
-using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.DependencyInjection;
-
-namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
-
-internal sealed class DeviceBoundSessionCookieEvents : CookieAuthenticationEvents
-{
-    private readonly string _dbscScheme;
-    private readonly CookieAuthenticationEvents _innerEvents;
-    private readonly Type? _innerEventsType;
-
-    public DeviceBoundSessionCookieEvents(string dbscScheme, CookieAuthenticationEvents innerEvents, Type? innerEventsType)
-    {
-        _dbscScheme = dbscScheme;
-        _innerEvents = innerEvents;
-        _innerEventsType = innerEventsType;
-    }
-
-    public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
-    {
-        await GetInnerEvents(context.HttpContext).ValidatePrincipal(context);
-    }
-
-    public override async Task CheckSlidingExpiration(CookieSlidingExpirationContext context)
-    {
-        await GetInnerEvents(context.HttpContext).CheckSlidingExpiration(context);
-    }
-
-    public override async Task SigningIn(CookieSigningInContext context)
-    {
-        PostConfigureDeviceBoundSessionCookieOptions.EmitRegistrationHeader(context, _dbscScheme);
-        await GetInnerEvents(context.HttpContext).SigningIn(context);
-    }
-
-    public override async Task SignedIn(CookieSignedInContext context)
-    {
-        await GetInnerEvents(context.HttpContext).SignedIn(context);
-    }
-
-    public override async Task SigningOut(CookieSigningOutContext context)
-    {
-        await GetInnerEvents(context.HttpContext).SigningOut(context);
-    }
-
-    public override async Task RedirectToLogout(RedirectContext context)
-    {
-        await GetInnerEvents(context.HttpContext).RedirectToLogout(context);
-    }
-
-    public override async Task RedirectToLogin(RedirectContext context)
-    {
-        await GetInnerEvents(context.HttpContext).RedirectToLogin(context);
-    }
-
-    public override async Task RedirectToReturnUrl(RedirectContext context)
-    {
-        await GetInnerEvents(context.HttpContext).RedirectToReturnUrl(context);
-    }
-
-    public override async Task RedirectToAccessDenied(RedirectContext context)
-    {
-        await GetInnerEvents(context.HttpContext).RedirectToAccessDenied(context);
-    }
-
-    private CookieAuthenticationEvents GetInnerEvents(HttpContext httpContext)
-    {
-        if (_innerEventsType is null)
-        {
-            return _innerEvents;
-        }
-
-        return (CookieAuthenticationEvents)httpContext.RequestServices.GetRequiredService(_innerEventsType);
-    }
-}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs
index 315ec4577f47..84d6fe93a77a 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs
@@ -1,11 +1,14 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics.CodeAnalysis;
+
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
 /// 
 /// Default values for the Device Bound Session Credentials authentication scheme.
 /// 
+[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
 public static class DeviceBoundSessionDefaults
 {
     /// 
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
index df59c525d801..c33e8dade774 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics.CodeAnalysis;
 using Microsoft.AspNetCore.Authentication;
 using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
@@ -13,6 +14,7 @@ namespace Microsoft.Extensions.DependencyInjection;
 /// 
 /// Extension methods to configure Device Bound Session Credentials authentication.
 /// 
+[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
 public static class DeviceBoundSessionExtensions
 {
     /// 
@@ -22,15 +24,24 @@ public static class DeviceBoundSessionExtensions
     /// 
     /// The authentication builder.
     /// The existing cookie authentication scheme to protect with DBSC (e.g., "Identity.Application").
-    /// Optional action to configure DBSC options.
+    /// The authentication builder for chaining.
+    public static AuthenticationBuilder AddDeviceBoundSession(
+        this AuthenticationBuilder builder,
+        string sourceScheme)
+        => AddDeviceBoundSessionCore(builder, DeviceBoundSessionDefaults.AuthenticationScheme, sourceScheme, configureOptions: null);
+
+    /// 
+    /// Adds Device Bound Session Credentials (DBSC) authentication that wraps an existing cookie scheme.
+    /// 
+    /// The authentication builder.
+    /// The existing cookie authentication scheme to protect with DBSC (e.g., "Identity.Application").
+    /// Action to configure DBSC options.
     /// The authentication builder for chaining.
     public static AuthenticationBuilder AddDeviceBoundSession(
         this AuthenticationBuilder builder,
         string sourceScheme,
-        Action? configureOptions = null)
-    {
-        return AddDeviceBoundSession(builder, DeviceBoundSessionDefaults.AuthenticationScheme, sourceScheme, configureOptions);
-    }
+        Action configureOptions)
+        => AddDeviceBoundSessionCore(builder, DeviceBoundSessionDefaults.AuthenticationScheme, sourceScheme, configureOptions);
 
     /// 
     /// Adds Device Bound Session Credentials (DBSC) authentication that wraps an existing cookie scheme.
@@ -38,13 +49,33 @@ public static AuthenticationBuilder AddDeviceBoundSession(
     /// The authentication builder.
     /// The DBSC authentication scheme name.
     /// The existing cookie authentication scheme to protect with DBSC.
-    /// Optional action to configure DBSC options.
     /// The authentication builder for chaining.
     public static AuthenticationBuilder AddDeviceBoundSession(
         this AuthenticationBuilder builder,
         string authenticationScheme,
+        string sourceScheme)
+        => AddDeviceBoundSessionCore(builder, authenticationScheme, sourceScheme, configureOptions: null);
+
+    /// 
+    /// Adds Device Bound Session Credentials (DBSC) authentication that wraps an existing cookie scheme.
+    /// 
+    /// The authentication builder.
+    /// The DBSC authentication scheme name.
+    /// The existing cookie authentication scheme to protect with DBSC.
+    /// Action to configure DBSC options.
+    /// The authentication builder for chaining.
+    public static AuthenticationBuilder AddDeviceBoundSession(
+        this AuthenticationBuilder builder,
+        string authenticationScheme,
+        string sourceScheme,
+        Action configureOptions)
+        => AddDeviceBoundSessionCore(builder, authenticationScheme, sourceScheme, configureOptions);
+
+    private static AuthenticationBuilder AddDeviceBoundSessionCore(
+        AuthenticationBuilder builder,
+        string authenticationScheme,
         string sourceScheme,
-        Action? configureOptions = null)
+        Action? configureOptions)
     {
         var refreshScheme = $"{sourceScheme}.Dbsc.Refresh";
         var sessionScheme = $"{sourceScheme}.Dbsc.Session";
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
index 2586e3d1aaa0..18b1fdead73d 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics.CodeAnalysis;
 using System.Linq;
 using System.Security.Claims;
 using System.Security.Cryptography;
@@ -19,13 +20,19 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// Authentication handler that implements the Device Bound Session Credentials (DBSC) protocol.
 /// Handles registration and refresh endpoints, delegating cookie management to separate cookie schemes.
 /// 
+[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
 public class DeviceBoundSessionHandler : AuthenticationHandler, IAuthenticationRequestHandler
 {
     private readonly DeviceBoundSessionChallengeProtector _challengeProtector;
+    private readonly DeviceBoundSessionJwtValidator _jwtValidator;
 
     /// 
     /// Initializes a new instance of .
     /// 
+    /// The monitor for  instances.
+    /// The  used to create loggers.
+    /// The  used to encode URLs.
+    /// The  used to protect and unprotect registration challenges.
     public DeviceBoundSessionHandler(
         IOptionsMonitor options,
         ILoggerFactory logger,
@@ -33,18 +40,24 @@ public DeviceBoundSessionHandler(
         IDataProtectionProvider dataProtectionProvider)
         : base(options, logger, encoder)
     {
-        _challengeProtector = new DeviceBoundSessionChallengeProtector(dataProtectionProvider);
+        _challengeProtector = new DeviceBoundSessionChallengeProtector(dataProtectionProvider, logger.CreateLogger());
+        _jwtValidator = new DeviceBoundSessionJwtValidator(logger.CreateLogger());
     }
 
     /// 
     /// The handler does not authenticate normal requests — cookie handlers do that.
     /// 
+    /// A completed task whose result is always .
     protected override Task HandleAuthenticateAsync()
         => Task.FromResult(AuthenticateResult.NoResult());
 
     /// 
     /// Handles DBSC registration and refresh requests if the path matches.
     /// 
+    /// 
+    /// A task whose result is  when the request matched a DBSC registration or
+    /// refresh endpoint and was handled (the pipeline should short-circuit); otherwise .
+    /// 
     public async Task HandleRequestAsync()
     {
         if (HttpMethods.IsPost(Request.Method))
@@ -68,20 +81,20 @@ public async Task HandleRequestAsync()
     private async Task HandleRegistrationAsync()
     {
         // Extract the JWT proof from the Secure-Session-Response header
-        var responseHeader = Request.Headers["Secure-Session-Response"].ToString();
-        if (string.IsNullOrEmpty(responseHeader))
+        var proofHeader = Request.Headers[DeviceBoundSessionConstants.Headers.Proof].ToString();
+        if (string.IsNullOrEmpty(proofHeader))
         {
             Response.StatusCode = StatusCodes.Status400BadRequest;
             return;
         }
 
-        responseHeader = responseHeader.Trim('"');
+        proofHeader = proofHeader.Trim('"');
 
         // Validate the JWT and extract the public key (registration: no existing key to check against)
-        var jwtResult = await DeviceBoundSessionJwtValidator.ValidateAsync(responseHeader, publicKeyJwk: null, expectedChallenge: null);
+        var jwtResult = await _jwtValidator.ValidateAsync(proofHeader, publicKeyJwk: null, expectedChallenge: null);
         if (jwtResult is null)
         {
-            Logger.LogWarning("DBSC registration: invalid JWT proof.");
+            // The validator logs the specific reason (malformed / wrong typ / unsupported alg / bad signature).
             Response.StatusCode = StatusCodes.Status400BadRequest;
             return;
         }
@@ -90,7 +103,7 @@ private async Task HandleRegistrationAsync()
         var authResult = await Context.AuthenticateAsync(Options.RegistrationSourceScheme);
         if (!authResult.Succeeded || authResult.Principal is null)
         {
-            Logger.LogWarning("DBSC registration: no valid authentication from source scheme.");
+            Logger.RegistrationNoSourceAuthentication();
             Response.StatusCode = StatusCodes.Status401Unauthorized;
             return;
         }
@@ -98,9 +111,16 @@ private async Task HandleRegistrationAsync()
         var principal = authResult.Principal;
         var properties = authResult.Properties ?? new AuthenticationProperties();
 
-        if (jwtResult.Challenge is null || !ValidateRegistrationChallenge(jwtResult.Challenge, principal))
+        if (jwtResult.Challenge is null)
         {
-            Logger.LogWarning("DBSC registration: invalid challenge.");
+            Logger.RegistrationChallengeMissing();
+            Response.StatusCode = StatusCodes.Status403Forbidden;
+            return;
+        }
+
+        if (!ValidateRegistrationChallenge(jwtResult.Challenge, principal))
+        {
+            // The protector logs the specific reason (undecryptable / malformed / principal-mismatch).
             Response.StatusCode = StatusCodes.Status403Forbidden;
             return;
         }
@@ -119,6 +139,13 @@ private async Task HandleRegistrationAsync()
         refreshProperties.Items["DbscAlgorithm"] = jwtResult.Algorithm;
         refreshProperties.IsPersistent = true;
 
+        // Anchor the refresh cookie's lifetime to the original sign-in cookie so the bound session
+        // never outlives the credential it was exchanged for. Copying the source ticket's IssuedUtc/
+        // ExpiresUtc makes the cookie handler honor them (instead of starting a fresh ExpireTimeSpan
+        // window at registration time). Falls back to the handler default if the source has no expiry.
+        refreshProperties.IssuedUtc = properties.IssuedUtc;
+        refreshProperties.ExpiresUtc = properties.ExpiresUtc;
+
         // 1. Stamp the refresh cookie (path-scoped stash with ticket + public key)
         await Context.SignInAsync(Options.RefreshScheme, principal, refreshProperties);
 
@@ -135,11 +162,6 @@ private async Task HandleRegistrationAsync()
         await Context.SignOutAsync(Options.RegistrationSourceScheme);
 
         // Build and return session instructions JSON.
-        // NOTE: We intentionally do NOT emit a Secure-Session-Challenge header on this 200 response.
-        // The canonical DBSC test server (drubery/dbsc-test-server) only issues a challenge on a 403
-        // (the "demand proof" path). Pre-seeding a challenge on every success response makes Chrome
-        // proactively refresh to consume it, producing a post-registration refresh storm. The next
-        // refresh will receive its challenge via the 403 + Secure-Session-Challenge handshake instead.
         var instructions = BuildSessionInstruction(sessionId);
 
         Response.StatusCode = StatusCodes.Status200OK;
@@ -151,7 +173,7 @@ private async Task HandleRegistrationAsync()
     private async Task HandleRefreshAsync()
     {
         // Read session ID from header
-        var sessionIdHeader = Request.Headers["Sec-Secure-Session-Id"].ToString();
+        var sessionIdHeader = Request.Headers[DeviceBoundSessionConstants.Headers.SessionId].ToString();
         if (string.IsNullOrEmpty(sessionIdHeader))
         {
             Response.StatusCode = StatusCodes.Status400BadRequest;
@@ -164,7 +186,7 @@ private async Task HandleRefreshAsync()
         var authResult = await Context.AuthenticateAsync(Options.RefreshScheme);
         if (!authResult.Succeeded || authResult.Principal is null)
         {
-            Logger.LogWarning("DBSC refresh: no valid refresh cookie for session {SessionId}.", sessionIdHeader);
+            Logger.RefreshNoCookie(sessionIdHeader);
             Response.StatusCode = StatusCodes.Status401Unauthorized;
             return;
         }
@@ -188,37 +210,43 @@ private async Task HandleRefreshAsync()
         }
 
         // Check for the proof JWT
-        var proofHeader = Request.Headers["Secure-Session-Response"].ToString();
+        var proofHeader = Request.Headers[DeviceBoundSessionConstants.Headers.Proof].ToString();
         if (string.IsNullOrEmpty(proofHeader))
         {
             // No proof yet — issue a challenge (first leg of refresh)
             var challenge = GenerateRefreshChallenge(authResult.Principal, sessionIdHeader);
             Response.StatusCode = StatusCodes.Status403Forbidden;
-            Response.Headers["Secure-Session-Challenge"] = $"\"{challenge}\";id=\"{sessionIdHeader}\"";
+            Response.Headers[DeviceBoundSessionConstants.Headers.Challenge] = $"\"{challenge}\";id=\"{sessionIdHeader}\"";
             return;
         }
 
         proofHeader = proofHeader.Trim('"');
 
         // Validate the JWT proof against the public key from the refresh cookie
-        var jwtResult = await DeviceBoundSessionJwtValidator.ValidateAsync(proofHeader, publicKeyJwk, expectedChallenge: null);
+        var jwtResult = await _jwtValidator.ValidateAsync(proofHeader, publicKeyJwk, expectedChallenge: null);
         if (jwtResult is null)
         {
-            Logger.LogWarning("DBSC refresh: invalid JWT signature for session {SessionId}.", sessionIdHeader);
+            // The validator logs the specific reason (malformed / wrong typ / unsupported alg / bad signature).
             Response.StatusCode = StatusCodes.Status403Forbidden;
             return;
         }
 
-        // Validate the challenge (jti) is one we issued and is fresh.
-        if (jwtResult.Challenge is null || !ValidateRefreshChallenge(jwtResult.Challenge, authResult.Principal, sessionIdHeader))
+        // Validate the challenge (jti). The protector logs the specific reason
+        // (undecryptable / malformed / principal- or session-mismatch); a missing jti is invalid.
+        var challengeValid = jwtResult.Challenge is not null
+            && ValidateRefreshChallenge(jwtResult.Challenge, authResult.Principal, sessionIdHeader);
+
+        if (!challengeValid)
         {
-            Logger.LogWarning("DBSC refresh: stale or invalid challenge for session {SessionId}.", sessionIdHeader);
-            // Re-issue a fresh challenge so the client can immediately retry with a valid proof
-            // (the DBSC 403 + Secure-Session-Challenge handshake). Without this the client would
-            // keep retrying the same stale challenge, producing a burst of 403s.
+            // Re-issue a fresh, correctly-bound challenge so the client can immediately
+            // retry (the DBSC 403 + Secure-Session-Challenge handshake).
+            // This is curative, not a futile retry: the signature and refresh cookie already validated and are
+            // unchanged on retry, so the stale/expired/version-skewed challenge was the only failing input and a
+            // fresh nonce fixes it. Persistent client/server faults can't recover but are bounded by the
+            // client's challenge quota, which ends in a clean re-login.
             var retryChallenge = GenerateRefreshChallenge(authResult.Principal, sessionIdHeader);
             Response.StatusCode = StatusCodes.Status403Forbidden;
-            Response.Headers["Secure-Session-Challenge"] = $"\"{retryChallenge}\";id=\"{sessionIdHeader}\"";
+            Response.Headers[DeviceBoundSessionConstants.Headers.Challenge] = $"\"{retryChallenge}\";id=\"{sessionIdHeader}\"";
             return;
         }
 
@@ -278,7 +306,6 @@ private SessionInstruction BuildSessionInstruction(string sessionId)
             {
                 new SessionCredential
                 {
-                    Type = "cookie",
                     Name = sessionCookieName,
                     Attributes = "Secure; HttpOnly; SameSite=Lax; Path=/",
                 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHttpContextExtensions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHttpContextExtensions.cs
new file mode 100644
index 000000000000..9751b123c3b7
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHttpContextExtensions.cs
@@ -0,0 +1,103 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Diagnostics.CodeAnalysis;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Extension methods on  for Device Bound Session Credentials (DBSC).
+/// 
+[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
+public static class DeviceBoundSessionHttpContextExtensions
+{
+    /// 
+    /// Writes the DBSC Secure-Session-Registration response header for the current
+    /// authenticated user (), advertising the supported signing
+    /// algorithms and a fresh, principal-bound registration challenge.
+    /// 
+    /// 
+    /// 
+    /// Use this to opt an already signed-in user into a device bound session without forcing a
+    /// re-login — for example, to migrate existing sessions after deploying DBSC instead of waiting
+    /// for them to expire. Registration is not tied to the sign-in event: a DBSC-capable browser reacts
+    /// to this header on any authenticated response and starts registration, sending the proof to the
+    /// registration endpoint with the existing source cookie attached. Browsers that do not support DBSC
+    /// ignore the header, so it is safe to emit broadly.
+    /// 
+    /// 
+    /// Calling this does not itself create a session; it only advertises registration. Emitting it
+    /// repeatedly mints a new challenge each time, so the caller owns idempotency: gate the call so a
+    /// user is offered registration at most once (for example, with a durable marker cookie), and skip
+    /// clients that are already bound (detected by the presence of the bound session cookie). The header
+    /// is only honored over HTTPS.
+    /// 
+    /// 
+    /// The current .
+    /// 
+    /// The source cookie authentication scheme that was passed to
+    /// AddDeviceBoundSession (for example IdentityConstants.ApplicationScheme).
+    /// 
+    ///  is .
+    ///  is  or empty.
+    /// 
+    ///  is not a registered Device Bound Session source scheme, or the
+    /// current user is not authenticated.
+    /// 
+    /// 
+    /// Migration middleware that offers DBSC registration to already-signed-in users once each. It skips
+    /// clients that already have a bound session (the bound session cookie is present) and uses a durable
+    /// marker cookie so the offer is not repeated on every request:
+    /// 
+    /// // The DBSC bound session cookie follows .AspNetCore.{sourceScheme}.Dbsc.Session
+    /// const string boundCookie = ".AspNetCore.Identity.Application.Dbsc.Session";
+    ///
+    /// app.Use(async (context, next) =>
+    /// {
+    ///     var alreadyBound = context.Request.Cookies.ContainsKey(boundCookie);
+    ///     var alreadyOffered = context.Request.Cookies.ContainsKey("dbsc-offered");
+    ///
+    ///     if (context.User.Identity?.IsAuthenticated == true && !alreadyBound && !alreadyOffered)
+    ///     {
+    ///         context.WriteDeviceBoundSessionRegistration(IdentityConstants.ApplicationScheme);
+    ///
+    ///         context.Response.Cookies.Append("dbsc-offered", "1", new CookieOptions
+    ///         {
+    ///             Path = "/",
+    ///             Secure = true,
+    ///             HttpOnly = true,
+    ///             SameSite = SameSiteMode.Lax,
+    ///         });
+    ///     }
+    ///
+    ///     await next();
+    /// });
+    /// 
+    /// 
+    public static void WriteDeviceBoundSessionRegistration(this HttpContext context, string sourceScheme)
+    {
+        ArgumentNullException.ThrowIfNull(context);
+        ArgumentException.ThrowIfNullOrEmpty(sourceScheme);
+
+        var principal = context.User;
+        if (principal.Identity?.IsAuthenticated != true)
+        {
+            throw new InvalidOperationException(
+                "The Device Bound Session registration challenge must be bound to an authenticated principal.");
+        }
+
+        var sourceSchemes = context.RequestServices
+            .GetRequiredService>().Value;
+        if (!sourceSchemes.Schemes.TryGetValue(sourceScheme, out var dbscScheme))
+        {
+            throw new InvalidOperationException(
+                $"'{sourceScheme}' is not registered as a Device Bound Session source scheme. " +
+                $"Call AddDeviceBoundSession(\"{sourceScheme}\") to register it first.");
+        }
+
+        DeviceBoundSessionRegistrationHeader.Emit(context, principal, dbscScheme);
+    }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs
index d1f0cc499157..9e897d3b41c1 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs
@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics.CodeAnalysis;
 using System.Text.Json.Serialization;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
@@ -8,6 +9,7 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// 
 /// Source-generated JSON serialization context for DBSC configuration types.
 /// 
+[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
 [JsonSerializable(typeof(SessionInstruction))]
 [JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
 internal sealed partial class DeviceBoundSessionJsonContext : JsonSerializerContext
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
index 8e9797481aa2..3aaf1210b966 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs
@@ -2,6 +2,7 @@
 // The .NET Foundation licenses this file to you under the MIT license.
 
 using System.Text.Json;
+using Microsoft.Extensions.Logging;
 using Microsoft.IdentityModel.JsonWebTokens;
 using Microsoft.IdentityModel.Tokens;
 
@@ -10,9 +11,15 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// 
 /// Validates DBSC proof JWTs (typ: "dbsc+jwt") signed with ES256 or RS256.
 /// 
-internal static class DeviceBoundSessionJwtValidator
+internal sealed class DeviceBoundSessionJwtValidator
 {
     private static readonly JsonWebTokenHandler _tokenHandler = new();
+    private readonly ILogger _logger;
+
+    public DeviceBoundSessionJwtValidator(ILogger logger)
+    {
+        _logger = logger;
+    }
 
     /// 
     /// Validates a DBSC proof JWT and extracts its claims.
@@ -21,7 +28,7 @@ internal static class DeviceBoundSessionJwtValidator
     /// The JWK JSON of the public key to validate against. If null, extracts from JWT header.
     /// The expected challenge value (jti claim).
     /// The parsed result, or null if validation fails.
-    public static async Task ValidateAsync(string jwt, string? publicKeyJwk, string? expectedChallenge)
+    public async Task ValidateAsync(string jwt, string? publicKeyJwk, string? expectedChallenge)
     {
         JsonWebToken token;
         try
@@ -30,17 +37,20 @@ internal static class DeviceBoundSessionJwtValidator
         }
         catch (ArgumentException)
         {
+            _logger.ProofMalformed();
             return null;
         }
 
         if (!token.TryGetHeaderValue("typ", out var tokenType) ||
             !string.Equals(tokenType, "dbsc+jwt", StringComparison.Ordinal))
         {
+            _logger.ProofWrongType();
             return null;
         }
 
         if (!token.TryGetHeaderValue("alg", out var algorithm) || string.IsNullOrEmpty(algorithm))
         {
+            _logger.ProofMissingAlgorithm();
             return null;
         }
 
@@ -49,6 +59,7 @@ internal static class DeviceBoundSessionJwtValidator
         {
             if (!token.TryGetHeaderValue("jwk", out var jwkElement))
             {
+                _logger.ProofMissingKey();
                 return null;
             }
 
@@ -58,6 +69,7 @@ internal static class DeviceBoundSessionJwtValidator
         var securityKey = CreateSecurityKey(jwkJson, algorithm);
         if (securityKey is null)
         {
+            _logger.ProofUnsupportedKey();
             return null;
         }
 
@@ -72,12 +84,14 @@ internal static class DeviceBoundSessionJwtValidator
 
         if (!validationResult.IsValid)
         {
+            _logger.ProofSignatureInvalid();
             return null;
         }
 
         token.TryGetPayloadValue("jti", out string? challenge);
         if (expectedChallenge is not null && !string.Equals(challenge, expectedChallenge, StringComparison.Ordinal))
         {
+            _logger.ProofChallengeMismatch();
             return null;
         }
 
@@ -106,8 +120,8 @@ internal static class DeviceBoundSessionJwtValidator
 
         return algorithm switch
         {
-            "ES256" when string.Equals(jsonWebKey.Kty, "EC", StringComparison.Ordinal) && string.Equals(jsonWebKey.Crv, "P-256", StringComparison.Ordinal) => jsonWebKey,
-            "RS256" when string.Equals(jsonWebKey.Kty, "RSA", StringComparison.Ordinal) => jsonWebKey,
+            DeviceBoundSessionConstants.Es256 when string.Equals(jsonWebKey.Kty, "EC", StringComparison.Ordinal) && string.Equals(jsonWebKey.Crv, "P-256", StringComparison.Ordinal) => jsonWebKey,
+            DeviceBoundSessionConstants.Rs256 when string.Equals(jsonWebKey.Kty, "RSA", StringComparison.Ordinal) => jsonWebKey,
             _ => null,
         };
     }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs
index 961914d1eaa5..b5422b8c00c5 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs
@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics.CodeAnalysis;
 using Microsoft.AspNetCore.Http;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
@@ -8,6 +9,7 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// 
 /// Options for the Device Bound Session Credentials authentication handler.
 /// 
+[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
 public class DeviceBoundSessionOptions : AuthenticationSchemeOptions
 {
     /// 
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionRegistrationHeader.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionRegistrationHeader.cs
new file mode 100644
index 000000000000..ee0d018f3be8
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionRegistrationHeader.cs
@@ -0,0 +1,50 @@
+#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Security.Claims;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Writes the DBSC Secure-Session-Registration response header, advertising the supported
+/// signing algorithms and a fresh registration challenge bound to a principal. Shared by the
+/// cookie-events wiring paths (the inline OnSigningIn delegate installed by
+///  and the
+///  wrapper) and by the public
+///  on-demand entry point.
+/// 
+internal static class DeviceBoundSessionRegistrationHeader
+{
+    /// 
+    /// Emits the Secure-Session-Registration header onto the current response.
+    /// 
+    /// The current HTTP context.
+    /// The principal the challenge is bound to. When  an empty principal is used.
+    /// The resolved DBSC handler scheme name whose options drive the header.
+    public static void Emit(HttpContext httpContext, ClaimsPrincipal? principal, string dbscScheme)
+    {
+        // Dependencies are resolved from the request scope rather than constructor-injected: this is a
+        // shared helper used by the inline OnSigningIn delegate (wired by the singleton
+        // IPostConfigureOptions), the DI-less DeviceBoundSessionCookieEvents wrapper, and the public
+        // HttpContext extension, none of which hold these dependencies. It runs per-request, so the
+        // live HttpContext.RequestServices is the only source all callers share. (Both IOptionsMonitor<>
+        // and the challenge protector are singletons, so request-scope resolution is for sharing, not
+        // lifetime; the genuinely per-request input is the principal.)
+        var dbscOptions = httpContext.RequestServices
+            .GetRequiredService>()
+            .Get(dbscScheme);
+        var challengeProtector = httpContext.RequestServices
+            .GetRequiredService();
+
+        var effectivePrincipal = principal ?? new ClaimsPrincipal();
+        var challenge = challengeProtector.GenerateRegistrationChallenge(effectivePrincipal, dbscOptions.ChallengeMaxAge);
+
+        var headerValue = $"{DeviceBoundSessionConstants.AdvertisedAlgorithms};path=\"{dbscOptions.RegistrationPath.Value}\";challenge=\"{challenge}\"";
+        httpContext.Response.Headers.Append(DeviceBoundSessionConstants.Headers.Registration, headerValue);
+    }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRule.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRule.cs
index 9e4af687eb4f..f39a595f0f0a 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRule.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRule.cs
@@ -1,11 +1,14 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics.CodeAnalysis;
+
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
 /// 
 /// Represents a scope rule for DBSC session configuration.
 /// 
+[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
 public class DeviceBoundSessionScopeRule
 {
     /// 
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/LoggingExtensions.cs b/src/Security/Authentication/DeviceBoundSessions/src/LoggingExtensions.cs
new file mode 100644
index 000000000000..f3017c69e4f7
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/LoggingExtensions.cs
@@ -0,0 +1,72 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.Extensions.Logging;
+
+/// 
+/// Source-generated log messages for Device Bound Session Credentials. Messages intentionally avoid
+/// logging sensitive material (challenge values, claim values); the opaque session identifier is used
+/// only as a correlation key.
+/// 
+internal static partial class DeviceBoundSessionsLoggingExtensions
+{
+    // Refresh challenge validation (DeviceBoundSessionChallengeProtector).
+
+    [LoggerMessage(1, LogLevel.Debug, "DBSC refresh challenge for session {SessionId} could not be decrypted; it is expired, tampered, or was minted for a different flow or version.", EventName = "RefreshChallengeUndecryptable")]
+    public static partial void RefreshChallengeUndecryptable(this ILogger logger, string sessionId);
+
+    [LoggerMessage(2, LogLevel.Debug, "DBSC refresh challenge for session {SessionId} decrypted but was not in the expected shape.", EventName = "RefreshChallengeMalformed")]
+    public static partial void RefreshChallengeMalformed(this ILogger logger, string sessionId);
+
+    [LoggerMessage(3, LogLevel.Warning, "DBSC refresh challenge for session {SessionId} is bound to a different principal than the request; this should not happen for a browser-originated request and may indicate a client or server bug.", EventName = "RefreshChallengePrincipalMismatch")]
+    public static partial void RefreshChallengePrincipalMismatch(this ILogger logger, string sessionId);
+
+    [LoggerMessage(4, LogLevel.Warning, "DBSC refresh challenge is bound to a different session than the request ({SessionId}); this should not happen for a browser-originated request and may indicate a client or server bug.", EventName = "RefreshChallengeSessionMismatch")]
+    public static partial void RefreshChallengeSessionMismatch(this ILogger logger, string sessionId);
+
+    // Registration endpoint (DeviceBoundSessionHandler).
+
+    [LoggerMessage(6, LogLevel.Warning, "DBSC registration: no valid authentication from the source scheme.", EventName = "RegistrationNoSourceAuthentication")]
+    public static partial void RegistrationNoSourceAuthentication(this ILogger logger);
+
+    [LoggerMessage(7, LogLevel.Debug, "DBSC registration: the proof did not contain a challenge (jti).", EventName = "RegistrationChallengeMissing")]
+    public static partial void RegistrationChallengeMissing(this ILogger logger);
+
+    [LoggerMessage(10, LogLevel.Debug, "DBSC registration challenge could not be decrypted; it is expired, tampered, or was minted for a different flow or version.", EventName = "RegistrationChallengeUndecryptable")]
+    public static partial void RegistrationChallengeUndecryptable(this ILogger logger);
+
+    [LoggerMessage(11, LogLevel.Debug, "DBSC registration challenge decrypted but was not in the expected shape.", EventName = "RegistrationChallengeMalformed")]
+    public static partial void RegistrationChallengeMalformed(this ILogger logger);
+
+    [LoggerMessage(12, LogLevel.Warning, "DBSC registration challenge is bound to a different principal than the request; this should not happen for a browser-originated request and may indicate a client or server bug.", EventName = "RegistrationChallengePrincipalMismatch")]
+    public static partial void RegistrationChallengePrincipalMismatch(this ILogger logger);
+
+    // Refresh endpoint (DeviceBoundSessionHandler).
+
+    [LoggerMessage(8, LogLevel.Warning, "DBSC refresh: no valid refresh cookie for session {SessionId}.", EventName = "RefreshNoCookie")]
+    public static partial void RefreshNoCookie(this ILogger logger, string sessionId);
+
+    // Proof JWT validation (DeviceBoundSessionJwtValidator). All are routine rejections of a bad or
+    // unsupported proof and are logged at Debug.
+
+    [LoggerMessage(13, LogLevel.Debug, "DBSC proof rejected: the token is not a well-formed JWT.", EventName = "ProofMalformed")]
+    public static partial void ProofMalformed(this ILogger logger);
+
+    [LoggerMessage(14, LogLevel.Debug, "DBSC proof rejected: the 'typ' header is not 'dbsc+jwt'.", EventName = "ProofWrongType")]
+    public static partial void ProofWrongType(this ILogger logger);
+
+    [LoggerMessage(15, LogLevel.Debug, "DBSC proof rejected: the 'alg' header is missing.", EventName = "ProofMissingAlgorithm")]
+    public static partial void ProofMissingAlgorithm(this ILogger logger);
+
+    [LoggerMessage(16, LogLevel.Debug, "DBSC proof rejected: no 'jwk' header and no stored key to validate against.", EventName = "ProofMissingKey")]
+    public static partial void ProofMissingKey(this ILogger logger);
+
+    [LoggerMessage(17, LogLevel.Debug, "DBSC proof rejected: could not build a signing key (malformed JWK, unsupported algorithm, or key-type mismatch).", EventName = "ProofUnsupportedKey")]
+    public static partial void ProofUnsupportedKey(this ILogger logger);
+
+    [LoggerMessage(18, LogLevel.Debug, "DBSC proof rejected: signature validation failed.", EventName = "ProofSignatureInvalid")]
+    public static partial void ProofSignatureInvalid(this ILogger logger);
+
+    [LoggerMessage(19, LogLevel.Debug, "DBSC proof rejected: the challenge (jti) did not match the expected value.", EventName = "ProofChallengeMismatch")]
+    public static partial void ProofChallengeMismatch(this ILogger logger);
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
index 55469611f2f1..03051d9db487 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
+++ b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
@@ -1,31 +1,31 @@
 
 
   
-    ASP.NET Core authentication handler for Device Bound Session Credentials (DBSC).
+    ASP.NET Core middleware that enables Device Bound Session Credentials (DBSC) over an inner authentication scheme.
     $(DefaultNetCoreTargetFramework)
     true
-    aspnetcore;authentication;security;dbsc
+    aspnetcore;authentication;dbsc;security
     true
-    enable
-    $(NoWarn);RS0016;RS0026
   
 
-  
-    
-  
-
   
     
     
     
     
+    
     
-    
     
     
     
+    
     
     
   
 
+  
+    
+    
+  
+
 
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionCookieOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionCookieOptions.cs
new file mode 100644
index 000000000000..0c81bf2a8d35
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionCookieOptions.cs
@@ -0,0 +1,47 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// Post-configures each cookie scheme that is a DBSC source scheme so that the
+/// Secure-Session-Registration header is emitted during sign-in. For delegate-based events it
+/// chains ; for EventsType scenarios it
+/// wraps the events with .
+/// 
+internal sealed class PostConfigureDeviceBoundSessionCookieOptions : IPostConfigureOptions
+{
+    private readonly IOptions _sourceSchemes;
+
+    public PostConfigureDeviceBoundSessionCookieOptions(IOptions sourceSchemes)
+    {
+        _sourceSchemes = sourceSchemes;
+    }
+
+    public void PostConfigure(string? name, CookieAuthenticationOptions options)
+    {
+        ArgumentNullException.ThrowIfNull(name);
+
+        if (!_sourceSchemes.Value.Schemes.TryGetValue(name, out var dbscScheme))
+        {
+            return;
+        }
+
+        if (options.EventsType is null)
+        {
+            var priorSigningIn = options.Events.OnSigningIn;
+            options.Events.OnSigningIn = async context =>
+            {
+                DeviceBoundSessionRegistrationHeader.Emit(context.HttpContext, context.Principal, dbscScheme);
+                await priorSigningIn(context);
+            };
+            return;
+        }
+
+        options.Events = new DeviceBoundSessionCookieEvents(dbscScheme, options.Events, options.EventsType);
+        options.EventsType = null;
+    }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
index 840d62717217..2ce0d0321ddc 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
@@ -3,9 +3,12 @@ const Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSession
 const Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults.RefreshPath = "/.well-known/dbsc/refresh" -> string!
 const Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults.RegistrationPath = "/.well-known/dbsc/registration" -> string!
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHttpContextExtensions
+static Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHttpContextExtensions.WriteDeviceBoundSessionRegistration(this Microsoft.AspNetCore.Http.HttpContext! context, string! sourceScheme) -> void
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.DeviceBoundSessionHandler(Microsoft.Extensions.Options.IOptionsMonitor! options, Microsoft.Extensions.Logging.ILoggerFactory! logger, System.Text.Encodings.Web.UrlEncoder! encoder, Microsoft.AspNetCore.DataProtection.IDataProtectionProvider! dataProtectionProvider) -> void
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.HandleRequestAsync() -> System.Threading.Tasks.Task!
+override Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.HandleAuthenticateAsync() -> System.Threading.Tasks.Task!
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.AllowedRefreshInitiators.get -> System.Collections.Generic.IList!
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.ChallengeMaxAge.get -> System.TimeSpan
@@ -41,7 +44,6 @@ Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Name.g
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Name.set -> void
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.SessionCredential() -> void
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Type.get -> string!
-Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Type.set -> void
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Credentials.get -> System.Collections.Generic.List?
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Credentials.set -> void
@@ -69,5 +71,7 @@ Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Session
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Type.get -> string!
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Type.set -> void
 Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions
-static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! authenticationScheme, string! sourceScheme, System.Action? configureOptions = null) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder!
-static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! sourceScheme, System.Action? configureOptions = null) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder!
+static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! authenticationScheme, string! sourceScheme) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder!
+static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! authenticationScheme, string! sourceScheme, System.Action! configureOptions) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder!
+static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! sourceScheme) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder!
+static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! sourceScheme, System.Action! configureOptions) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder!
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/SessionCredential.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionCredential.cs
index 1673f733e969..52c410058750 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/SessionCredential.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionCredential.cs
@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics.CodeAnalysis;
 using System.Text.Json.Serialization;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
@@ -9,13 +10,14 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// Represents a credential in the DBSC session instructions. Corresponds to the "JSON Session
 /// Credential Format" defined in W3C Device Bound Session Credentials §9.9.
 /// 
+[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
 public sealed class SessionCredential
 {
     /// 
-    /// Gets or sets the credential type (always "cookie").
+    /// Gets the credential type (always "cookie").
     /// 
     [JsonPropertyName("type")]
-    public string Type { get; set; } = "cookie";
+    public string Type { get; } = "cookie";
 
     /// 
     /// Gets or sets the cookie name.
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs
index bad797c1ace1..f218fc1bc4e1 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs
@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics.CodeAnalysis;
 using System.Text.Json.Serialization;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
@@ -10,6 +11,7 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// and (optionally) refresh. Corresponds to the "JSON Session Instruction Format" defined in
 /// W3C Device Bound Session Credentials §9.6.
 /// 
+[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
 public sealed class SessionInstruction
 {
     /// 
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/SessionScope.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionScope.cs
index 1cc2b6f0a99d..c19adedaa05b 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/SessionScope.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionScope.cs
@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics.CodeAnalysis;
 using System.Text.Json.Serialization;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
@@ -9,6 +10,7 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// Represents the scope of a DBSC session. Corresponds to the "JSON Session Scope Instruction
 /// Format" defined in W3C Device Bound Session Credentials §9.7.
 /// 
+[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
 public sealed class SessionScope
 {
     /// 
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/SessionScopeRule.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionScopeRule.cs
index 0005e4ecf8e0..918cbabaf131 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/SessionScopeRule.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionScopeRule.cs
@@ -1,6 +1,7 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System.Diagnostics.CodeAnalysis;
 using System.Text.Json.Serialization;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
@@ -9,6 +10,7 @@ namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 /// Represents a scope specification rule in the DBSC session instructions. Corresponds to the
 /// "JSON Session Scope Rule Format" defined in W3C Device Bound Session Credentials §9.8.
 /// 
+[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]
 public sealed class SessionScopeRule
 {
     /// 
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs b/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs
index 6d54fca7b031..22e8c2d6e512 100644
--- a/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs
@@ -1,8 +1,12 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+#nullable enable
+
+using System;
 using System.Collections.Generic;
 using System.Security.Cryptography;
+using System.Text;
 using Microsoft.AspNetCore.WebUtilities;
 using Microsoft.IdentityModel.JsonWebTokens;
 using Microsoft.IdentityModel.Tokens;
@@ -59,7 +63,7 @@ public static DbscProofKey CreateRs256()
     }
 
     /// Creates a signed DBSC proof JWT with the public key in the header (registration shape).
-    public string CreateProof(string jti, bool includeJwkHeader = true)
+    public string CreateProof(string jti, bool includeJwkHeader = true, string? authorization = null, DateTimeOffset? expires = null)
     {
         var headerClaims = new Dictionary { ["typ"] = "dbsc+jwt" };
         if (includeJwkHeader)
@@ -67,16 +71,54 @@ public string CreateProof(string jti, bool includeJwkHeader = true)
             headerClaims["jwk"] = System.Text.Json.JsonDocument.Parse(PublicJwkJson).RootElement;
         }
 
+        var claims = new Dictionary { ["jti"] = jti };
+        if (authorization is not null)
+        {
+            claims["authorization"] = authorization;
+        }
+
         var descriptor = new SecurityTokenDescriptor
         {
-            Claims = new Dictionary { ["jti"] = jti },
+            Claims = claims,
             SigningCredentials = new SigningCredentials(_signingKey, Algorithm),
             AdditionalHeaderClaims = headerClaims,
         };
 
+        if (expires is not null)
+        {
+            descriptor.Expires = expires.Value.UtcDateTime;
+        }
+
         return s_handler.CreateToken(descriptor);
     }
 
+    /// 
+    /// Builds an unsigned (empty-signature) compact JWS with caller-controlled header fields, used to
+    /// exercise the validator's pre-signature gates (typ, alg, key selection) in isolation.
+    /// 
+    public static string CreateUnsignedToken(string? typ, string? alg, string? jwkJson, string jti)
+    {
+        var header = new Dictionary();
+        if (typ is not null)
+        {
+            header["typ"] = typ;
+        }
+        if (alg is not null)
+        {
+            header["alg"] = alg;
+        }
+        if (jwkJson is not null)
+        {
+            header["jwk"] = System.Text.Json.JsonDocument.Parse(jwkJson).RootElement;
+        }
+
+        var payload = new Dictionary { ["jti"] = jti };
+
+        var headerSegment = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(header)));
+        var payloadSegment = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(payload)));
+        return $"{headerSegment}.{payloadSegment}.";
+    }
+
     /// Flips one byte of the signature segment to produce a tampered token.
     public static string TamperSignature(string jwt)
     {
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionChallengeProtectorTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionChallengeProtectorTests.cs
index 06033278fa1c..c722384eb506 100644
--- a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionChallengeProtectorTests.cs
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionChallengeProtectorTests.cs
@@ -4,6 +4,7 @@
 using System;
 using System.Security.Claims;
 using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.Logging.Abstractions;
 using Xunit;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
@@ -13,7 +14,7 @@ public class DeviceBoundSessionChallengeProtectorTests
     private static readonly TimeSpan s_lifetime = TimeSpan.FromMinutes(5);
 
     private static DeviceBoundSessionChallengeProtector CreateProtector()
-        => new(new EphemeralDataProtectionProvider());
+        => new(new EphemeralDataProtectionProvider(), NullLogger.Instance);
 
     private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
     {
@@ -87,6 +88,53 @@ public void RefreshChallenge_Fails_ForDifferentPrincipal()
         Assert.False(protector.TryValidateRefreshChallenge(challenge, Principal(("sub", "bob")), "session-1"));
     }
 
+    [Fact]
+    public void RefreshChallenge_Fails_WhenExpired()
+    {
+        var protector = CreateProtector();
+        var principal = Principal(("sub", "alice"));
+        // Negative lifetime → already expired when validated; the data protector can't unprotect it.
+        var challenge = protector.GenerateRefreshChallenge(principal, "session-1", TimeSpan.FromSeconds(-1));
+
+        Assert.False(protector.TryValidateRefreshChallenge(challenge, principal, "session-1"));
+    }
+
+    [Fact]
+    public void RefreshChallenge_Fails_WhenTampered()
+    {
+        var protector = CreateProtector();
+        var principal = Principal(("sub", "alice"));
+        var challenge = protector.GenerateRefreshChallenge(principal, "session-1", s_lifetime);
+        var tampered = challenge[0] == 'A' ? "B" + challenge[1..] : "A" + challenge[1..];
+
+        Assert.False(protector.TryValidateRefreshChallenge(tampered, principal, "session-1"));
+    }
+
+    [Fact]
+    public void RefreshChallenge_Fails_ForRegistrationChallenge()
+    {
+        var protector = CreateProtector();
+        var principal = Principal(("sub", "alice"));
+        // A registration challenge is encrypted under a different data-protection purpose, so the
+        // refresh validator cannot even decrypt it. Cross-type confusion is impossible.
+        var registrationChallenge = protector.GenerateRegistrationChallenge(principal, s_lifetime);
+
+        Assert.False(protector.TryValidateRefreshChallenge(registrationChallenge, principal, "session-1"));
+    }
+
+    [Fact]
+    public void TryValidateRegistrationChallenge_Fails_ForRefreshChallenge()
+    {
+        var protector = CreateProtector();
+        var principal = Principal(("sub", "alice"));
+        // The reverse direction: a refresh challenge is encrypted under the refresh purpose, so
+        // registration validation cannot decrypt it and rejects it (it would otherwise read the
+        // shared claim-uid prefix and silently accept it).
+        var refreshChallenge = protector.GenerateRefreshChallenge(principal, "session-1", s_lifetime);
+
+        Assert.False(protector.TryValidateRegistrationChallenge(refreshChallenge, principal));
+    }
+
     [Fact]
     public void ComputeClaimUid_PrefersSub_OverNameIdentifier()
     {
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs
new file mode 100644
index 000000000000..48707f76f8d6
--- /dev/null
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs
@@ -0,0 +1,130 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using System;
+using System.Security.Claims;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+public class DeviceBoundSessionCookieProtectionTests
+{
+    private const string SourceScheme = "Source";
+    private const string RefreshScheme = "Source.Dbsc.Refresh";
+    private const string SessionScheme = "Source.Dbsc.Session";
+
+    [Fact]
+    public void PostConfigure_PreservesExistingTicketDataFormat_ForRefreshScheme()
+    {
+        var sut = CreateDerivedPostConfigure(refreshScheme: RefreshScheme);
+        var sentinel = CreateSentinelFormat();
+        var options = new CookieAuthenticationOptions { TicketDataFormat = sentinel };
+        // The DBSC extension assigns the cookie name (and refresh path) before post-configure runs.
+        options.Cookie.Name = ".AspNetCore." + RefreshScheme;
+
+        sut.PostConfigure(RefreshScheme, options);
+
+        // Protection is left untouched...
+        Assert.Same(sentinel, options.TicketDataFormat);
+        // ...but the post-configure still ran (copied source lifetime, applied refresh path scope, kept the name).
+        Assert.Equal(TimeSpan.FromHours(3), options.ExpireTimeSpan);
+        Assert.Equal("/.well-known/dbsc", options.Cookie.Path);
+        Assert.Equal(".AspNetCore." + RefreshScheme, options.Cookie.Name);
+    }
+
+    [Fact]
+    public void PostConfigure_PreservesExistingTicketDataFormat_ForSessionScheme()
+    {
+        var sut = CreateDerivedPostConfigure(sessionScheme: SessionScheme);
+        var sentinel = CreateSentinelFormat();
+        var options = new CookieAuthenticationOptions { TicketDataFormat = sentinel };
+        options.Cookie.Name = ".AspNetCore." + SessionScheme;
+
+        sut.PostConfigure(SessionScheme, options);
+
+        Assert.Same(sentinel, options.TicketDataFormat);
+        Assert.Equal(TimeSpan.FromHours(3), options.ExpireTimeSpan);
+        Assert.Equal(".AspNetCore." + SessionScheme, options.Cookie.Name);
+    }
+
+    [Fact]
+    public void DerivedCookieSchemes_RemainDataProtected_AndSchemeKeyed()
+    {
+        var services = new ServiceCollection();
+        services.AddLogging();
+        services.AddAuthentication()
+            .AddCookie(SourceScheme)
+            .AddDeviceBoundSession(SourceScheme);
+        using var provider = services.BuildServiceProvider();
+
+        var monitor = provider.GetRequiredService>();
+        var source = monitor.Get(SourceScheme);
+        var refresh = monitor.Get(RefreshScheme);
+        var session = monitor.Get(SessionScheme);
+
+        // All three schemes end up with a data-protecting ticket format.
+        Assert.NotNull(source.TicketDataFormat);
+        Assert.NotNull(refresh.TicketDataFormat);
+        Assert.NotNull(session.TicketDataFormat);
+
+        var ticket = new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity("test")), RefreshScheme);
+        var protectedByRefresh = refresh.TicketDataFormat.Protect(ticket);
+
+        // The refresh format round-trips its own payload (real protection, not a no-op).
+        Assert.True(CanUnprotect(refresh.TicketDataFormat, protectedByRefresh));
+
+        // Protection is scheme-keyed: neither the session nor the source format can read the
+        // refresh scheme's payload, proving each derived scheme keeps its own protector and the
+        // source scheme's protection is independent.
+        Assert.False(CanUnprotect(session.TicketDataFormat, protectedByRefresh));
+        Assert.False(CanUnprotect(source.TicketDataFormat, protectedByRefresh));
+    }
+
+    private static PostConfigureDeviceBoundSessionDerivedCookieOptions CreateDerivedPostConfigure(
+        string? refreshScheme = null,
+        string? sessionScheme = null)
+    {
+        var sourceSchemes = new DeviceBoundSessionSourceSchemes();
+        if (refreshScheme is not null)
+        {
+            sourceSchemes.RefreshSchemes[refreshScheme] = SourceScheme;
+        }
+        if (sessionScheme is not null)
+        {
+            sourceSchemes.SessionSchemes[sessionScheme] = SourceScheme;
+        }
+
+        var services = new ServiceCollection();
+        services.AddOptions();
+        services.Configure(SourceScheme, o =>
+        {
+            o.Cookie.HttpOnly = true;
+            o.ExpireTimeSpan = TimeSpan.FromHours(3);
+        });
+
+        return new PostConfigureDeviceBoundSessionDerivedCookieOptions(
+            Options.Create(sourceSchemes),
+            services.BuildServiceProvider());
+    }
+
+    private static TicketDataFormat CreateSentinelFormat()
+        => new(new EphemeralDataProtectionProvider().CreateProtector("sentinel"));
+
+    private static bool CanUnprotect(ISecureDataFormat format, string value)
+    {
+        try
+        {
+            return format.Unprotect(value) is not null;
+        }
+        catch
+        {
+            return false;
+        }
+    }
+}
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionJwtValidatorTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionJwtValidatorTests.cs
index c79f08dceb19..640053abd0be 100644
--- a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionJwtValidatorTests.cs
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionJwtValidatorTests.cs
@@ -1,20 +1,24 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+using System;
 using System.Threading.Tasks;
+using Microsoft.Extensions.Logging.Abstractions;
 using Xunit;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
 public class DeviceBoundSessionJwtValidatorTests
 {
+    private static readonly DeviceBoundSessionJwtValidator Validator = new(NullLogger.Instance);
+
     [Fact]
     public async Task ValidateAsync_ValidEs256Proof_WithJwkHeader_Succeeds()
     {
         var key = DbscProofKey.CreateEs256();
         var proof = key.CreateProof("challenge-1");
 
-        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
+        var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
 
         Assert.NotNull(result);
         Assert.Equal("ES256", result.Algorithm);
@@ -27,7 +31,7 @@ public async Task ValidateAsync_ValidRs256Proof_WithJwkHeader_Succeeds()
         var key = DbscProofKey.CreateRs256();
         var proof = key.CreateProof("challenge-2");
 
-        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
+        var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
 
         Assert.NotNull(result);
         Assert.Equal("RS256", result.Algorithm);
@@ -40,7 +44,7 @@ public async Task ValidateAsync_RefreshShape_UsesProvidedStoredKey_WhenJwkHeader
         var key = DbscProofKey.CreateEs256();
         var proof = key.CreateProof("challenge-3", includeJwkHeader: false);
 
-        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, key.PublicJwkJson, expectedChallenge: null);
+        var result = await Validator.ValidateAsync(proof, key.PublicJwkJson, expectedChallenge: null);
 
         Assert.NotNull(result);
         Assert.Equal("challenge-3", result.Challenge);
@@ -52,7 +56,7 @@ public async Task ValidateAsync_TamperedSignature_ReturnsNull()
         var key = DbscProofKey.CreateEs256();
         var proof = DbscProofKey.TamperSignature(key.CreateProof("challenge-4"));
 
-        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
+        var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
 
         Assert.Null(result);
     }
@@ -64,7 +68,7 @@ public async Task ValidateAsync_WrongStoredKey_ReturnsNull()
         var otherKey = DbscProofKey.CreateEs256();
         var proof = signingKey.CreateProof("challenge-5", includeJwkHeader: false);
 
-        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, otherKey.PublicJwkJson, expectedChallenge: null);
+        var result = await Validator.ValidateAsync(proof, otherKey.PublicJwkJson, expectedChallenge: null);
 
         Assert.Null(result);
     }
@@ -75,7 +79,7 @@ public async Task ValidateAsync_MissingJwkHeaderAndNoStoredKey_ReturnsNull()
         var key = DbscProofKey.CreateEs256();
         var proof = key.CreateProof("challenge-6", includeJwkHeader: false);
 
-        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
+        var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
 
         Assert.Null(result);
     }
@@ -83,7 +87,7 @@ public async Task ValidateAsync_MissingJwkHeaderAndNoStoredKey_ReturnsNull()
     [Fact]
     public async Task ValidateAsync_MalformedToken_ReturnsNull()
     {
-        var result = await DeviceBoundSessionJwtValidator.ValidateAsync("not-a-jwt", publicKeyJwk: null, expectedChallenge: null);
+        var result = await Validator.ValidateAsync("not-a-jwt", publicKeyJwk: null, expectedChallenge: null);
 
         Assert.Null(result);
     }
@@ -94,8 +98,105 @@ public async Task ValidateAsync_ChallengeMismatch_WhenExpectedChallengeProvided_
         var key = DbscProofKey.CreateEs256();
         var proof = key.CreateProof("actual-challenge");
 
-        var result = await DeviceBoundSessionJwtValidator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: "different-challenge");
+        var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: "different-challenge");
+
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_WrongTyp_ReturnsNull()
+    {
+        var key = DbscProofKey.CreateEs256();
+        var token = DbscProofKey.CreateUnsignedToken(typ: "JWT", alg: "ES256", jwkJson: key.PublicJwkJson, jti: "c");
+
+        var result = await Validator.ValidateAsync(token, publicKeyJwk: null, expectedChallenge: null);
+
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_MissingAlg_ReturnsNull()
+    {
+        var key = DbscProofKey.CreateEs256();
+        var token = DbscProofKey.CreateUnsignedToken(typ: "dbsc+jwt", alg: null, jwkJson: key.PublicJwkJson, jti: "c");
+
+        var result = await Validator.ValidateAsync(token, publicKeyJwk: null, expectedChallenge: null);
+
+        Assert.Null(result);
+    }
+
+    [Theory]
+    [InlineData("none")]
+    [InlineData("HS256")]
+    [InlineData("ES384")]
+    [InlineData("RS384")]
+    public async Task ValidateAsync_UnsupportedAlg_ReturnsNull(string alg)
+    {
+        var key = DbscProofKey.CreateEs256();
+        var token = DbscProofKey.CreateUnsignedToken(typ: "dbsc+jwt", alg: alg, jwkJson: key.PublicJwkJson, jti: "c");
+
+        var result = await Validator.ValidateAsync(token, publicKeyJwk: null, expectedChallenge: null);
 
         Assert.Null(result);
     }
+
+    [Fact]
+    public async Task ValidateAsync_AlgKeyTypeMismatch_ReturnsNull()
+    {
+        // Proof is signed ES256 but validated against an RSA stored key: the EC switch arm's
+        // kty guard rejects the mismatch before signature validation.
+        var ecKey = DbscProofKey.CreateEs256();
+        var rsaKey = DbscProofKey.CreateRs256();
+        var proof = ecKey.CreateProof("c", includeJwkHeader: false);
+
+        var result = await Validator.ValidateAsync(proof, rsaKey.PublicJwkJson, expectedChallenge: null);
+
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_MalformedStoredJwk_ReturnsNull()
+    {
+        var key = DbscProofKey.CreateEs256();
+        var proof = key.CreateProof("c", includeJwkHeader: false);
+
+        var result = await Validator.ValidateAsync(proof, publicKeyJwk: "{ not valid json", expectedChallenge: null);
+
+        Assert.Null(result);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_ExpectedChallengeMatches_Succeeds()
+    {
+        var key = DbscProofKey.CreateEs256();
+        var proof = key.CreateProof("the-challenge");
+
+        var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: "the-challenge");
+
+        Assert.NotNull(result);
+        Assert.Equal("the-challenge", result.Challenge);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_ExtractsAuthorizationClaim()
+    {
+        var key = DbscProofKey.CreateEs256();
+        var proof = key.CreateProof("c", authorization: "auth-token-value");
+
+        var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
+
+        Assert.NotNull(result);
+        Assert.Equal("auth-token-value", result.Authorization);
+    }
+
+    [Fact]
+    public async Task ValidateAsync_ExpiredProof_StillValidates_BecauseLifetimeNotChecked()
+    {
+        var key = DbscProofKey.CreateEs256();
+        var proof = key.CreateProof("c", expires: DateTimeOffset.UtcNow.AddMinutes(-10));
+
+        var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null);
+
+        Assert.NotNull(result);
+    }
 }

From 28610de682538facb7b1582a1c399b262856ce5f Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Tue, 23 Jun 2026 17:45:57 +0200
Subject: [PATCH 20/33] feat: mirror auth cookie sliding for DBSC refresh
 cookie
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- inherit SlidingExpiration from the source scheme; drop refresh-scheme override
- stop anchoring refresh cookie lifetime at registration so it slides like the auth cookie
- add tests for refresh sliding inheritance and short-lived cookie staying fixed
- sample: surface the refresh cookie on the dashboard and decode challenges with split purposes

🔒 - Generated by Copilot
---
 .../samples/DbscDebugServer/DbscDebug.cs      | 121 +++++++++++++++---
 .../samples/DbscDebugServer/Program.cs        |  14 +-
 .../src/DeviceBoundSessionHandler.cs          |  10 +-
 ...eDeviceBoundSessionDerivedCookieOptions.cs |   6 +-
 ...DeviceBoundSessionCookieProtectionTests.cs |  44 ++++++-
 5 files changed, 163 insertions(+), 32 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebug.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebug.cs
index f6b8e9896c02..6a25e959a42a 100644
--- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebug.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebug.cs
@@ -28,7 +28,11 @@ public static class DbscNames
     public const string RefreshCookie = ".AspNetCore.Application.Dbsc.Refresh";
     public const string SessionCookie = ".AspNetCore.Application.Dbsc.Session";
 
-    public const string ChallengePurpose = "Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.v1";
+    // The challenge protector is domain-separated by flow: registration challenges and refresh
+    // challenges are protected under distinct purposes, so a challenge from one flow can never be
+    // decrypted (or confused) as the other.
+    public const string RegistrationChallengePurpose = "Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.Registration.v1";
+    public const string RefreshChallengePurpose = "Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.Refresh.v1";
 
     public static string? SchemeForCookie(string name) => name switch
     {
@@ -61,6 +65,19 @@ public TimeSpan SessionTtl
         set => Interlocked.Exchange(ref _ttlTicks, value.Ticks);
     }
 
+    private DebugCookie? _latestRefreshCookie;
+
+    /// 
+    /// The most recently observed refresh cookie (decoded). The refresh cookie is path-scoped to
+    /// /.well-known/dbsc, so the dashboard's own /debug/state polls never receive it; the capture
+    /// middleware stashes the latest copy here from the DBSC traffic that does carry it.
+    /// 
+    public DebugCookie? LatestRefreshCookie
+    {
+        get { lock (_lock) { return _latestRefreshCookie; } }
+        set { lock (_lock) { _latestRefreshCookie = value; } }
+    }
+
     public DebugExchange Add(DebugExchange exchange)
     {
         lock (_lock)
@@ -363,32 +380,58 @@ public static class DbscDecoder
 
     public static DebugChallenge DecodeChallenge(HttpContext context, string challenge)
     {
+        var dp = context.RequestServices.GetRequiredService();
+
+        byte[] raw;
         try
         {
-            var dp = context.RequestServices.GetRequiredService();
-            var protector = dp.CreateProtector(DbscNames.ChallengePurpose).ToTimeLimitedDataProtector();
-            var bytes = protector.Unprotect(WebEncoders.Base64UrlDecode(challenge));
-
-            var reader = new CborReader(bytes, allowMultipleRootLevelValues: true);
-            var claimUid = reader.ReadTextString();
-            string? sessionId = null;
-            if (reader.PeekState() != CborReaderState.Finished)
-            {
-                sessionId = reader.ReadTextString();
-            }
-
-            return new DebugChallenge
-            {
-                Kind = sessionId is null ? "registration" : "refresh",
-                ClaimUid = claimUid,
-                SessionId = sessionId,
-                Valid = true,
-            };
+            raw = WebEncoders.Base64UrlDecode(challenge);
         }
         catch (Exception ex)
         {
             return new DebugChallenge { Valid = false, Error = DescribeException(ex) };
         }
+
+        // The challenge is protected under one of two domain-separated purposes. Try each: the one
+        // that decrypts both validates the payload and identifies which flow issued it.
+        Exception? firstError = null;
+        foreach (var (kind, purpose) in new[]
+        {
+            ("refresh", DbscNames.RefreshChallengePurpose),
+            ("registration", DbscNames.RegistrationChallengePurpose),
+        })
+        {
+            try
+            {
+                var protector = dp.CreateProtector(purpose).ToTimeLimitedDataProtector();
+                var bytes = protector.Unprotect(raw);
+
+                var reader = new CborReader(bytes, allowMultipleRootLevelValues: true);
+                var claimUid = reader.ReadTextString();
+                string? sessionId = null;
+                if (reader.PeekState() != CborReaderState.Finished)
+                {
+                    sessionId = reader.ReadTextString();
+                }
+
+                return new DebugChallenge
+                {
+                    Kind = kind,
+                    ClaimUid = claimUid,
+                    SessionId = sessionId,
+                    Valid = true,
+                };
+            }
+            catch (Exception ex)
+            {
+                // Keep the first failure: when a challenge is the correct kind but expired, the
+                // matching purpose surfaces an informative "payload expired" message, whereas the
+                // other purpose only reports a generic key mismatch.
+                firstError ??= ex;
+            }
+        }
+
+        return new DebugChallenge { Valid = false, Error = DescribeException(firstError!) };
     }
 
     /// 
@@ -558,6 +601,13 @@ public async Task InvokeAsync(HttpContext context)
                 var registrationHeader = context.Response.Headers["Secure-Session-Registration"].ToString();
                 var challengeHeader = context.Response.Headers["Secure-Session-Challenge"].ToString();
 
+                var setCookies = DbscDecoder.DecodeSetCookies(context);
+
+                // The refresh cookie is path-scoped to /.well-known/dbsc, so the dashboard's own
+                // /debug/state polls never carry it. Stash the latest decoded copy seen on real DBSC
+                // traffic (a fresh Set-Cookie from registration or a slide wins; a deletion clears it).
+                UpdateRefreshCookieStash(requestCookies, setCookies);
+
                 var exchange = new DebugExchange
                 {
                     Time = start.ToLocalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture),
@@ -568,7 +618,7 @@ public async Task InvokeAsync(HttpContext context)
                     Authenticated = context.User.Identity?.IsAuthenticated == true,
                     DurationMs = Math.Round((DateTimeOffset.UtcNow - start).TotalMilliseconds, 2),
                     RequestCookies = requestCookies,
-                    SetCookies = DbscDecoder.DecodeSetCookies(context),
+                    SetCookies = setCookies,
                     Proof = proof,
                     RegistrationHeader = string.IsNullOrEmpty(registrationHeader) ? null : registrationHeader,
                     ChallengeHeader = string.IsNullOrEmpty(challengeHeader) ? null : challengeHeader,
@@ -581,6 +631,35 @@ public async Task InvokeAsync(HttpContext context)
         }
     }
 
+    private void UpdateRefreshCookieStash(List? requestCookies, List? setCookies)
+    {
+        // A fresh Set-Cookie (registration or sliding renewal) reflects the newest expiry; prefer it.
+        if (setCookies is not null)
+        {
+            foreach (var c in setCookies)
+            {
+                if (c.Name == DbscNames.RefreshCookie)
+                {
+                    _state.LatestRefreshCookie = c.Deleted ? null : c;
+                    return;
+                }
+            }
+        }
+
+        // Otherwise fall back to the refresh cookie the browser sent (e.g. the first leg of a refresh).
+        if (requestCookies is not null)
+        {
+            foreach (var c in requestCookies)
+            {
+                if (c.Name == DbscNames.RefreshCookie)
+                {
+                    _state.LatestRefreshCookie = c;
+                    return;
+                }
+            }
+        }
+    }
+
     private static bool IsDbscEndpoint(string path) =>
         path is "/.well-known/dbsc/registration" or "/.well-known/dbsc/refresh";
 
diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
index 20539e13a1de..8afedb39543c 100644
--- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
@@ -59,7 +59,8 @@ public void ConfigureServices(IServiceCollection services)
             {
                 o.Cookie.Name = DbscNames.SourceCookie;
                 o.LoginPath = "/login";
-                o.ExpireTimeSpan = TimeSpan.FromDays(7);
+                // TEMP (manual sliding test): 20 min lifetime, slides at the 50% mark (~10 min).
+                o.ExpireTimeSpan = TimeSpan.FromMinutes(20);
             })
             // The DBSC handler + refresh/session cookie schemes + policy scheme.
             // The session TTL is read live from the debug state so it can be changed at runtime.
@@ -184,7 +185,16 @@ await context.SignInAsync(
 
             endpoints.MapGet("/debug/state", async context =>
             {
-                var cookies = DbscDecoder.DecodeRequestCookies(context);
+                var cookies = DbscDecoder.DecodeRequestCookies(context) ?? new List();
+
+                // The refresh cookie is path-scoped to /.well-known/dbsc and is never sent to this
+                // endpoint, so surface the latest copy the capture middleware stashed from DBSC traffic.
+                var refresh = _debug.LatestRefreshCookie;
+                if (refresh is not null && !cookies.Exists(c => c.Name == refresh.Name))
+                {
+                    cookies.Add(refresh);
+                }
+
                 await context.Response.WriteAsJsonAsync(new
                 {
                     authenticated = context.User.Identity?.IsAuthenticated == true,
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
index 18b1fdead73d..1e08c69ff26a 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -139,12 +139,10 @@ private async Task HandleRegistrationAsync()
         refreshProperties.Items["DbscAlgorithm"] = jwtResult.Algorithm;
         refreshProperties.IsPersistent = true;
 
-        // Anchor the refresh cookie's lifetime to the original sign-in cookie so the bound session
-        // never outlives the credential it was exchanged for. Copying the source ticket's IssuedUtc/
-        // ExpiresUtc makes the cookie handler honor them (instead of starting a fresh ExpireTimeSpan
-        // window at registration time). Falls back to the handler default if the source has no expiry.
-        refreshProperties.IssuedUtc = properties.IssuedUtc;
-        refreshProperties.ExpiresUtc = properties.ExpiresUtc;
+        // Leave IssuedUtc/ExpiresUtc unset so the refresh cookie scheme starts a fresh ExpireTimeSpan
+        // window at registration (exactly like a normal sign-in) and then slides on each refresh when
+        // the source scheme uses sliding expiration. This mirrors the auth cookie the session replaces,
+        // so enabling DBSC does not regress an active user's session lifetime.
 
         // 1. Stamp the refresh cookie (path-scoped stash with ticket + public key)
         await Context.SignInAsync(Options.RefreshScheme, principal, refreshProperties);
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
index c19297d9f8e0..0cad1d87558f 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
@@ -33,9 +33,10 @@ public void PostConfigure(string? name, CookieAuthenticationOptions options)
         if (schemes.RefreshSchemes.TryGetValue(name, out var refreshSourceScheme))
         {
             CopyFromSource(refreshSourceScheme, options);
-            // Override: path-scoped to DBSC endpoints, match source lifetime
+            // Override: path-scoped to DBSC endpoints. Lifetime and sliding behavior are inherited
+            // from the source scheme so the refresh cookie ages exactly like the auth cookie it
+            // replaces (renewed on each refresh when the source uses sliding expiration).
             options.Cookie.Path = "/.well-known/dbsc";
-            options.SlidingExpiration = false;
         }
         else if (schemes.SessionSchemes.TryGetValue(name, out var sessionSourceScheme))
         {
@@ -60,6 +61,7 @@ private void CopyFromSource(string sourceScheme, CookieAuthenticationOptions tar
         target.Cookie.Domain = source.Cookie.Domain;
         target.Cookie.IsEssential = source.Cookie.IsEssential;
         target.ExpireTimeSpan = source.ExpireTimeSpan;
+        target.SlidingExpiration = source.SlidingExpiration;
 
         // Restore the target-specific name and path
         target.Cookie.Name = targetName;
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs
index 48707f76f8d6..3bd906b8bb1d 100644
--- a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs
@@ -86,9 +86,50 @@ public void DerivedCookieSchemes_RemainDataProtected_AndSchemeKeyed()
         Assert.False(CanUnprotect(source.TicketDataFormat, protectedByRefresh));
     }
 
+    [Fact]
+    public void PostConfigure_RefreshScheme_InheritsSlidingExpiration_FromSource()
+    {
+        var sut = CreateDerivedPostConfigure(refreshScheme: RefreshScheme, sourceSlidingExpiration: true);
+        var options = new CookieAuthenticationOptions();
+        options.Cookie.Name = ".AspNetCore." + RefreshScheme;
+
+        sut.PostConfigure(RefreshScheme, options);
+
+        // The refresh cookie ages like the auth cookie it replaces: sliding inherited, lifetime copied.
+        Assert.True(options.SlidingExpiration);
+        Assert.Equal(TimeSpan.FromHours(3), options.ExpireTimeSpan);
+    }
+
+    [Fact]
+    public void PostConfigure_RefreshScheme_RespectsDisabledSlidingExpiration_FromSource()
+    {
+        var sut = CreateDerivedPostConfigure(refreshScheme: RefreshScheme, sourceSlidingExpiration: false);
+        var options = new CookieAuthenticationOptions();
+        options.Cookie.Name = ".AspNetCore." + RefreshScheme;
+
+        sut.PostConfigure(RefreshScheme, options);
+
+        // When the source app opts out of sliding, the refresh cookie matches it.
+        Assert.False(options.SlidingExpiration);
+    }
+
+    [Fact]
+    public void PostConfigure_SessionScheme_DisablesSlidingExpiration_EvenWhenSourceSlides()
+    {
+        var sut = CreateDerivedPostConfigure(sessionScheme: SessionScheme, sourceSlidingExpiration: true);
+        var options = new CookieAuthenticationOptions();
+        options.Cookie.Name = ".AspNetCore." + SessionScheme;
+
+        sut.PostConfigure(SessionScheme, options);
+
+        // The short-lived session cookie is deliberately non-sliding regardless of the source.
+        Assert.False(options.SlidingExpiration);
+    }
+
     private static PostConfigureDeviceBoundSessionDerivedCookieOptions CreateDerivedPostConfigure(
         string? refreshScheme = null,
-        string? sessionScheme = null)
+        string? sessionScheme = null,
+        bool sourceSlidingExpiration = true)
     {
         var sourceSchemes = new DeviceBoundSessionSourceSchemes();
         if (refreshScheme is not null)
@@ -106,6 +147,7 @@ private static PostConfigureDeviceBoundSessionDerivedCookieOptions CreateDerived
         {
             o.Cookie.HttpOnly = true;
             o.ExpireTimeSpan = TimeSpan.FromHours(3);
+            o.SlidingExpiration = sourceSlidingExpiration;
         });
 
         return new PostConfigureDeviceBoundSessionDerivedCookieOptions(

From 369627c099ee7d7bb5c3c818b4f7769a056aff1a Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 23 Jun 2026 16:08:44 +0000
Subject: [PATCH 21/33] Merge branch 'main' into roman/dbsc-prototype

Co-authored-by: rokonec <25249058+rokonec@users.noreply.github.com>
---
 src/submodules/MessagePack-CSharp | 2 +-
 src/submodules/googletest         | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/submodules/MessagePack-CSharp b/src/submodules/MessagePack-CSharp
index 365965f0d8c1..9aeb12b9bdb0 160000
--- a/src/submodules/MessagePack-CSharp
+++ b/src/submodules/MessagePack-CSharp
@@ -1 +1 @@
-Subproject commit 365965f0d8c13c40ff8fde25882066b90f569c7a
+Subproject commit 9aeb12b9bdb024512ffe2e4bddfa2785dca6e39e
diff --git a/src/submodules/googletest b/src/submodules/googletest
index 0b1e895ba422..d72f9c8aea68 160000
--- a/src/submodules/googletest
+++ b/src/submodules/googletest
@@ -1 +1 @@
-Subproject commit 0b1e895ba4226c2fda5ee0178c9b5b1195a741aa
+Subproject commit d72f9c8aea6817cdf1ca0ac10887f328de7f3da2

From ae7947dbefca5b3561b3aefa4758a6eb2134f99e Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Tue, 23 Jun 2026 18:15:09 +0200
Subject: [PATCH 22/33] docs: update DBSC design doc for recent work
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- document experimental ASP0030 gating and per-reason logging
- describe domain-separated challenge protectors and refresh-cookie sliding
- refresh file structure and add the lifetime-anchoring alternative

🔒 - Generated by Copilot
---
 .../DeviceBoundSessions/plans/dbsc-design.md  | 74 ++++++++++++++-----
 1 file changed, 55 insertions(+), 19 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md b/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
index 0c2e17f3f0b3..28c47904ec3a 100644
--- a/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
+++ b/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
@@ -6,13 +6,15 @@ Add support for [Device Bound Session Credentials (DBSC)](https://w3c.github.io/
 
 The implementation introduces a new `Microsoft.AspNetCore.Authentication.DeviceBoundSessions` NuGet package with a dedicated authentication handler that manages the DBSC protocol (registration, refresh, key proof validation) and delegates cookie management to existing cookie authentication handlers — following the same architectural pattern as OpenID Connect.
 
+> **Status: experimental prototype.** The entire public API surface is gated behind the analyzer diagnostic `ASP0030` (`[Experimental("ASP0030")]`); consumers must explicitly opt in. The DBSC wire protocol is a W3C **Editor's Draft** that changes frequently (we have already absorbed breaking changes such as header renames and a 401→403 re-challenge switch) and ships single-vendor (Chromium) behind a flag. Both the protocol and this API are expected to change.
+
 ## Motivation
 
 Cookie-based sessions are the most common authentication mechanism on the web. Their primary vulnerability is **cookie theft** — malware with access to the file system or browser memory can exfiltrate cookies and use them on another device.
 
 DBSC solves this by introducing a cryptographic key pair per session, where the private key is stored in secure hardware (TPM). The server issues short-lived cookies that must be periodically refreshed by proving possession of the private key. Even if the cookie is stolen, the attacker cannot refresh it without the TPM-bound key.
 
-Chrome 135+ ships DBSC support. This proposal integrates DBSC into ASP.NET Core so that applications can opt in with minimal code changes.
+Chrome ships DBSC behind a flag (`chrome://flags/#enable-standard-device-bound-session-credentials`). This proposal integrates DBSC into ASP.NET Core so that applications can opt in with minimal code changes.
 
 ## Goals
 
@@ -84,11 +86,11 @@ graph TD
 | Scheme | Cookie Name | Path | Lifetime | Role |
 |--------|-------------|------|----------|------|
 | `{Source}` (e.g. `Identity.Application`) | `.AspNetCore.{Source}` | `/` | Long (days) | Initial sign-in. Deleted after DBSC registration. |
-| `{Source}.Dbsc.Refresh` | `.AspNetCore.{Source}.Dbsc.Refresh` | `/.well-known/dbsc/` | Long (days) | Stash — holds ticket + public key. Only sent to refresh endpoint. |
+| `{Source}.Dbsc.Refresh` | `.AspNetCore.{Source}.Dbsc.Refresh` | `/.well-known/dbsc/` | Slides with source | Stash — holds ticket + public key. Only sent to refresh endpoint. |
 | `{Source}.Dbsc.Session` | `.AspNetCore.{Source}.Dbsc.Session` | `/` | Short (10min default) | Active session cookie. Used for authentication on all requests. |
 | `{Source}.Dbsc` (Policy) | — | — | — | Routes authentication: tries `.Session` first, falls back to `{Source}`. |
 
-The refresh and session cookie schemes **inherit settings** (HttpOnly, Secure, SameSite, Domain) from the source cookie scheme via `IPostConfigureOptions`.
+The refresh and session cookie schemes **inherit settings** (HttpOnly, Secure, SameSite, Domain, lifetime, and sliding behavior) from the source cookie scheme via `IPostConfigureOptions`.
 
 ### Protocol Flow
 
@@ -144,21 +146,22 @@ Challenges are time-limited, data-protected payloads that bind to the user ident
 
 **Registration challenge** (emitted at sign-in, validated at registration):
 ```
-ITimeLimitedDataProtector.Protect(CBOR(claimUid), lifetime=5min)
+registrationProtector.Protect(CBOR(claimUid), lifetime=5min)
 ```
 
 **Refresh challenge** (emitted at registration/refresh, validated at next refresh):
 ```
-ITimeLimitedDataProtector.Protect(CBOR(claimUid, sessionId), lifetime=5min)
+refreshProtector.Protect(CBOR(claimUid, sessionId), lifetime=5min)
 ```
 
 - **No nonce needed** — `ITimeLimitedDataProtector` provides uniqueness via its embedded timestamp + MAC
 - **Claim UID** follows antiforgery priority: `sub` > `ClaimTypes.NameIdentifier` > `ClaimTypes.Upn` > SHA256(all claims)
 - **Claim UID encoding** uses CBOR (claim type, value, issuer as text strings)
 - **CBOR payload** is a bare sequence of text strings (no array/map framing)
-- **5-minute expiry** — time-limited protector rejects expired challenges automatically
+- **5-minute expiry** (`ChallengeMaxAge`) — time-limited protector rejects expired challenges automatically
+- **Domain separation** — registration and refresh challenges are protected under **distinct data-protection purposes** (`...Challenge.Registration.v1` and `...Challenge.Refresh.v1`). A challenge minted for one flow can never be decrypted (let alone confused) as the other, so cross-type challenge confusion is cryptographically impossible.
 
-The `DeviceBoundSessionChallengeProtector` is a singleton service that caches the `ITimeLimitedDataProtector` instance.
+The `DeviceBoundSessionChallengeProtector` holds two `ITimeLimitedDataProtector` instances (one per purpose) and a logger; every validation failure is logged at `Debug` with its specific reason (undecryptable / malformed / principal-mismatch / session-mismatch), never logging the payload.
 
 ### JWT Validation
 
@@ -169,6 +172,16 @@ JWT validation uses `Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler`
 - Construct `ECDsaSecurityKey` (ES256) or `RsaSecurityKey` (RS256) from JWK
 - Validate signature via `JsonWebTokenHandler.ValidateTokenAsync()` with appropriate `TokenValidationParameters`
 
+`DeviceBoundSessionJwtValidator` is an instance service with a logger. Each rejection path logs its specific reason at `Debug` (malformed token, wrong `typ`, missing algorithm, missing/unsupported key, invalid signature, challenge mismatch) without logging the proof itself.
+
+### Diagnostics and Experimental Status
+
+Every public type in the package carries `[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]`, matching the convention used by the validation feature. The diagnostic id `ASP0030` is reserved in `docs/list-of-diagnostics.md`. Internal consumers, the source-generated JSON context, and the sample suppress `ASP0030` where the analyzer flags same-assembly usage. Marking the API experimental forces callers to explicitly opt in, signaling that both the unstable wire protocol and the still-evolving .NET surface may change.
+
+### Logging
+
+All diagnostic logging uses the source-generated `LoggerMessage` pattern in `LoggingExtensions.cs` (`internal static partial class DeviceBoundSessionsLoggingExtensions` in `namespace Microsoft.Extensions.Logging`). Validation failures are logged at `Debug` (matching Identity's convention), while operationally notable conditions (no source authentication, missing refresh cookie, principal/session mismatch) are logged at `Warning`. Secrets — proofs, challenge payloads, keys — are never logged.
+
 ### Integration with ASP.NET Core Identity
 
 ```csharp
@@ -197,8 +210,18 @@ No manual event wiring needed by the user.
 - `Cookie.Domain`
 - `Cookie.IsEssential`
 - `ExpireTimeSpan`
+- `SlidingExpiration`
+
+The refresh scheme overrides `Cookie.Path = "/.well-known/dbsc"`. The session scheme's `ExpireTimeSpan` is overridden at sign-in time via `AuthenticationProperties.ExpiresUtc`, and it is deliberately **non-sliding** (re-minted fresh on every refresh).
+
+#### Refresh cookie lifetime and sliding
 
-The refresh scheme overrides `Cookie.Path = "/.well-known/dbsc"`. The session scheme's `ExpireTimeSpan` is overridden at sign-in time via `AuthenticationProperties.ExpiresUtc`.
+The refresh cookie is a genuine cookie-auth scheme, so it reuses the standard `CookieAuthenticationHandler` lifetime machinery rather than inventing a parallel model:
+
+- It **inherits `SlidingExpiration`** from the source scheme (default `true`).
+- Its lifetime is **not anchored** at registration; sign-in starts a fresh `ExpireTimeSpan` window exactly like a normal login, and each refresh request (`AuthenticateAsync` on the refresh scheme) runs the cookie handler's own sliding renewal at the 50%-of-`ExpireTimeSpan` mark.
+
+The result is that enabling DBSC does **not** regress an active user's session lifetime: the refresh cookie ages exactly like the auth cookie it replaces — an active user stays signed in, an inactive one expires after `ExpireTimeSpan` of inactivity. Because each refresh is cryptographically bound to the device key, sliding here is also safer than sliding a plain bearer cookie. The short-lived session cookie remains non-sliding by design.
 
 ### Security Properties
 
@@ -211,34 +234,39 @@ The refresh scheme overrides `Cookie.Path = "/.well-known/dbsc"`. The session sc
 | Refresh endpoint is down | User must re-login. No stealable long-lived token persists. |
 | Non-DBSC browser | Falls back to long-lived cookie via policy scheme. Same security as today (no regression). |
 | Challenge replay | ITimeLimitedDataProtector rejects expired challenges; claim UID binding prevents cross-user replay. |
+| Cross-type challenge confusion | Registration and refresh challenges use distinct data-protection purposes, so a challenge from one flow cannot be decrypted or accepted by the other. |
 
 ### File Structure
 
 ```
 src/Security/Authentication/DeviceBoundSessions/src/
-├── DeviceBoundSessionChallengeProtector.cs      # Singleton: generates/validates challenges (CBOR + time-limited DP)
-├── DeviceBoundSessionConfiguration.cs           # JSON response model: session config
+├── DeviceBoundSessionChallengeProtector.cs      # Generates/validates challenges (CBOR + time-limited DP, two purposes), logs failures
 ├── DeviceBoundSessionConstants.cs               # Constants: DBSC header names + supported/advertised algorithms
-├── DeviceBoundSessionCookieEvents.cs            # CookieAuthenticationEvents wrapper for EventsType scenarios
-├── PostConfigureDeviceBoundSessionCookieOptions.cs # IPostConfigureOptions: hooks OnSigningIn to emit registration header
-├── DeviceBoundSessionRegistrationHeader.cs      # Writes the Secure-Session-Registration header on sign-in
-├── DeviceBoundSessionCredentialConfiguration.cs # JSON response model: credential entry
+├── DeviceBoundSessionCookieEvents.cs            # CookieAuthenticationEvents wiring for the source scheme
 ├── DeviceBoundSessionDefaults.cs                # Constants: scheme name, paths
-├── DeviceBoundSessionExtensions.cs              # AddDeviceBoundSession() extension method
+├── DeviceBoundSessionExtensions.cs              # AddDeviceBoundSession() extension methods
 ├── DeviceBoundSessionHandler.cs                 # Main handler: registration + refresh protocol
+├── DeviceBoundSessionHttpContextExtensions.cs   # WriteDeviceBoundSessionRegistration() helper
 ├── DeviceBoundSessionJsonContext.cs             # Source-generated JSON serializer context
 ├── DeviceBoundSessionJwtResult.cs               # JWT validation result model
-├── DeviceBoundSessionJwtValidator.cs            # JWT validation using Microsoft.IdentityModel
+├── DeviceBoundSessionJwtValidator.cs            # JWT proof validation (instance + logging) via Microsoft.IdentityModel
 ├── DeviceBoundSessionOptions.cs                 # Handler options
-├── DeviceBoundSessionScopeConfiguration.cs      # JSON response model: scope
+├── DeviceBoundSessionRegistrationHeader.cs      # Writes the Secure-Session-Registration header on sign-in
 ├── DeviceBoundSessionScopeRule.cs               # Options model: scope rule
-├── DeviceBoundSessionScopeRuleConfiguration.cs  # JSON response model: scope rule
 ├── DeviceBoundSessionSourceSchemes.cs           # Internal: tracks scheme name mappings
-├── PostConfigureDeviceBoundSessionDerivedCookieOptions.cs  # Copies source cookie settings to derived schemes
+├── LoggingExtensions.cs                         # Source-generated LoggerMessage events
+├── PostConfigureDeviceBoundSessionCookieOptions.cs          # IPostConfigure: hooks OnSigningIn to emit registration header
+├── PostConfigureDeviceBoundSessionDerivedCookieOptions.cs   # Copies source cookie settings (+ sliding) to derived schemes
+├── SessionInstruction.cs                        # JSON response model: DBSC session instruction (root)
+├── SessionScope.cs                              # JSON response model: scope
+├── SessionScopeRule.cs                          # JSON response model: scope rule
+├── SessionCredential.cs                         # JSON response model: credential entry (type is fixed "cookie")
 ├── PublicAPI.Shipped.txt
 └── PublicAPI.Unshipped.txt
 ```
 
+The `DbscDebugServer` sample (`samples/DbscDebugServer/`) provides a live dashboard that decodes cookies, proof JWTs, and challenges (using both protector purposes) for inspecting the protocol end-to-end.
+
 ## Risks
 
 - **Refresh cookie size**: Contains a full authentication ticket (~500-1500 bytes base64). Within the 4KB per-cookie limit for typical claims sets. Large claims sets may need the existing cookie chunking mechanism.
@@ -293,3 +321,11 @@ Rejected because:
 - `ITimeLimitedDataProtector` already provides uniqueness via embedded timestamp + MAC
 - Adding a nonce increases payload size without security benefit
 - The data protection system's MAC prevents any form of replay or forgery
+
+### Anchoring the refresh cookie lifetime to the source ticket
+
+An earlier iteration copied the source sign-in ticket's `IssuedUtc`/`ExpiresUtc` onto the refresh cookie so the bound session could "never outlive the credential it was exchanged for." Rejected because:
+- It silently **shortened** an active user's session relative to plain cookie auth (which slides by default), a behavior regression when DBSC is enabled
+- It duplicated lifetime logic instead of reusing the cookie handler's proven sliding machinery
+
+Replaced by inheriting `SlidingExpiration`/`ExpireTimeSpan` from the source scheme and letting the refresh scheme's own `CookieAuthenticationHandler` slide it \u2014 zero divergence from the auth cookie it replaces.

From 84f7b485e6316071c1dd44e3c68583b85ed486d9 Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Tue, 23 Jun 2026 19:41:43 +0200
Subject: [PATCH 23/33] chore: restore DBSC sample source cookie lifetime to 7
 days
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- remove the temporary 20-minute ExpireTimeSpan used for manual sliding tests

🔒 - Generated by Copilot
---
 .../DeviceBoundSessions/samples/DbscDebugServer/Program.cs     | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
index 8afedb39543c..334bcf40fa79 100644
--- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
@@ -59,8 +59,7 @@ public void ConfigureServices(IServiceCollection services)
             {
                 o.Cookie.Name = DbscNames.SourceCookie;
                 o.LoginPath = "/login";
-                // TEMP (manual sliding test): 20 min lifetime, slides at the 50% mark (~10 min).
-                o.ExpireTimeSpan = TimeSpan.FromMinutes(20);
+                o.ExpireTimeSpan = TimeSpan.FromDays(7);
             })
             // The DBSC handler + refresh/session cookie schemes + policy scheme.
             // The session TTL is read live from the debug state so it can be changed at runtime.

From e82aad911fb15316ac4fc9f8431ab5b3c88eea6f Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Tue, 23 Jun 2026 19:44:05 +0200
Subject: [PATCH 24/33] fix: correct DBSC sample path in solution and drop
 phantom test project
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- point AspNetCore.slnx at the renamed DbscDebugServer sample
- remove the non-existent DeviceBoundSessions test project (tests live in the shared Authentication.Test project)

🔒 - Generated by Copilot
---
 AspNetCore.slnx | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/AspNetCore.slnx b/AspNetCore.slnx
index 8649bc77b95a..9ffec5549017 100644
--- a/AspNetCore.slnx
+++ b/AspNetCore.slnx
@@ -813,10 +813,7 @@
     
   
   
-    
-  
-  
-    
+    
   
   
     

From d75a6255c3877f21e2d036d3a123e1f0aa02a557 Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Tue, 23 Jun 2026 23:30:25 +0200
Subject: [PATCH 25/33] build: regenerate project reference lists for DBSC
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- run GenerateProjectList to fix ordering/formatting of the DBSC entry
- updates ProjectReferences, ShippingAssemblies, and TrimmableProjects

🔒 - Generated by Copilot
---
 eng/ProjectReferences.props  | 2 +-
 eng/ShippingAssemblies.props | 1 +
 eng/TrimmableProjects.props  | 1 +
 3 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props
index aa87fee42140..73ba757edeba 100644
--- a/eng/ProjectReferences.props
+++ b/eng/ProjectReferences.props
@@ -57,8 +57,8 @@
     
     
     
-    
     
+    
     
     
     
diff --git a/eng/ShippingAssemblies.props b/eng/ShippingAssemblies.props
index a432e88d2eaf..a47f24f92caa 100644
--- a/eng/ShippingAssemblies.props
+++ b/eng/ShippingAssemblies.props
@@ -122,6 +122,7 @@
     
     
     
+    
     
     
     
diff --git a/eng/TrimmableProjects.props b/eng/TrimmableProjects.props
index 826193b51946..707ba374fe28 100644
--- a/eng/TrimmableProjects.props
+++ b/eng/TrimmableProjects.props
@@ -48,6 +48,7 @@
     
     
     
+    
     
     
     

From ecd7d9357b97b556e582fd0798d2bbb0d70e1e95 Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Wed, 24 Jun 2026 00:55:05 +0200
Subject: [PATCH 26/33] fix(dbsc): address review feedback on session
 instruction and cookie wiring
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Resolve correctness issues raised in PR review and tighten the handler's
dependency wiring:

- Mirror the source sign-in's IsPersistent on the refresh cookie instead of
  forcing it persistent, so DBSC no longer changes session/persistence
  semantics for session-only sign-ins.
- Derive the session instruction's credential name and attributes from the
  session scheme's CookieAuthenticationOptions so they stay in lock-step with
  the cookie actually emitted; throw on a missing scheme/name rather than
  emitting a guessed default that can never match.
- Derive the refresh cookie path from the configured RefreshPath so a
  customized RefreshPath still receives the cookie.
- Resolve the session scheme's cookie name at request time in the policy
  scheme selector so a custom Cookie.Name is still matched.
- Constructor-inject IOptionsMonitor instead of
  service-locating it per request; update PublicAPI.Unshipped accordingly.
- Use the idiomatic JsonSerializer.Deserialize in the proof-key
  test helper instead of an undisposed JsonDocument.

🤖 Generated by Copilot
---
 .../src/DeviceBoundSessionExtensions.cs       |  8 +-
 .../src/DeviceBoundSessionHandler.cs          | 88 +++++++++++++++----
 ...eDeviceBoundSessionDerivedCookieOptions.cs | 33 ++++++-
 .../src/PublicAPI.Unshipped.txt               |  2 +-
 .../test/DeviceBoundSessions/DbscProofKey.cs  |  4 +-
 5 files changed, 110 insertions(+), 25 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
index c33e8dade774..cba8284fd4a4 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
@@ -96,11 +96,17 @@ private static AuthenticationBuilder AddDeviceBoundSessionCore(
         });
 
         // Add a policy scheme that tries the session cookie first, then falls back to the source scheme
-        var sessionCookieName = $".AspNetCore.{sourceScheme}.Dbsc.Session";
         builder.AddPolicyScheme(policyScheme, policyScheme, o =>
         {
             o.ForwardDefaultSelector = context =>
             {
+                // Resolve the session scheme's configured cookie name at request time so an app that
+                // customizes CookieAuthenticationOptions.Cookie.Name is still matched. Otherwise the
+                // selector would miss the cookie and fall back to a source scheme that DBSC registration
+                // may have deleted, effectively logging the user out.
+                var sessionCookieName = context.RequestServices
+                    .GetRequiredService>()
+                    .Get(sessionScheme).Cookie.Name ?? $".AspNetCore.{sessionScheme}";
                 if (context.Request.Cookies.ContainsKey(sessionCookieName))
                 {
                     return sessionScheme;
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
index 1e08c69ff26a..3142adccb66b 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -25,6 +25,7 @@ public class DeviceBoundSessionHandler : AuthenticationHandler _cookieOptionsMonitor;
 
     /// 
     /// Initializes a new instance of .
@@ -33,15 +34,18 @@ public class DeviceBoundSessionHandler : AuthenticationHandlerThe  used to create loggers.
     /// The  used to encode URLs.
     /// The  used to protect and unprotect registration challenges.
+    /// The monitor for  used to resolve the session cookie configuration.
     public DeviceBoundSessionHandler(
         IOptionsMonitor options,
         ILoggerFactory logger,
         UrlEncoder encoder,
-        IDataProtectionProvider dataProtectionProvider)
+        IDataProtectionProvider dataProtectionProvider,
+        IOptionsMonitor cookieOptionsMonitor)
         : base(options, logger, encoder)
     {
         _challengeProtector = new DeviceBoundSessionChallengeProtector(dataProtectionProvider, logger.CreateLogger());
         _jwtValidator = new DeviceBoundSessionJwtValidator(logger.CreateLogger());
+        _cookieOptionsMonitor = cookieOptionsMonitor;
     }
 
     /// 
@@ -137,7 +141,9 @@ private async Task HandleRegistrationAsync()
         refreshProperties.Items["DbscPublicKeyJwk"] = jwtResult.PublicKeyJwk;
         refreshProperties.Items["DbscSessionId"] = sessionId;
         refreshProperties.Items["DbscAlgorithm"] = jwtResult.Algorithm;
-        refreshProperties.IsPersistent = true;
+        // Mirror the source sign-in's persistence so enabling DBSC does not change session/persistence
+        // semantics: a session-only sign-in stays session-only, a persistent one stays persistent.
+        refreshProperties.IsPersistent = properties.IsPersistent;
 
         // Leave IssuedUtc/ExpiresUtc unset so the refresh cookie scheme starts a fresh ExpireTimeSpan
         // window at registration (exactly like a normal sign-in) and then slides on each refresh when
@@ -286,9 +292,14 @@ private SessionInstruction BuildSessionInstruction(string sessionId)
             }).ToList();
         }
 
-        // The credential cookie name is based on the session scheme
-        // We need to resolve it from the cookie options for that scheme
-        var sessionCookieName = ResolveSessionCookieName();
+        // The credential cookie name and attributes are derived from the session scheme's cookie
+        // options so the session instruction stays in lock-step with the cookie we actually emit.
+        // A wrong name would silently break DBSC (the browser would bind to a cookie we never set),
+        // so we fail loudly rather than emit a guessed default that can never match.
+        var sessionCookieOptions = ResolveSessionCookieOptions();
+        var sessionCookieName = sessionCookieOptions.Cookie.Name
+            ?? throw new InvalidOperationException(
+                $"The session cookie scheme '{Options.SessionScheme}' has no configured cookie name; cannot build a valid DBSC session instruction.");
 
         return new SessionInstruction
         {
@@ -305,26 +316,69 @@ private SessionInstruction BuildSessionInstruction(string sessionId)
                 new SessionCredential
                 {
                     Name = sessionCookieName,
-                    Attributes = "Secure; HttpOnly; SameSite=Lax; Path=/",
+                    Attributes = BuildCredentialAttributes(sessionCookieOptions),
                 }
             },
         };
     }
 
-    private string ResolveSessionCookieName()
+    private CookieAuthenticationOptions ResolveSessionCookieOptions()
     {
-        // Try to get the cookie name from the session scheme's cookie options
-        var cookieOptionsMonitor = Context.RequestServices.GetService(
-            typeof(IOptionsMonitor)) as IOptionsMonitor;
-        if (cookieOptionsMonitor is not null && Options.SessionScheme is not null)
+        if (Options.SessionScheme is null)
         {
-            var cookieOptions = cookieOptionsMonitor.Get(Options.SessionScheme);
-            if (cookieOptions.Cookie.Name is not null)
-            {
-                return cookieOptions.Cookie.Name;
-            }
+            throw new InvalidOperationException(
+                $"{nameof(DeviceBoundSessionOptions.SessionScheme)} must be configured to build a DBSC session instruction.");
+        }
+
+        return _cookieOptionsMonitor.Get(Options.SessionScheme);
+    }
+
+    private string BuildCredentialAttributes(CookieAuthenticationOptions cookieOptions)
+    {
+        // Keep the credential's attributes aligned with the actual session cookie configuration so the
+        // browser interprets the session instruction the same way it treats the cookie we emit.
+        var cookie = cookieOptions.Cookie;
+
+        var secure = cookie.SecurePolicy switch
+        {
+            CookieSecurePolicy.Always => true,
+            CookieSecurePolicy.None => false,
+            // SameAsRequest (the cookie-auth default) follows the request scheme.
+            _ => Request.IsHttps,
+        };
+
+        var attributes = new List();
+        if (secure)
+        {
+            attributes.Add("Secure");
+        }
+        if (cookie.HttpOnly)
+        {
+            attributes.Add("HttpOnly");
+        }
+
+        switch (cookie.SameSite)
+        {
+            case SameSiteMode.None:
+                attributes.Add("SameSite=None");
+                break;
+            case SameSiteMode.Strict:
+                attributes.Add("SameSite=Strict");
+                break;
+            case SameSiteMode.Lax:
+                attributes.Add("SameSite=Lax");
+                break;
         }
-        return ".AspNetCore.Dbsc.Session";
+
+        var path = cookie.Path;
+        attributes.Add($"Path={(string.IsNullOrEmpty(path) ? "/" : path)}");
+
+        if (!string.IsNullOrEmpty(cookie.Domain))
+        {
+            attributes.Add($"Domain={cookie.Domain}");
+        }
+
+        return string.Join("; ", attributes);
     }
 
     private string GenerateRegistrationChallenge(ClaimsPrincipal principal)
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
index 0cad1d87558f..feb5d4821cd1 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
@@ -1,6 +1,8 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
 using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Options;
@@ -33,10 +35,11 @@ public void PostConfigure(string? name, CookieAuthenticationOptions options)
         if (schemes.RefreshSchemes.TryGetValue(name, out var refreshSourceScheme))
         {
             CopyFromSource(refreshSourceScheme, options);
-            // Override: path-scoped to DBSC endpoints. Lifetime and sliding behavior are inherited
-            // from the source scheme so the refresh cookie ages exactly like the auth cookie it
-            // replaces (renewed on each refresh when the source uses sliding expiration).
-            options.Cookie.Path = "/.well-known/dbsc";
+            // Override: scope the refresh cookie to the DBSC endpoints' directory, derived from the
+            // configured RefreshPath so a customized RefreshPath still receives the cookie. Lifetime and
+            // sliding behavior are inherited from the source scheme so the refresh cookie ages exactly
+            // like the auth cookie it replaces (renewed on each refresh when the source uses sliding).
+            options.Cookie.Path = ResolveRefreshCookiePath(refreshSourceScheme);
         }
         else if (schemes.SessionSchemes.TryGetValue(name, out var sessionSourceScheme))
         {
@@ -47,6 +50,28 @@ public void PostConfigure(string? name, CookieAuthenticationOptions options)
         }
     }
 
+    private string ResolveRefreshCookiePath(string sourceScheme)
+    {
+        const string fallback = "/.well-known/dbsc";
+
+        // Map source cookie scheme -> DBSC handler scheme so we can read its configured RefreshPath.
+        if (!_sourceSchemes.Value.Schemes.TryGetValue(sourceScheme, out var dbscScheme))
+        {
+            return fallback;
+        }
+
+        var refreshPath = _services.GetRequiredService>()
+            .Get(dbscScheme).RefreshPath.Value;
+        if (string.IsNullOrEmpty(refreshPath))
+        {
+            return fallback;
+        }
+
+        // Scope the cookie to the refresh endpoint's directory so it is sent to that endpoint.
+        var lastSlash = refreshPath.LastIndexOf('/');
+        return lastSlash > 0 ? refreshPath[..lastSlash] : "/";
+    }
+
     private void CopyFromSource(string sourceScheme, CookieAuthenticationOptions target)
     {
         var source = _services.GetRequiredService>().Get(sourceScheme);
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
index 2ce0d0321ddc..f97d27992632 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
@@ -6,7 +6,7 @@ Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaul
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHttpContextExtensions
 static Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHttpContextExtensions.WriteDeviceBoundSessionRegistration(this Microsoft.AspNetCore.Http.HttpContext! context, string! sourceScheme) -> void
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler
-Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.DeviceBoundSessionHandler(Microsoft.Extensions.Options.IOptionsMonitor! options, Microsoft.Extensions.Logging.ILoggerFactory! logger, System.Text.Encodings.Web.UrlEncoder! encoder, Microsoft.AspNetCore.DataProtection.IDataProtectionProvider! dataProtectionProvider) -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.DeviceBoundSessionHandler(Microsoft.Extensions.Options.IOptionsMonitor! options, Microsoft.Extensions.Logging.ILoggerFactory! logger, System.Text.Encodings.Web.UrlEncoder! encoder, Microsoft.AspNetCore.DataProtection.IDataProtectionProvider! dataProtectionProvider, Microsoft.Extensions.Options.IOptionsMonitor! cookieOptionsMonitor) -> void
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.HandleRequestAsync() -> System.Threading.Tasks.Task!
 override Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.HandleAuthenticateAsync() -> System.Threading.Tasks.Task!
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs b/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs
index 22e8c2d6e512..45676ec79d9d 100644
--- a/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs
@@ -68,7 +68,7 @@ public string CreateProof(string jti, bool includeJwkHeader = true, string? auth
         var headerClaims = new Dictionary { ["typ"] = "dbsc+jwt" };
         if (includeJwkHeader)
         {
-            headerClaims["jwk"] = System.Text.Json.JsonDocument.Parse(PublicJwkJson).RootElement;
+            headerClaims["jwk"] = System.Text.Json.JsonSerializer.Deserialize(PublicJwkJson);
         }
 
         var claims = new Dictionary { ["jti"] = jti };
@@ -109,7 +109,7 @@ public static string CreateUnsignedToken(string? typ, string? alg, string? jwkJs
         }
         if (jwkJson is not null)
         {
-            header["jwk"] = System.Text.Json.JsonDocument.Parse(jwkJson).RootElement;
+            header["jwk"] = System.Text.Json.JsonSerializer.Deserialize(jwkJson);
         }
 
         var payload = new Dictionary { ["jti"] = jti };

From 4039bd936b6cb43cd6c79a293af4be4bd575ff7f Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Tue, 30 Jun 2026 19:27:31 +0200
Subject: [PATCH 27/33] build(dbsc): version the package with
 ExperimentalVersionPrefix
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

DBSC ships as a standalone NuGet package (it is not part of the
Microsoft.AspNetCore.App shared framework) and its public surface is
entirely experimental (ASP0030). Set VersionPrefix to the repo's
ExperimentalVersionPrefix so it publishes as a 0.x preview (0.11.0)
rather than the stable framework version (11.0.0), matching the
convention used by the experimental Microsoft.AspNetCore.Grpc.Swagger
package and signaling that the API is not yet stable.

🤖 Generated by Copilot
---
 ...crosoft.AspNetCore.Authentication.DeviceBoundSessions.csproj | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
index 03051d9db487..69ae6cff3ad0 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
+++ b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj
@@ -3,6 +3,8 @@
   
     ASP.NET Core middleware that enables Device Bound Session Credentials (DBSC) over an inner authentication scheme.
     $(DefaultNetCoreTargetFramework)
+    
+    $(ExperimentalVersionPrefix)
     true
     aspnetcore;authentication;dbsc;security
     true

From d98dcb11f1ec753f250da1cd133c7009bb0949b2 Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Tue, 30 Jun 2026 23:31:14 +0200
Subject: [PATCH 28/33] fix: drop accidental submodule pointer changes

- restore MessagePack-CSharp and googletest gitlinks to branch point so PR touches neither
---
 src/submodules/MessagePack-CSharp | 2 +-
 src/submodules/googletest         | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/submodules/MessagePack-CSharp b/src/submodules/MessagePack-CSharp
index 9aeb12b9bdb0..365965f0d8c1 160000
--- a/src/submodules/MessagePack-CSharp
+++ b/src/submodules/MessagePack-CSharp
@@ -1 +1 @@
-Subproject commit 9aeb12b9bdb024512ffe2e4bddfa2785dca6e39e
+Subproject commit 365965f0d8c13c40ff8fde25882066b90f569c7a
diff --git a/src/submodules/googletest b/src/submodules/googletest
index d72f9c8aea68..0b1e895ba422 160000
--- a/src/submodules/googletest
+++ b/src/submodules/googletest
@@ -1 +1 @@
-Subproject commit d72f9c8aea6817cdf1ca0ac10887f328de7f3da2
+Subproject commit 0b1e895ba4226c2fda5ee0178c9b5b1195a741aa

From ed9769d8fa9eace554a1d29f74a3fd55c52dc32f Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Thu, 9 Jul 2026 10:43:06 +0200
Subject: [PATCH 29/33] refactor(dbsc): drop no-op cookie name/path
 save-restore
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

CopyFromSource saved and restored target.Cookie.Name/Path around the
attribute copy, but the copy never touches Name or Path, so the
save-restore was a no-op. Remove it; callers set the refresh cookie path
explicitly and the session scheme keeps its configured name.

Addresses PR review feedback.

🤖 Generated by Copilot
---
 ...tConfigureDeviceBoundSessionDerivedCookieOptions.cs | 10 ----------
 1 file changed, 10 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
index feb5d4821cd1..dbdf89049e44 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
@@ -77,9 +77,6 @@ private void CopyFromSource(string sourceScheme, CookieAuthenticationOptions tar
         var source = _services.GetRequiredService>().Get(sourceScheme);
 
         // Copy cookie builder settings (preserving any name/path already set)
-        var targetName = target.Cookie.Name;
-        var targetPath = target.Cookie.Path;
-
         target.Cookie.HttpOnly = source.Cookie.HttpOnly;
         target.Cookie.SecurePolicy = source.Cookie.SecurePolicy;
         target.Cookie.SameSite = source.Cookie.SameSite;
@@ -87,12 +84,5 @@ private void CopyFromSource(string sourceScheme, CookieAuthenticationOptions tar
         target.Cookie.IsEssential = source.Cookie.IsEssential;
         target.ExpireTimeSpan = source.ExpireTimeSpan;
         target.SlidingExpiration = source.SlidingExpiration;
-
-        // Restore the target-specific name and path
-        target.Cookie.Name = targetName;
-        if (targetPath is not null)
-        {
-            target.Cookie.Path = targetPath;
-        }
     }
 }

From 6207bf2187d1d44034924ddc0483ac42be56295b Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Fri, 10 Jul 2026 00:40:13 +0200
Subject: [PATCH 30/33] feat(dbsc): full sign-out and opt-in default scheme
 handling
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Address PR review feedback on logout semantics and default-scheme
ownership:

- Sign-out of a DBSC source scheme now also clears the derived session
  and refresh cookies, so a plain SignOutAsync(sourceScheme) fully logs
  the user out. Applications never need to know the derived scheme names
  to clear them — just as they never need them to create them. Wired via
  a shared helper on DeviceBoundSessionCookieEvents, covering both the
  delegate-based and EventsType cookie-event configurations.
- Guard the registration cookie exchange: the handler signs out the
  source scheme to drop the long-lived cookie during registration, which
  is not a user logout, so it no longer clears the session/refresh
  cookies it just minted.
- Stop unconditionally overriding the application's default schemes.
  A new IPostConfigureOptions upgrades the default
  authenticate scheme to the DBSC policy scheme only when it already
  resolves to a wrapped source scheme (otherwise the user would be logged
  out after registration deletes the source cookie). Sign-in/sign-out and
  unrelated defaults are left untouched.

Add unit tests for the conditional default-scheme upgrade and end-to-end
integration tests (sign-in, registration, sign-out) that verify the
registration exchange keeps the derived cookies and sign-out clears them.

🤖 Generated by Copilot
---
 .../samples/DbscDebugServer/Program.cs        |   8 +-
 .../src/DeviceBoundSessionCookieEvents.cs     |  44 +++++
 .../src/DeviceBoundSessionExtensions.cs       |   9 +-
 .../src/DeviceBoundSessionHandler.cs          |   5 +-
 .../src/DeviceBoundSessionSourceSchemes.cs    |   3 +
 ...DeviceBoundSessionAuthenticationOptions.cs |  40 +++++
 ...onfigureDeviceBoundSessionCookieOptions.cs |   7 +
 ...eBoundSessionAuthenticationOptionsTests.cs | 120 +++++++++++++
 .../DeviceBoundSessionSignOutTests.cs         | 167 ++++++++++++++++++
 9 files changed, 392 insertions(+), 11 deletions(-)
 create mode 100644 src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionAuthenticationOptions.cs
 create mode 100644 src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionAuthenticationOptionsTests.cs
 create mode 100644 src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionSignOutTests.cs

diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
index 334bcf40fa79..a23d340c5625 100644
--- a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs
@@ -53,7 +53,10 @@ public void ConfigureServices(IServiceCollection services)
     {
         services.AddSingleton(_debug);
 
-        services.AddAuthentication()
+        // The app makes the source cookie its default scheme; AddDeviceBoundSession detects that and
+        // redirects the default authenticate scheme to its policy scheme, so the app never has to know
+        // (or wire) the derived DBSC scheme names.
+        services.AddAuthentication(DbscNames.Source)
             // The source cookie scheme (long-lived sign-in cookie)
             .AddCookie(DbscNames.Source, o =>
             {
@@ -220,8 +223,7 @@ await context.Response.WriteAsJsonAsync(new
 
     private static async Task SignOutAllAsync(HttpContext context)
     {
+        // Signing out the source scheme now also clears the DBSC session and refresh cookies.
         await context.SignOutAsync(DbscNames.Source);
-        await context.SignOutAsync(DbscNames.Refresh);
-        await context.SignOutAsync(DbscNames.Session);
     }
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
index bcdf63cbe324..9d02f4d57e64 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs
@@ -1,9 +1,12 @@
 // Licensed to the .NET Foundation under one or more agreements.
 // The .NET Foundation licenses this file to you under the MIT license.
 
+#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
 using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
 
 namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
@@ -48,6 +51,7 @@ public override async Task SignedIn(CookieSignedInContext context)
 
     public override async Task SigningOut(CookieSigningOutContext context)
     {
+        await ClearDerivedCookiesAsync(context.HttpContext, _dbscScheme);
         await GetInnerEvents(context.HttpContext).SigningOut(context);
     }
 
@@ -80,4 +84,44 @@ private CookieAuthenticationEvents GetInnerEvents(HttpContext httpContext)
 
         return (CookieAuthenticationEvents)httpContext.RequestServices.GetRequiredService(_innerEventsType);
     }
+
+    /// 
+    /// Marker set on  during the DBSC registration cookie exchange, when
+    /// the handler signs out the source scheme to drop the long-lived cookie. It tells the source
+    /// scheme's sign-out hook that this is an exchange, not a user logout, so it leaves the
+    /// freshly-minted session and refresh cookies intact.
+    /// 
+    internal const string RegistrationExchangeItemKey = "__DeviceBoundSession.RegistrationExchange";
+
+    /// 
+    /// Clears the DBSC-derived session and refresh cookies when the source scheme signs out, so a plain
+    /// SignOutAsync(sourceScheme) fully logs the user out. The application never needs to know the
+    /// derived scheme names to clear them — just as it never needs them to create them. Shared with the
+    /// delegate-based sign-out wiring installed by .
+    /// 
+    /// The current HTTP context.
+    /// The resolved DBSC handler scheme whose options name the derived schemes.
+    internal static async Task ClearDerivedCookiesAsync(HttpContext httpContext, string dbscScheme)
+    {
+        // During DBSC registration the handler signs out the source scheme to complete the cookie
+        // exchange. That is not a user logout, so leave the session/refresh cookies just minted intact.
+        if (httpContext.Items.ContainsKey(RegistrationExchangeItemKey))
+        {
+            return;
+        }
+
+        var options = httpContext.RequestServices
+            .GetRequiredService>()
+            .Get(dbscScheme);
+
+        if (options.SessionScheme is not null)
+        {
+            await httpContext.SignOutAsync(options.SessionScheme);
+        }
+
+        if (options.RefreshScheme is not null)
+        {
+            await httpContext.SignOutAsync(options.RefreshScheme);
+        }
+    }
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
index cba8284fd4a4..3c39d209e338 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs
@@ -119,11 +119,13 @@ private static AuthenticationBuilder AddDeviceBoundSessionCore(
         builder.Services.TryAddSingleton();
         builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureDeviceBoundSessionCookieOptions>());
         builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureDeviceBoundSessionDerivedCookieOptions>());
+        builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureDeviceBoundSessionAuthenticationOptions>());
         builder.Services.Configure(o =>
         {
             o.Schemes[sourceScheme] = authenticationScheme;
             o.RefreshSchemes[refreshScheme] = sourceScheme;
             o.SessionSchemes[sessionScheme] = sourceScheme;
+            o.PolicySchemes[sourceScheme] = policyScheme;
         });
 
         // Add the DBSC protocol handler
@@ -135,13 +137,6 @@ private static AuthenticationBuilder AddDeviceBoundSessionCore(
             configureOptions?.Invoke(o);
         });
 
-        // Set the policy scheme as the default authenticate scheme
-        builder.Services.Configure(o =>
-        {
-            o.DefaultAuthenticateScheme = policyScheme;
-            o.DefaultSignInScheme = sourceScheme;
-        });
-
         return builder;
     }
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
index 3142adccb66b..843eb3a59e98 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -162,7 +162,10 @@ private async Task HandleRegistrationAsync()
         };
         await Context.SignInAsync(Options.SessionScheme, principal, sessionProperties);
 
-        // 3. Delete the long-lived source cookie (exchange complete)
+        // 3. Delete the long-lived source cookie (exchange complete). Flag this as the DBSC
+        // registration exchange so the source scheme's sign-out hook does not treat it as a user
+        // logout and clear the session and refresh cookies we just minted.
+        Context.Items[DeviceBoundSessionCookieEvents.RegistrationExchangeItemKey] = true;
         await Context.SignOutAsync(Options.RegistrationSourceScheme);
 
         // Build and return session instructions JSON.
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs
index 85d29fc4b624..070ccee96d19 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs
@@ -13,4 +13,7 @@ internal sealed class DeviceBoundSessionSourceSchemes
 
     /// Maps session cookie scheme → source cookie scheme.
     public IDictionary SessionSchemes { get; } = new Dictionary(StringComparer.Ordinal);
+
+    /// Maps source cookie scheme → DBSC policy scheme.
+    public IDictionary PolicySchemes { get; } = new Dictionary(StringComparer.Ordinal);
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionAuthenticationOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionAuthenticationOptions.cs
new file mode 100644
index 000000000000..cf729ab1be78
--- /dev/null
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionAuthenticationOptions.cs
@@ -0,0 +1,40 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+/// 
+/// If an application's default authenticate scheme resolves to a DBSC source scheme, redirects it to
+/// the corresponding DBSC policy scheme. This keeps the user authenticated after registration swaps the
+/// long-lived source cookie for the short-lived session cookie: the source cookie the app defaulted to
+/// no longer exists, but the policy scheme reads the session cookie (falling back to the source cookie
+/// before registration). Defaults the app pointed at any other scheme are left untouched, and the
+/// sign-in/sign-out defaults are never changed.
+/// 
+internal sealed class PostConfigureDeviceBoundSessionAuthenticationOptions : IPostConfigureOptions
+{
+    private readonly IOptions _sourceSchemes;
+
+    public PostConfigureDeviceBoundSessionAuthenticationOptions(IOptions sourceSchemes)
+    {
+        _sourceSchemes = sourceSchemes;
+    }
+
+    public void PostConfigure(string? name, AuthenticationOptions options)
+    {
+        // The effective authenticate scheme is DefaultAuthenticateScheme, falling back to DefaultScheme.
+        var effectiveAuthenticateScheme = options.DefaultAuthenticateScheme ?? options.DefaultScheme;
+        if (effectiveAuthenticateScheme is null)
+        {
+            return;
+        }
+
+        // Only upgrade when the app's default authenticate scheme is a scheme we wrapped with DBSC.
+        if (_sourceSchemes.Value.PolicySchemes.TryGetValue(effectiveAuthenticateScheme, out var policyScheme))
+        {
+            options.DefaultAuthenticateScheme = policyScheme;
+        }
+    }
+}
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionCookieOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionCookieOptions.cs
index 0c81bf2a8d35..f9c9c1c23d5f 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionCookieOptions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionCookieOptions.cs
@@ -38,6 +38,13 @@ public void PostConfigure(string? name, CookieAuthenticationOptions options)
                 DeviceBoundSessionRegistrationHeader.Emit(context.HttpContext, context.Principal, dbscScheme);
                 await priorSigningIn(context);
             };
+
+            var priorSigningOut = options.Events.OnSigningOut;
+            options.Events.OnSigningOut = async context =>
+            {
+                await DeviceBoundSessionCookieEvents.ClearDerivedCookiesAsync(context.HttpContext, dbscScheme);
+                await priorSigningOut(context);
+            };
             return;
         }
 
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionAuthenticationOptionsTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionAuthenticationOptionsTests.cs
new file mode 100644
index 000000000000..87152a536a04
--- /dev/null
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionAuthenticationOptionsTests.cs
@@ -0,0 +1,120 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using Microsoft.Extensions.Options;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+public class DeviceBoundSessionAuthenticationOptionsTests
+{
+    private const string SourceScheme = "Source";
+    private const string PolicyScheme = "Source.Dbsc";
+
+    [Fact]
+    public void Upgrades_DefaultAuthenticateScheme_WhenItIsAWrappedSourceScheme()
+    {
+        var sut = CreateSut((SourceScheme, PolicyScheme));
+        var options = new AuthenticationOptions { DefaultAuthenticateScheme = SourceScheme };
+
+        sut.PostConfigure(Options.DefaultName, options);
+
+        Assert.Equal(PolicyScheme, options.DefaultAuthenticateScheme);
+    }
+
+    [Fact]
+    public void Upgrades_EffectiveDefault_WhenOnlyDefaultSchemeIsWrappedSource()
+    {
+        var sut = CreateSut((SourceScheme, PolicyScheme));
+        var options = new AuthenticationOptions { DefaultScheme = SourceScheme };
+
+        sut.PostConfigure(Options.DefaultName, options);
+
+        // The authenticate default is redirected to the policy scheme...
+        Assert.Equal(PolicyScheme, options.DefaultAuthenticateScheme);
+        // ...but DefaultScheme (which sign-in/out/challenge fall back to) is left on the source scheme.
+        Assert.Equal(SourceScheme, options.DefaultScheme);
+    }
+
+    [Fact]
+    public void ExplicitAuthenticateScheme_TakesPrecedence_OverDefaultScheme()
+    {
+        var sut = CreateSut((SourceScheme, PolicyScheme));
+        var options = new AuthenticationOptions
+        {
+            DefaultScheme = SourceScheme,
+            DefaultAuthenticateScheme = "Other",
+        };
+
+        // The effective authenticate scheme is "Other", not the wrapped source, so nothing changes.
+        sut.PostConfigure(Options.DefaultName, options);
+
+        Assert.Equal("Other", options.DefaultAuthenticateScheme);
+    }
+
+    [Fact]
+    public void DoesNotChange_WhenDefaultIsUnrelatedScheme()
+    {
+        var sut = CreateSut((SourceScheme, PolicyScheme));
+        var options = new AuthenticationOptions { DefaultAuthenticateScheme = "Other" };
+
+        sut.PostConfigure(Options.DefaultName, options);
+
+        Assert.Equal("Other", options.DefaultAuthenticateScheme);
+    }
+
+    [Fact]
+    public void DoesNotInventADefault_WhenNoneConfigured()
+    {
+        var sut = CreateSut((SourceScheme, PolicyScheme));
+        var options = new AuthenticationOptions();
+
+        sut.PostConfigure(Options.DefaultName, options);
+
+        Assert.Null(options.DefaultAuthenticateScheme);
+        Assert.Null(options.DefaultScheme);
+    }
+
+    [Fact]
+    public void DoesNotChange_SignInOrSignOutDefaults()
+    {
+        var sut = CreateSut((SourceScheme, PolicyScheme));
+        var options = new AuthenticationOptions
+        {
+            DefaultScheme = SourceScheme,
+            DefaultSignInScheme = SourceScheme,
+            DefaultSignOutScheme = SourceScheme,
+        };
+
+        sut.PostConfigure(Options.DefaultName, options);
+
+        Assert.Equal(PolicyScheme, options.DefaultAuthenticateScheme);
+        Assert.Equal(SourceScheme, options.DefaultSignInScheme);
+        Assert.Equal(SourceScheme, options.DefaultSignOutScheme);
+    }
+
+    [Fact]
+    public void Upgrades_ToMatchingPolicy_WithMultipleRegistrations()
+    {
+        var sut = CreateSut(("A", "A.Dbsc"), ("B", "B.Dbsc"));
+        var options = new AuthenticationOptions { DefaultAuthenticateScheme = "B" };
+
+        sut.PostConfigure(Options.DefaultName, options);
+
+        Assert.Equal("B.Dbsc", options.DefaultAuthenticateScheme);
+    }
+
+    private static PostConfigureDeviceBoundSessionAuthenticationOptions CreateSut(
+        params (string source, string policy)[] mappings)
+    {
+        var schemes = new DeviceBoundSessionSourceSchemes();
+        foreach (var (source, policy) in mappings)
+        {
+            schemes.PolicySchemes[source] = policy;
+        }
+
+        return new PostConfigureDeviceBoundSessionAuthenticationOptions(Options.Create(schemes));
+    }
+}
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionSignOutTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionSignOutTests.cs
new file mode 100644
index 000000000000..00355547363d
--- /dev/null
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionSignOutTests.cs
@@ -0,0 +1,167 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+using System.Net;
+using System.Net.Http;
+using System.Security.Claims;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+public class DeviceBoundSessionSignOutTests
+{
+    private const string SourceScheme = "Source";
+    private const string SourceCookieName = ".AspNetCore.Source";
+    private const string SessionCookieName = ".AspNetCore.Source.Dbsc.Session";
+    private const string RefreshCookieName = ".AspNetCore.Source.Dbsc.Refresh";
+    private const string RegistrationPath = "/.well-known/dbsc/registration";
+
+    [Fact]
+    public async Task Registration_MintsDerivedCookies_WithoutClearingThem()
+    {
+        using var host = await CreateHostAsync();
+        var client = host.GetTestServer().CreateClient();
+
+        var registration = await SignInAndRegisterAsync(client);
+
+        Assert.Equal(HttpStatusCode.OK, registration.StatusCode);
+
+        var session = GetSetCookie(registration, SessionCookieName);
+        var refresh = GetSetCookie(registration, RefreshCookieName);
+
+        // Registration is a cookie exchange: it must SET the derived cookies, not delete them
+        // (regression guard — the source sign-out that drops the long-lived cookie must not wipe
+        // the session/refresh cookies just minted).
+        Assert.NotNull(session);
+        Assert.NotNull(refresh);
+        Assert.False(IsDeletion(session!));
+        Assert.False(IsDeletion(refresh!));
+    }
+
+    [Fact]
+    public async Task SignOut_OfSourceScheme_ClearsDerivedSessionAndRefreshCookies()
+    {
+        using var host = await CreateHostAsync();
+        var client = host.GetTestServer().CreateClient();
+
+        var registration = await SignInAndRegisterAsync(client);
+        registration.EnsureSuccessStatusCode();
+
+        var sessionPair = CookiePair(GetSetCookie(registration, SessionCookieName)!);
+        var refreshPair = CookiePair(GetSetCookie(registration, RefreshCookieName)!);
+
+        var signOut = new HttpRequestMessage(HttpMethod.Get, "/signout");
+        signOut.Headers.TryAddWithoutValidation("Cookie", $"{sessionPair}; {refreshPair}");
+        var signOutResponse = await client.SendAsync(signOut);
+
+        var sessionDeletion = GetSetCookie(signOutResponse, SessionCookieName);
+        var refreshDeletion = GetSetCookie(signOutResponse, RefreshCookieName);
+
+        // Signing out only the source scheme must also clear the DBSC-derived cookies, so the
+        // application never needs to know the derived scheme names to log the user out.
+        Assert.NotNull(sessionDeletion);
+        Assert.NotNull(refreshDeletion);
+        Assert.True(IsDeletion(sessionDeletion!));
+        Assert.True(IsDeletion(refreshDeletion!));
+    }
+
+    private static async Task SignInAndRegisterAsync(HttpClient client)
+    {
+        var signIn = await client.GetAsync("/signin");
+        signIn.EnsureSuccessStatusCode();
+
+        var sourceCookie = GetSetCookie(signIn, SourceCookieName);
+        Assert.NotNull(sourceCookie);
+        var challenge = ParseChallenge(signIn);
+
+        var proof = DbscProofKey.CreateEs256().CreateProof(challenge);
+        var register = new HttpRequestMessage(HttpMethod.Post, RegistrationPath);
+        register.Headers.TryAddWithoutValidation("Cookie", CookiePair(sourceCookie!));
+        register.Headers.TryAddWithoutValidation(DeviceBoundSessionConstants.Headers.Proof, proof);
+        return await client.SendAsync(register);
+    }
+
+    private static async Task CreateHostAsync()
+    {
+        var host = new HostBuilder()
+            .ConfigureWebHost(webBuilder =>
+            {
+                webBuilder
+                    .UseTestServer()
+                    .ConfigureServices(services =>
+                    {
+                        services.AddRouting();
+                        services.AddDataProtection();
+                        services.AddAuthentication(SourceScheme)
+                            .AddCookie(SourceScheme, o => o.Cookie.Name = SourceCookieName)
+                            .AddDeviceBoundSession(SourceScheme);
+                    })
+                    .Configure(app =>
+                    {
+                        app.UseRouting();
+                        app.UseAuthentication();
+                        app.UseEndpoints(endpoints =>
+                        {
+                            endpoints.MapGet("/signin", async context =>
+                            {
+                                var identity = new ClaimsIdentity(SourceScheme);
+                                identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "alice"));
+                                await context.SignInAsync(SourceScheme, new ClaimsPrincipal(identity));
+                            });
+
+                            endpoints.MapGet("/signout", context => context.SignOutAsync(SourceScheme));
+                        });
+                    });
+            })
+            .Build();
+
+        await host.StartAsync();
+        return host;
+    }
+
+    private static string? GetSetCookie(HttpResponseMessage response, string cookieName)
+    {
+        if (!response.Headers.TryGetValues("Set-Cookie", out var values))
+        {
+            return null;
+        }
+
+        foreach (var value in values)
+        {
+            if (value.StartsWith(cookieName + "=", StringComparison.Ordinal))
+            {
+                return value;
+            }
+        }
+
+        return null;
+    }
+
+    private static string CookiePair(string setCookie)
+    {
+        var semicolon = setCookie.IndexOf(';');
+        return semicolon < 0 ? setCookie : setCookie[..semicolon];
+    }
+
+    private static bool IsDeletion(string setCookie)
+        => setCookie.Contains("expires=Thu, 01 Jan 1970", StringComparison.OrdinalIgnoreCase);
+
+    private static string ParseChallenge(HttpResponseMessage response)
+    {
+        var header = Assert.Single(response.Headers.GetValues(DeviceBoundSessionConstants.Headers.Registration));
+        var match = Regex.Match(header, "challenge=\"([^\"]+)\"");
+        Assert.True(match.Success, $"No challenge found in registration header: {header}");
+        return match.Groups[1].Value;
+    }
+}

From 785f0330681fa8b9d5567ca2b5224290b8408591 Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Fri, 10 Jul 2026 01:11:43 +0200
Subject: [PATCH 31/33] feat(dbsc): add continue and allowed_refresh_initiators
 to session instruction
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The JSON session instruction format (W3C DBSC §9.6) defines two root
fields that were missing from SessionInstruction:

- continue (boolean, default true) — lets registration/refresh terminate
  a session by returning false. Added and always emitted.
- allowed_refresh_initiators (list of strings) — limits which out-of-scope
  hosts may initiate refreshes. The DeviceBoundSessionOptions setting of the
  same name was never flowed into the JSON; BuildSessionInstruction now
  populates it (omitted when empty).

Add serialization and end-to-end tests confirming the option flows into the
registration response and that continue is emitted.

🤖 Generated by Copilot
---
 .../src/DeviceBoundSessionHandler.cs          |   7 +
 .../src/PublicAPI.Unshipped.txt               |   4 +
 .../src/SessionInstruction.cs                 |  16 ++
 .../DeviceBoundSessionInstructionTests.cs     | 160 ++++++++++++++++++
 4 files changed, 187 insertions(+)
 create mode 100644 src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionInstructionTests.cs

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
index 843eb3a59e98..b275ab0d717e 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -304,6 +304,12 @@ private SessionInstruction BuildSessionInstruction(string sessionId)
             ?? throw new InvalidOperationException(
                 $"The session cookie scheme '{Options.SessionScheme}' has no configured cookie name; cannot build a valid DBSC session instruction.");
 
+        List? allowedRefreshInitiators = null;
+        if (Options.AllowedRefreshInitiators.Count > 0)
+        {
+            allowedRefreshInitiators = [.. Options.AllowedRefreshInitiators];
+        }
+
         return new SessionInstruction
         {
             SessionIdentifier = sessionId,
@@ -322,6 +328,7 @@ private SessionInstruction BuildSessionInstruction(string sessionId)
                     Attributes = BuildCredentialAttributes(sessionCookieOptions),
                 }
             },
+            AllowedRefreshInitiators = allowedRefreshInitiators,
         };
     }
 
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
index f97d27992632..75eae16ca148 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt
@@ -45,6 +45,10 @@ Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Name.s
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.SessionCredential() -> void
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Type.get -> string!
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.AllowedRefreshInitiators.get -> System.Collections.Generic.List?
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.AllowedRefreshInitiators.set -> void
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Continue.get -> bool
+Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Continue.set -> void
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Credentials.get -> System.Collections.Generic.List?
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Credentials.set -> void
 Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.RefreshUrl.get -> string?
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs
index f218fc1bc4e1..6b6be401c88f 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs
@@ -26,6 +26,14 @@ public sealed class SessionInstruction
     [JsonPropertyName("refresh_url")]
     public string? RefreshUrl { get; set; }
 
+    /// 
+    /// Gets or sets a value indicating whether the session should continue to apply. Registration and
+    /// refresh endpoints can set this to  to terminate the session. Defaults to
+    /// .
+    /// 
+    [JsonPropertyName("continue")]
+    public bool Continue { get; set; } = true;
+
     /// 
     /// Gets or sets the session scope.
     /// 
@@ -37,4 +45,12 @@ public sealed class SessionInstruction
     /// 
     [JsonPropertyName("credentials")]
     public List? Credentials { get; set; }
+
+    /// 
+    /// Gets or sets the list of out-of-scope hosts allowed to initiate DBSC refreshes. When
+    ///  or empty the field is omitted and the browser uses its default (an empty
+    /// list). See W3C Device Bound Session Credentials §8.3.
+    /// 
+    [JsonPropertyName("allowed_refresh_initiators")]
+    public List? AllowedRefreshInitiators { get; set; }
 }
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionInstructionTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionInstructionTests.cs
new file mode 100644
index 000000000000..156b97307711
--- /dev/null
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionInstructionTests.cs
@@ -0,0 +1,160 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+
+using System.Net.Http;
+using System.Security.Claims;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
+
+public class DeviceBoundSessionInstructionTests
+{
+    private const string SourceScheme = "Source";
+    private const string SourceCookieName = ".AspNetCore.Source";
+    private const string RegistrationPath = "/.well-known/dbsc/registration";
+
+    [Fact]
+    public void Continue_DefaultsToTrue_AndIsAlwaysSerialized()
+    {
+        var json = Serialize(new SessionInstruction { SessionIdentifier = "id" });
+
+        Assert.Contains("\"continue\":true", json);
+    }
+
+    [Fact]
+    public void AllowedRefreshInitiators_AreSerialized_WhenSet()
+    {
+        var json = Serialize(new SessionInstruction
+        {
+            SessionIdentifier = "id",
+            AllowedRefreshInitiators = ["example.com", "*.example.com"],
+        });
+
+        Assert.Contains("\"allowed_refresh_initiators\":[\"example.com\",\"*.example.com\"]", json);
+    }
+
+    [Fact]
+    public void AllowedRefreshInitiators_AreOmitted_WhenNull()
+    {
+        var json = Serialize(new SessionInstruction { SessionIdentifier = "id" });
+
+        Assert.DoesNotContain("allowed_refresh_initiators", json);
+    }
+
+    [Fact]
+    public async Task Registration_FlowsAllowedRefreshInitiators_FromOptions()
+    {
+        using var host = await CreateHostAsync(o =>
+        {
+            o.AllowedRefreshInitiators.Add("example.com");
+            o.AllowedRefreshInitiators.Add("*.example.com");
+        });
+        var client = host.GetTestServer().CreateClient();
+
+        var body = await SignInRegisterAndReadBodyAsync(client);
+
+        // Setting the option must actually flow into the session instruction JSON.
+        Assert.Contains("\"allowed_refresh_initiators\":[\"example.com\",\"*.example.com\"]", body);
+        Assert.Contains("\"continue\":true", body);
+    }
+
+    [Fact]
+    public async Task Registration_OmitsAllowedRefreshInitiators_WhenNotConfigured()
+    {
+        using var host = await CreateHostAsync();
+        var client = host.GetTestServer().CreateClient();
+
+        var body = await SignInRegisterAndReadBodyAsync(client);
+
+        Assert.DoesNotContain("allowed_refresh_initiators", body);
+    }
+
+    private static string Serialize(SessionInstruction instruction)
+        => JsonSerializer.Serialize(instruction, DeviceBoundSessionJsonContext.Default.SessionInstruction);
+
+    private static async Task SignInRegisterAndReadBodyAsync(HttpClient client)
+    {
+        var signIn = await client.GetAsync("/signin");
+        signIn.EnsureSuccessStatusCode();
+
+        var sourceCookie = Assert.Single(signIn.Headers.GetValues("Set-Cookie"),
+            v => v.StartsWith(SourceCookieName + "=", StringComparison.Ordinal));
+        var challenge = ParseChallenge(signIn);
+
+        var proof = DbscProofKey.CreateEs256().CreateProof(challenge);
+        var register = new HttpRequestMessage(HttpMethod.Post, RegistrationPath);
+        register.Headers.TryAddWithoutValidation("Cookie", CookiePair(sourceCookie));
+        register.Headers.TryAddWithoutValidation(DeviceBoundSessionConstants.Headers.Proof, proof);
+        var response = await client.SendAsync(register);
+        response.EnsureSuccessStatusCode();
+        return await response.Content.ReadAsStringAsync();
+    }
+
+    private static async Task CreateHostAsync(Action? configureDbsc = null)
+    {
+        var host = new HostBuilder()
+            .ConfigureWebHost(webBuilder =>
+            {
+                webBuilder
+                    .UseTestServer()
+                    .ConfigureServices(services =>
+                    {
+                        services.AddRouting();
+                        services.AddDataProtection();
+                        var builder = services.AddAuthentication(SourceScheme)
+                            .AddCookie(SourceScheme, o => o.Cookie.Name = SourceCookieName);
+                        if (configureDbsc is null)
+                        {
+                            builder.AddDeviceBoundSession(SourceScheme);
+                        }
+                        else
+                        {
+                            builder.AddDeviceBoundSession(SourceScheme, configureDbsc);
+                        }
+                    })
+                    .Configure(app =>
+                    {
+                        app.UseRouting();
+                        app.UseAuthentication();
+                        app.UseEndpoints(endpoints =>
+                        {
+                            endpoints.MapGet("/signin", async context =>
+                            {
+                                var identity = new ClaimsIdentity(SourceScheme);
+                                identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "alice"));
+                                await context.SignInAsync(SourceScheme, new ClaimsPrincipal(identity));
+                            });
+                        });
+                    });
+            })
+            .Build();
+
+        await host.StartAsync();
+        return host;
+    }
+
+    private static string CookiePair(string setCookie)
+    {
+        var semicolon = setCookie.IndexOf(';');
+        return semicolon < 0 ? setCookie : setCookie[..semicolon];
+    }
+
+    private static string ParseChallenge(HttpResponseMessage response)
+    {
+        var header = Assert.Single(response.Headers.GetValues(DeviceBoundSessionConstants.Headers.Registration));
+        var match = Regex.Match(header, "challenge=\"([^\"]+)\"");
+        Assert.True(match.Success, $"No challenge found in registration header: {header}");
+        return match.Groups[1].Value;
+    }
+}

From ff0d4b2be64989326a114c261cda5be32dee38ea Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Fri, 10 Jul 2026 01:52:49 +0200
Subject: [PATCH 32/33] fix(dbsc): support a non-root path base end-to-end
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

An application mounted under a non-root path base (e.g. "/foo") advertised
and scoped its DBSC endpoints at the origin root, so registration and refresh
broke. Thread the request path base through every path DBSC emits:

- Registration header path and the session instruction refresh_url now include
  Request.PathBase.
- The refresh cookie is scoped with a RequestPathBaseCookieBuilder (AdditionalPath
  = the DBSC endpoints directory) so its path becomes "{path base}/.well-known/dbsc"
  per request, matching the advertised refresh endpoint.
- The credential attributes are derived from the built session cookie so the
  advertised Path (and Secure) exactly match the cookie we emit; otherwise the
  browser would treat the bound cookie as missing under a path base and loop.

Add an end-to-end test that registers under UsePathBase("/foo") and asserts the
registration header path, refresh_url, refresh/session cookie paths, and the
credential attributes path all carry the path base.

🤖 Generated by Copilot
---
 .../src/DeviceBoundSessionHandler.cs          | 21 +++------
 .../DeviceBoundSessionRegistrationHeader.cs   |  6 ++-
 ...eDeviceBoundSessionDerivedCookieOptions.cs | 39 ++++++++++++++--
 ...DeviceBoundSessionCookieProtectionTests.cs |  3 +-
 .../DeviceBoundSessionInstructionTests.cs     | 46 ++++++++++++++++++-
 5 files changed, 92 insertions(+), 23 deletions(-)

diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
index b275ab0d717e..2fa9227a2ff8 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs
@@ -313,7 +313,7 @@ private SessionInstruction BuildSessionInstruction(string sessionId)
         return new SessionInstruction
         {
             SessionIdentifier = sessionId,
-            RefreshUrl = Options.RefreshPath.Value,
+            RefreshUrl = Request.PathBase.Add(Options.RefreshPath).Value,
             Scope = new SessionScope
             {
                 Origin = origin,
@@ -345,20 +345,12 @@ private CookieAuthenticationOptions ResolveSessionCookieOptions()
 
     private string BuildCredentialAttributes(CookieAuthenticationOptions cookieOptions)
     {
-        // Keep the credential's attributes aligned with the actual session cookie configuration so the
-        // browser interprets the session instruction the same way it treats the cookie we emit.
-        var cookie = cookieOptions.Cookie;
-
-        var secure = cookie.SecurePolicy switch
-        {
-            CookieSecurePolicy.Always => true,
-            CookieSecurePolicy.None => false,
-            // SameAsRequest (the cookie-auth default) follows the request scheme.
-            _ => Request.IsHttps,
-        };
+        // Build the cookie exactly as the session cookie handler will, so the advertised attributes —
+        // including the path, which the request path base is applied to — match the cookie we emit.
+        var cookie = cookieOptions.Cookie.Build(Context);
 
         var attributes = new List();
-        if (secure)
+        if (cookie.Secure)
         {
             attributes.Add("Secure");
         }
@@ -380,8 +372,7 @@ private string BuildCredentialAttributes(CookieAuthenticationOptions cookieOptio
                 break;
         }
 
-        var path = cookie.Path;
-        attributes.Add($"Path={(string.IsNullOrEmpty(path) ? "/" : path)}");
+        attributes.Add($"Path={(string.IsNullOrEmpty(cookie.Path) ? "/" : cookie.Path)}");
 
         if (!string.IsNullOrEmpty(cookie.Domain))
         {
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionRegistrationHeader.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionRegistrationHeader.cs
index ee0d018f3be8..7d9ef453fbdb 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionRegistrationHeader.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionRegistrationHeader.cs
@@ -44,7 +44,11 @@ public static void Emit(HttpContext httpContext, ClaimsPrincipal? principal, str
         var effectivePrincipal = principal ?? new ClaimsPrincipal();
         var challenge = challengeProtector.GenerateRegistrationChallenge(effectivePrincipal, dbscOptions.ChallengeMaxAge);
 
-        var headerValue = $"{DeviceBoundSessionConstants.AdvertisedAlgorithms};path=\"{dbscOptions.RegistrationPath.Value}\";challenge=\"{challenge}\"";
+        // Advertise the registration endpoint relative to the application's path base so an app mounted
+        // under a non-root path base (e.g. "/foo") tells the browser to POST to "/foo/.well-known/dbsc/..."
+        // rather than the origin root.
+        var registrationPath = httpContext.Request.PathBase.Add(dbscOptions.RegistrationPath);
+        var headerValue = $"{DeviceBoundSessionConstants.AdvertisedAlgorithms};path=\"{registrationPath.Value}\";challenge=\"{challenge}\"";
         httpContext.Response.Headers.Append(DeviceBoundSessionConstants.Headers.Registration, headerValue);
     }
 }
diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
index dbdf89049e44..54475f4b1177 100644
--- a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
+++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
@@ -4,6 +4,7 @@
 #pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
 
 using Microsoft.AspNetCore.Authentication.Cookies;
+using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Options;
 
@@ -35,11 +36,11 @@ public void PostConfigure(string? name, CookieAuthenticationOptions options)
         if (schemes.RefreshSchemes.TryGetValue(name, out var refreshSourceScheme))
         {
             CopyFromSource(refreshSourceScheme, options);
-            // Override: scope the refresh cookie to the DBSC endpoints' directory, derived from the
-            // configured RefreshPath so a customized RefreshPath still receives the cookie. Lifetime and
-            // sliding behavior are inherited from the source scheme so the refresh cookie ages exactly
-            // like the auth cookie it replaces (renewed on each refresh when the source uses sliding).
-            options.Cookie.Path = ResolveRefreshCookiePath(refreshSourceScheme);
+            // Scope the refresh cookie to the DBSC endpoints' directory, applied relative to the request
+            // path base so an app mounted under a non-root path base still receives the cookie at its
+            // advertised refresh endpoint. Lifetime and sliding behavior are inherited from the source
+            // scheme so the refresh cookie ages exactly like the auth cookie it replaces.
+            options.Cookie = CreatePathBaseScopedCookie(options.Cookie, ResolveRefreshCookiePath(refreshSourceScheme));
         }
         else if (schemes.SessionSchemes.TryGetValue(name, out var sessionSourceScheme))
         {
@@ -85,4 +86,32 @@ private void CopyFromSource(string sourceScheme, CookieAuthenticationOptions tar
         target.ExpireTimeSpan = source.ExpireTimeSpan;
         target.SlidingExpiration = source.SlidingExpiration;
     }
+
+    // Rebuilds the cookie as a path-base-aware builder so the cookie path becomes
+    // "{request path base}{additionalPath}" at emit time, instead of a fixed absolute path.
+    private static CookieBuilder CreatePathBaseScopedCookie(CookieBuilder source, string additionalPath)
+        => new PathBaseScopedCookieBuilder(additionalPath)
+        {
+            Name = source.Name,
+            HttpOnly = source.HttpOnly,
+            SecurePolicy = source.SecurePolicy,
+            SameSite = source.SameSite,
+            Domain = source.Domain,
+            IsEssential = source.IsEssential,
+            MaxAge = source.MaxAge,
+            Expiration = source.Expiration,
+            // Path is intentionally left unset so the builder derives (path base + additional path) per request.
+        };
+
+    private sealed class PathBaseScopedCookieBuilder : RequestPathBaseCookieBuilder
+    {
+        private readonly string _additionalPath;
+
+        public PathBaseScopedCookieBuilder(string additionalPath)
+        {
+            _additionalPath = additionalPath;
+        }
+
+        protected override string AdditionalPath => _additionalPath;
+    }
 }
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs
index 3bd906b8bb1d..623581327a97 100644
--- a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs
@@ -7,6 +7,7 @@
 using System.Security.Claims;
 using Microsoft.AspNetCore.Authentication.Cookies;
 using Microsoft.AspNetCore.DataProtection;
+using Microsoft.AspNetCore.Http;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Options;
 using Xunit;
@@ -34,7 +35,7 @@ public void PostConfigure_PreservesExistingTicketDataFormat_ForRefreshScheme()
         Assert.Same(sentinel, options.TicketDataFormat);
         // ...but the post-configure still ran (copied source lifetime, applied refresh path scope, kept the name).
         Assert.Equal(TimeSpan.FromHours(3), options.ExpireTimeSpan);
-        Assert.Equal("/.well-known/dbsc", options.Cookie.Path);
+        Assert.Equal("/.well-known/dbsc", options.Cookie.Build(new DefaultHttpContext()).Path);
         Assert.Equal(".AspNetCore." + RefreshScheme, options.Cookie.Name);
     }
 
diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionInstructionTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionInstructionTests.cs
index 156b97307711..5463d64bbd24 100644
--- a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionInstructionTests.cs
+++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionInstructionTests.cs
@@ -80,6 +80,45 @@ public async Task Registration_OmitsAllowedRefreshInitiators_WhenNotConfigured()
         Assert.DoesNotContain("allowed_refresh_initiators", body);
     }
 
+    [Fact]
+    public async Task Registration_AdvertisesRegistrationPathAndRefreshUrl_WithPathBase()
+    {
+        using var host = await CreateHostAsync(pathBase: "/foo");
+        var client = host.GetTestServer().CreateClient();
+
+        // The registration header advertised on sign-in must include the path base.
+        var signIn = await client.GetAsync("/foo/signin");
+        signIn.EnsureSuccessStatusCode();
+        var registrationHeader = Assert.Single(
+            signIn.Headers.GetValues(DeviceBoundSessionConstants.Headers.Registration));
+        Assert.Contains("path=\"/foo/.well-known/dbsc/registration\"", registrationHeader);
+
+        // Completing registration returns a refresh_url that also includes the path base.
+        var sourceCookie = Assert.Single(signIn.Headers.GetValues("Set-Cookie"),
+            v => v.StartsWith(SourceCookieName + "=", StringComparison.Ordinal));
+        var challenge = ParseChallenge(signIn);
+        var proof = DbscProofKey.CreateEs256().CreateProof(challenge);
+        var register = new HttpRequestMessage(HttpMethod.Post, "/foo" + RegistrationPath);
+        register.Headers.TryAddWithoutValidation("Cookie", CookiePair(sourceCookie));
+        register.Headers.TryAddWithoutValidation(DeviceBoundSessionConstants.Headers.Proof, proof);
+        var response = await client.SendAsync(register);
+        response.EnsureSuccessStatusCode();
+        var body = await response.Content.ReadAsStringAsync();
+
+        Assert.Contains("\"refresh_url\":\"/foo/.well-known/dbsc/refresh\"", body);
+
+        // The refresh cookie is path-scoped under the path base so the browser sends it to the refresh endpoint.
+        var refreshSetCookie = Assert.Single(response.Headers.GetValues("Set-Cookie"),
+            v => v.StartsWith(".AspNetCore.Source.Dbsc.Refresh=", StringComparison.Ordinal));
+        Assert.Contains("path=/foo/.well-known/dbsc", refreshSetCookie);
+
+        // The session cookie and the advertised credential attributes both carry the path base.
+        var sessionSetCookie = Assert.Single(response.Headers.GetValues("Set-Cookie"),
+            v => v.StartsWith(".AspNetCore.Source.Dbsc.Session=", StringComparison.Ordinal));
+        Assert.Contains("path=/foo", sessionSetCookie);
+        Assert.Contains("Path=/foo\"", body);
+    }
+
     private static string Serialize(SessionInstruction instruction)
         => JsonSerializer.Serialize(instruction, DeviceBoundSessionJsonContext.Default.SessionInstruction);
 
@@ -101,7 +140,7 @@ private static async Task SignInRegisterAndReadBodyAsync(HttpClient clie
         return await response.Content.ReadAsStringAsync();
     }
 
-    private static async Task CreateHostAsync(Action? configureDbsc = null)
+    private static async Task CreateHostAsync(Action? configureDbsc = null, string? pathBase = null)
     {
         var host = new HostBuilder()
             .ConfigureWebHost(webBuilder =>
@@ -125,6 +164,11 @@ private static async Task CreateHostAsync(Action
                     {
+                        if (pathBase is not null)
+                        {
+                            app.UsePathBase(pathBase);
+                        }
+
                         app.UseRouting();
                         app.UseAuthentication();
                         app.UseEndpoints(endpoints =>

From 4772d25c7cdf05ec2765d125db887467c4fa2091 Mon Sep 17 00:00:00 2001
From: Roman Konecny 
Date: Mon, 13 Jul 2026 17:14:22 +0200
Subject: [PATCH 33/33] Delete dbsc-design.md

It existed only for review and design purposes. Not needed anymore.
---
 .../DeviceBoundSessions/plans/dbsc-design.md  | 331 ------------------
 1 file changed, 331 deletions(-)
 delete mode 100644 src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md

diff --git a/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md b/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
deleted file mode 100644
index 28c47904ec3a..000000000000
--- a/src/Security/Authentication/DeviceBoundSessions/plans/dbsc-design.md
+++ /dev/null
@@ -1,331 +0,0 @@
-# Device Bound Session Credentials (DBSC) for ASP.NET Core
-
-## Summary
-
-Add support for [Device Bound Session Credentials (DBSC)](https://w3c.github.io/webappsec-dbsc/) to ASP.NET Core's authentication system. DBSC binds session cookies to a device using TPM-backed cryptographic keys, preventing session hijacking even if cookies are exfiltrated by malware.
-
-The implementation introduces a new `Microsoft.AspNetCore.Authentication.DeviceBoundSessions` NuGet package with a dedicated authentication handler that manages the DBSC protocol (registration, refresh, key proof validation) and delegates cookie management to existing cookie authentication handlers — following the same architectural pattern as OpenID Connect.
-
-> **Status: experimental prototype.** The entire public API surface is gated behind the analyzer diagnostic `ASP0030` (`[Experimental("ASP0030")]`); consumers must explicitly opt in. The DBSC wire protocol is a W3C **Editor's Draft** that changes frequently (we have already absorbed breaking changes such as header renames and a 401→403 re-challenge switch) and ships single-vendor (Chromium) behind a flag. Both the protocol and this API are expected to change.
-
-## Motivation
-
-Cookie-based sessions are the most common authentication mechanism on the web. Their primary vulnerability is **cookie theft** — malware with access to the file system or browser memory can exfiltrate cookies and use them on another device.
-
-DBSC solves this by introducing a cryptographic key pair per session, where the private key is stored in secure hardware (TPM). The server issues short-lived cookies that must be periodically refreshed by proving possession of the private key. Even if the cookie is stolen, the attacker cannot refresh it without the TPM-bound key.
-
-Chrome ships DBSC behind a flag (`chrome://flags/#enable-standard-device-bound-session-credentials`). This proposal integrates DBSC into ASP.NET Core so that applications can opt in with minimal code changes.
-
-## Goals
-
-- Provide a DBSC authentication handler that manages the registration/refresh protocol
-- Follow the established pattern of remote authentication handlers (like OpenID Connect) that delegate cookie management to a separate cookie scheme
-- Support fully stateless operation (no server-side session store required)
-- Ensure non-DBSC browsers continue to work normally (graceful degradation)
-- Make integration with ASP.NET Core Identity straightforward
-- Ship as a standalone NuGet package (not part of the shared framework), same model as JwtBearer
-
-## Non-goals
-
-- **Federated DBSC sessions** (cross-site key sharing via Session Provider) — deferred to a future version
-- **Server-side session revocation store** — optional, not required for baseline operation
-- **Custom signing algorithms beyond ES256/RS256** — only the algorithms Chrome currently supports
-
-## Detailed Design
-
-### Package and Dependencies
-
-- **Package**: `Microsoft.AspNetCore.Authentication.DeviceBoundSessions`
-- **Ships as NuGet package** (not shared framework) — same model as `Microsoft.AspNetCore.Authentication.JwtBearer`
-- **Dependencies**: `Microsoft.IdentityModel.Protocols.OpenIdConnect` (for JWT validation, same package JwtBearer uses), `System.Formats.Cbor` (for challenge payload encoding)
-
-### Architectural Parallel with OpenID Connect
-
-The DBSC handler follows the same delegation pattern as OpenID Connect:
-
-| Concept | OpenID Connect | DBSC |
-|---------|---------------|------|
-| Protocol handler | `OpenIdConnectHandler` | `DeviceBoundSessionHandler` |
-| Protocol dance | Authorization code flow | Registration + JWT proof |
-| Credential stamping | `SignInAsync(SignInScheme)` | `SignInAsync(SessionScheme)` |
-| Cookie storage | Cookie handler for `.AspNetCore.Cookies` | Cookie handler for `.Session` cookie |
-| Trigger | HTTP redirect to IdP | Browser-initiated POST after seeing `Secure-Session-Registration` header |
-| Package model | Standalone NuGet | Standalone NuGet |
-
-**Why a separate handler instead of embedding in cookie middleware:**
-
-1. **Separation of concerns** — Cookie auth handles reading/writing cookies. DBSC handles a JWT-based cryptographic protocol.
-2. **JWT dependency** — The cookie authentication package has zero JWT dependencies today. DBSC requires JWT parsing and signature validation (ES256/RS256) via Microsoft.IdentityModel.
-3. **Independent evolution** — The DBSC spec is still a W3C draft. A separate handler can evolve without affecting the stable cookie auth package.
-4. **Composability** — The handler can delegate to any cookie scheme, just like OIDC can sign into any cookie scheme.
-5. **Testability** — The protocol logic can be tested independently from cookie management.
-
-### Scheme Architecture
-
-```mermaid
-graph TD
-    subgraph "Authentication Schemes"
-        PS["Policy Scheme
(Default Authenticate)
{Source}.Dbsc"] - IS["Source Scheme
(Cookie Handler)
{Source}"] - RS["Refresh Scheme
(Cookie Handler)
{Source}.Dbsc.Refresh"] - SS["Session Scheme
(Cookie Handler)
{Source}.Dbsc.Session"] - DH["DBSC Handler
(Protocol Handler)
DeviceBoundSession"] - end - - PS -->|"Try first"| SS - PS -->|"Fallback"| IS - - DH -->|"Registration: reads from"| IS - DH -->|"Registration: stamps"| RS - DH -->|"Registration: stamps"| SS - DH -->|"Registration: deletes"| IS - DH -->|"Refresh: reads from"| RS - DH -->|"Refresh: stamps"| SS -``` - -| Scheme | Cookie Name | Path | Lifetime | Role | -|--------|-------------|------|----------|------| -| `{Source}` (e.g. `Identity.Application`) | `.AspNetCore.{Source}` | `/` | Long (days) | Initial sign-in. Deleted after DBSC registration. | -| `{Source}.Dbsc.Refresh` | `.AspNetCore.{Source}.Dbsc.Refresh` | `/.well-known/dbsc/` | Slides with source | Stash — holds ticket + public key. Only sent to refresh endpoint. | -| `{Source}.Dbsc.Session` | `.AspNetCore.{Source}.Dbsc.Session` | `/` | Short (10min default) | Active session cookie. Used for authentication on all requests. | -| `{Source}.Dbsc` (Policy) | — | — | — | Routes authentication: tries `.Session` first, falls back to `{Source}`. | - -The refresh and session cookie schemes **inherit settings** (HttpOnly, Secure, SameSite, Domain, lifetime, and sliding behavior) from the source cookie scheme via `IPostConfigureOptions`. - -### Protocol Flow - -```mermaid -sequenceDiagram - participant User - participant Browser as Chrome (DBSC) - participant App as ASP.NET Core - participant Source as Source Scheme
(Cookie Handler) - participant DBSC as DBSC Handler - participant Refresh as Refresh Scheme
(Cookie Handler) - participant Session as Session Scheme
(Cookie Handler) - - User->>Browser: Submit login form - Browser->>App: POST /login - App->>Source: SignInAsync(principal) - Source-->>Browser: Set-Cookie (long-lived)
Secure-Session-Registration header - Browser->>Browser: Generate key pair (TPM) - - Note over Browser,App: Registration (async, non-blocking) - Browser->>App: POST /.well-known/dbsc/registration
Header: Secure-Session-Response (JWT with public key) - App->>DBSC: HandleRegistration - DBSC->>Source: AuthenticateAsync() → read long-lived cookie - DBSC->>DBSC: Validate JWT, validate challenge claim UID - DBSC->>Refresh: SignInAsync(principal + public key) → stamp refresh cookie - DBSC->>Session: SignInAsync(principal) → stamp session cookie - DBSC->>Source: SignOutAsync() → delete long-lived cookie - DBSC-->>Browser: 200 + JSON session config + Secure-Session-Challenge - - Note over Browser,App: Normal requests (session cookie only) - Browser->>App: GET /api/data
Cookie: .{Source}.Dbsc.Session - App->>Session: AuthenticateAsync() → read session cookie - Session-->>App: Principal ✓ - - Note over Browser,App: Refresh (when session cookie expires) - Browser->>App: POST /.well-known/dbsc/refresh
Header: Sec-Secure-Session-Id - App->>DBSC: HandleRefresh (no proof yet) - DBSC->>Refresh: AuthenticateAsync() → read refresh cookie - DBSC-->>Browser: 403 + Secure-Session-Challenge - - Browser->>Browser: Sign challenge with TPM key - Browser->>App: POST /.well-known/dbsc/refresh
Headers: Sec-Secure-Session-Id + Secure-Session-Response - App->>DBSC: HandleRefresh (with proof) - DBSC->>Refresh: AuthenticateAsync() → get public key - DBSC->>DBSC: Validate JWT, validate challenge claim UID + session ID - DBSC->>Session: SignInAsync(principal) → stamp fresh session cookie - DBSC-->>Browser: 200 + JSON config + Secure-Session-Challenge -``` - -### Challenge Design - -Challenges are time-limited, data-protected payloads that bind to the user identity: - -**Registration challenge** (emitted at sign-in, validated at registration): -``` -registrationProtector.Protect(CBOR(claimUid), lifetime=5min) -``` - -**Refresh challenge** (emitted at registration/refresh, validated at next refresh): -``` -refreshProtector.Protect(CBOR(claimUid, sessionId), lifetime=5min) -``` - -- **No nonce needed** — `ITimeLimitedDataProtector` provides uniqueness via its embedded timestamp + MAC -- **Claim UID** follows antiforgery priority: `sub` > `ClaimTypes.NameIdentifier` > `ClaimTypes.Upn` > SHA256(all claims) -- **Claim UID encoding** uses CBOR (claim type, value, issuer as text strings) -- **CBOR payload** is a bare sequence of text strings (no array/map framing) -- **5-minute expiry** (`ChallengeMaxAge`) — time-limited protector rejects expired challenges automatically -- **Domain separation** — registration and refresh challenges are protected under **distinct data-protection purposes** (`...Challenge.Registration.v1` and `...Challenge.Refresh.v1`). A challenge minted for one flow can never be decrypted (let alone confused) as the other, so cross-type challenge confusion is cryptographically impossible. - -The `DeviceBoundSessionChallengeProtector` holds two `ITimeLimitedDataProtector` instances (one per purpose) and a logger; every validation failure is logged at `Debug` with its specific reason (undecryptable / malformed / principal-mismatch / session-mismatch), never logging the payload. - -### JWT Validation - -JWT validation uses `Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler` (same library as JwtBearer): -- Parse JWT via `new JsonWebToken(tokenString)` -- Validate `typ` header is `"dbsc+jwt"` -- Extract `jwk` from JWT header (registration) or from refresh cookie properties (refresh) -- Construct `ECDsaSecurityKey` (ES256) or `RsaSecurityKey` (RS256) from JWK -- Validate signature via `JsonWebTokenHandler.ValidateTokenAsync()` with appropriate `TokenValidationParameters` - -`DeviceBoundSessionJwtValidator` is an instance service with a logger. Each rejection path logs its specific reason at `Debug` (malformed token, wrong `typ`, missing algorithm, missing/unsupported key, invalid signature, challenge mismatch) without logging the proof itself. - -### Diagnostics and Experimental Status - -Every public type in the package carries `[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]`, matching the convention used by the validation feature. The diagnostic id `ASP0030` is reserved in `docs/list-of-diagnostics.md`. Internal consumers, the source-generated JSON context, and the sample suppress `ASP0030` where the analyzer flags same-assembly usage. Marking the API experimental forces callers to explicitly opt in, signaling that both the unstable wire protocol and the still-evolving .NET surface may change. - -### Logging - -All diagnostic logging uses the source-generated `LoggerMessage` pattern in `LoggingExtensions.cs` (`internal static partial class DeviceBoundSessionsLoggingExtensions` in `namespace Microsoft.Extensions.Logging`). Validation failures are logged at `Debug` (matching Identity's convention), while operationally notable conditions (no source authentication, missing refresh cookie, principal/session mismatch) are logged at `Warning`. Secrets — proofs, challenge payloads, keys — are never logged. - -### Integration with ASP.NET Core Identity - -```csharp -builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme) - .AddIdentityCookies() - .AddDeviceBoundSession(IdentityConstants.ApplicationScheme, o => - { - o.ShortLivedCookieExpiration = TimeSpan.FromMinutes(10); - }); -``` - -`AddDeviceBoundSession(sourceScheme)` handles everything: -- Registers refresh/session cookie schemes (inheriting source cookie settings via `IPostConfigureOptions`) -- Registers a policy scheme as `DefaultAuthenticateScheme` -- Registers the DBSC protocol handler -- Hooks `Secure-Session-Registration` header emission into the source scheme's `OnSigningIn` event via `IPostConfigureOptions` - -No manual event wiring needed by the user. - -### Cookie Settings Inheritance - -`PostConfigureDeviceBoundSessionDerivedCookieOptions` copies the following from the source cookie scheme to refresh and session schemes: -- `Cookie.HttpOnly` -- `Cookie.SecurePolicy` -- `Cookie.SameSite` -- `Cookie.Domain` -- `Cookie.IsEssential` -- `ExpireTimeSpan` -- `SlidingExpiration` - -The refresh scheme overrides `Cookie.Path = "/.well-known/dbsc"`. The session scheme's `ExpireTimeSpan` is overridden at sign-in time via `AuthenticationProperties.ExpiresUtc`, and it is deliberately **non-sliding** (re-minted fresh on every refresh). - -#### Refresh cookie lifetime and sliding - -The refresh cookie is a genuine cookie-auth scheme, so it reuses the standard `CookieAuthenticationHandler` lifetime machinery rather than inventing a parallel model: - -- It **inherits `SlidingExpiration`** from the source scheme (default `true`). -- Its lifetime is **not anchored** at registration; sign-in starts a fresh `ExpireTimeSpan` window exactly like a normal login, and each refresh request (`AuthenticateAsync` on the refresh scheme) runs the cookie handler's own sliding renewal at the 50%-of-`ExpireTimeSpan` mark. - -The result is that enabling DBSC does **not** regress an active user's session lifetime: the refresh cookie ages exactly like the auth cookie it replaces — an active user stays signed in, an inactive one expires after `ExpireTimeSpan` of inactivity. Because each refresh is cryptographically bound to the device key, sliding here is also safer than sliding a plain bearer cookie. The short-lived session cookie remains non-sliding by design. - -### Security Properties - -| Threat | Mitigation | -|--------|-----------| -| Malware steals session cookie | Expires in ≤10min. Attacker has a limited window. | -| Malware steals refresh cookie | Useless — refresh endpoint requires JWT proof signed with TPM-bound private key. | -| Malware steals both cookies | Session cookie expires quickly. Refresh cookie can't be used remotely. | -| Long-lived cookie exfiltration | Deleted after registration. Only exists briefly during sign-in → registration gap. | -| Refresh endpoint is down | User must re-login. No stealable long-lived token persists. | -| Non-DBSC browser | Falls back to long-lived cookie via policy scheme. Same security as today (no regression). | -| Challenge replay | ITimeLimitedDataProtector rejects expired challenges; claim UID binding prevents cross-user replay. | -| Cross-type challenge confusion | Registration and refresh challenges use distinct data-protection purposes, so a challenge from one flow cannot be decrypted or accepted by the other. | - -### File Structure - -``` -src/Security/Authentication/DeviceBoundSessions/src/ -├── DeviceBoundSessionChallengeProtector.cs # Generates/validates challenges (CBOR + time-limited DP, two purposes), logs failures -├── DeviceBoundSessionConstants.cs # Constants: DBSC header names + supported/advertised algorithms -├── DeviceBoundSessionCookieEvents.cs # CookieAuthenticationEvents wiring for the source scheme -├── DeviceBoundSessionDefaults.cs # Constants: scheme name, paths -├── DeviceBoundSessionExtensions.cs # AddDeviceBoundSession() extension methods -├── DeviceBoundSessionHandler.cs # Main handler: registration + refresh protocol -├── DeviceBoundSessionHttpContextExtensions.cs # WriteDeviceBoundSessionRegistration() helper -├── DeviceBoundSessionJsonContext.cs # Source-generated JSON serializer context -├── DeviceBoundSessionJwtResult.cs # JWT validation result model -├── DeviceBoundSessionJwtValidator.cs # JWT proof validation (instance + logging) via Microsoft.IdentityModel -├── DeviceBoundSessionOptions.cs # Handler options -├── DeviceBoundSessionRegistrationHeader.cs # Writes the Secure-Session-Registration header on sign-in -├── DeviceBoundSessionScopeRule.cs # Options model: scope rule -├── DeviceBoundSessionSourceSchemes.cs # Internal: tracks scheme name mappings -├── LoggingExtensions.cs # Source-generated LoggerMessage events -├── PostConfigureDeviceBoundSessionCookieOptions.cs # IPostConfigure: hooks OnSigningIn to emit registration header -├── PostConfigureDeviceBoundSessionDerivedCookieOptions.cs # Copies source cookie settings (+ sliding) to derived schemes -├── SessionInstruction.cs # JSON response model: DBSC session instruction (root) -├── SessionScope.cs # JSON response model: scope -├── SessionScopeRule.cs # JSON response model: scope rule -├── SessionCredential.cs # JSON response model: credential entry (type is fixed "cookie") -├── PublicAPI.Shipped.txt -└── PublicAPI.Unshipped.txt -``` - -The `DbscDebugServer` sample (`samples/DbscDebugServer/`) provides a live dashboard that decodes cookies, proof JWTs, and challenges (using both protector purposes) for inspecting the protocol end-to-end. - -## Risks - -- **Refresh cookie size**: Contains a full authentication ticket (~500-1500 bytes base64). Within the 4KB per-cookie limit for typical claims sets. Large claims sets may need the existing cookie chunking mechanism. -- **Chrome spec changes**: DBSC is still a W3C draft. The separate handler/package approach makes it easier to adapt. -- **TPM rate limiting**: Chrome batches/deduplicates refresh requests. With 10min cookie expiry, TPM signing happens at most once per 10min per session. - -## Drawbacks - -- Adds complexity to the authentication scheme graph (policy scheme + 3 cookie schemes + DBSC handler) -- Non-DBSC browsers retain the long-lived cookie (no security improvement for them) -- If the refresh endpoint is unavailable, DBSC users lose their session and must re-login - -## Considered Alternatives - -### Embedding DBSC in the Cookie Authentication Handler - -Rejected because: -- Adds JWT dependencies to the cookie auth package -- Mixes two distinct responsibilities (cookie management vs. cryptographic protocol) -- Makes the cookie handler harder to maintain and test -- Prevents independent evolution of DBSC support - -### Adding IdentityModel to the shared framework - -Rejected because: -- JwtBearer already ships as a standalone NuGet package with IdentityModel as a dependency -- Adding IdentityModel to the shared framework would bloat it for all apps -- The DBSC package follows the same standalone package model - -### Using the long-lived cookie for both normal requests and refresh - -Rejected because: -- If the long-lived cookie is sent on every request, stealing it defeats the purpose of DBSC -- The whole point is that the "stealable" tokens are short-lived - -### Keeping the long-lived cookie as fallback - -Rejected for the strict security mode because: -- A persistent long-lived cookie at `path=/` is exactly what DBSC is trying to eliminate -- The tradeoff (re-login on refresh failure vs. security) is acceptable - -### Stashing ticket data in the session ID or challenge header - -Rejected because: -- Both appear in HTTP headers with size constraints -- Path-scoped refresh cookie provides ample space (4KB) without header size concerns -- Cookie infrastructure (chunking, data protection) already exists in ASP.NET Core - -### Using nonce in challenges - -Rejected because: -- `ITimeLimitedDataProtector` already provides uniqueness via embedded timestamp + MAC -- Adding a nonce increases payload size without security benefit -- The data protection system's MAC prevents any form of replay or forgery - -### Anchoring the refresh cookie lifetime to the source ticket - -An earlier iteration copied the source sign-in ticket's `IssuedUtc`/`ExpiresUtc` onto the refresh cookie so the bound session could "never outlive the credential it was exchanged for." Rejected because: -- It silently **shortened** an active user's session relative to plain cookie auth (which slides by default), a behavior regression when DBSC is enabled -- It duplicated lifetime logic instead of reusing the cookie handler's proven sliding machinery - -Replaced by inheriting `SlidingExpiration`/`ExpireTimeSpan` from the source scheme and letting the refresh scheme's own `CookieAuthenticationHandler` slide it \u2014 zero divergence from the auth cookie it replaces.