Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix session fixation and session cookies in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Keep the signed-in user in an authentication cookie, not in ISession, which cannot change its ID: SignInAsync issues a new ticket at sign-in. Set Cookie.Name = "__Host-auth", SecurePolicy = Always, HttpOnly and SameSite = Lax, use ExpireTimeSpan = 30 minutes with SlidingExpiration, and reject tickets older than 8 hours in OnValidatePrincipal.

Stack
  • Language C# 14
  • Runtime .NET 10.0.12 (SDK 10.0.112, ASP.NET Core runtime 10.0.12)
  • Framework ASP.NET Core 10 minimal API with cookie authentication (fixed) or session state (vulnerable and wrong)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks5 cards
Download the project ZIP, 33 KB
On this page

Steps

  1. Sign users in with cookie authentication: AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(...), app.UseAuthentication(), and HttpContext.SignInAsync with a ClaimsPrincipal after the password check. Each sign-in writes a new encrypted ticket, so no cookie the browser held before identifies the signed-in session.
  2. Don't keep the signed-in user in session state (HttpContext.Session). ISession has no way to change its key, and Clear() empties the data but keeps the cookie. If you use it for other data, keep nothing in it that grants access.
  3. Set options.Cookie.Name = "__Host-auth", SecurePolicy = CookieSecurePolicy.Always, HttpOnly = true and SameSite = SameSiteMode.Lax, and leave Cookie.Domain unset and the path at /.
  4. Set ExpireTimeSpan to the idle limit (30 minutes here) with SlidingExpiration = true; a request in the second half of the window gets a renewed ticket.
  5. Store the sign-in time in the ticket (AuthenticationProperties.SetString) and, in Events.OnValidatePrincipal, call RejectPrincipal() and SignOutAsync() once it is older than the absolute limit (8 hours here). IssuedUtc won't do: it moves on every renewal.
  6. Share the Data Protection key ring between instances, since it encrypts the ticket.
  7. Add regression tests with WebApplicationFactory that replay the pre-login cookies after sign-in, read the Set-Cookie attributes, and register a TimeProvider test clock (cookie authentication reads TimeProvider from DI) to move time past both limits.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerablesrc/Portal/impl/VulnerableSessions.csC#
public async Task<IResult> SignInAsync(HttpContext context, string username)
{
    await context.Session.LoadAsync();
    // Mark the visitor's session as signed in. Its key, and its cookie, stay the same.
    context.Session.SetString("User", username);
    return Results.Redirect(context.Session.GetString("ReturnTo") ?? "/account");
}

With IMPL=vulnerable, 5 of 11 tests fail (5 exploit tests).

The fix: a new ticket at sign-in

Fixedsrc/Portal/impl/FixedSessions.csC#
public async Task<IResult> SignInAsync(HttpContext context, string username)
{
    var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, username) }, CookieAuthenticationDefaults.AuthenticationScheme);
    var properties = new AuthenticationProperties();
    var now = context.RequestServices.GetRequiredService<TimeProvider>().GetUtcNow();
    properties.SetString(SignedInAt, now.ToString("O", CultureInfo.InvariantCulture));
    // A new encrypted ticket in a new cookie value: no cookie the browser held before sign-in,
    // planted or not, identifies the signed-in session. Don't keep sign-in state in ISession,
    // which has no way to change its ID.
    await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity), properties);
    return Results.Redirect("/account"); // this app has a single protected page
}

With IMPL=fixed, 11 of 11 tests pass.

The fix: cookie attributes and timeouts

Fixedsrc/Portal/impl/FixedSessions.csC#
private const string SignedInAt = "signed_in_at";
private static readonly TimeSpan AbsoluteTimeout = TimeSpan.FromHours(8);

public void AddServices(IServiceCollection services) =>
    services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
        .AddCookie(options =>
        {
            options.Cookie.Name = "__Host-auth"; // browsers accept it only with Secure, Path=/ and no Domain
            options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
            options.Cookie.HttpOnly = true;
            options.Cookie.SameSite = SameSiteMode.Lax;
            // Idle timeout: the ticket expires 30 minutes after it was issued or last renewed, and a
            // request in the second half of that window renews it.
            options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
            options.SlidingExpiration = true;
            options.LoginPath = "/login";
            // Absolute timeout: sliding expiration alone keeps a busy ticket alive forever.
            options.Events.OnValidatePrincipal = async context =>
            {
                var now = context.HttpContext.RequestServices.GetRequiredService<TimeProvider>().GetUtcNow();
                var signedInAt = context.Properties.GetString(SignedInAt);
                if (signedInAt is null || now - DateTimeOffset.Parse(signedInAt, CultureInfo.InvariantCulture) >= AbsoluteTimeout)
                {
                    context.RejectPrincipal();
                    await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
                }
            };
        });

public void UseMiddleware(WebApplication app) => app.UseAuthentication();

With IMPL=fixed, 11 of 11 tests pass.

A fix that looks safe but is not

Wrong fixsrc/Portal/impl/WrongSessions.csC#
public async Task<IResult> SignInAsync(HttpContext context, string username)
{
    await context.Session.LoadAsync();
    var returnTo = context.Session.GetString("ReturnTo") ?? "/account";
    // "Start a fresh session for the signed-in user." Clear() empties the session; its key and
    // cookie stay the same.
    context.Session.Clear();
    context.Session.SetString("User", username);
    var now = context.RequestServices.GetRequiredService<TimeProvider>().GetUtcNow();
    context.Session.SetString("SignedInAt", now.ToString("O", CultureInfo.InvariantCulture));
    return Results.Redirect(returnTo);
}

With IMPL=wrong, 2 of 11 tests fail (2 exploit tests).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/Portal.Tests/ExploitTests.csC#
/// <summary>
/// Exploit: a session ID that outlives sign-in, cookie attributes that expose the ID, and
/// sessions that never end.
/// </summary>
public sealed class ExploitTests(PortalAppFactory factory) : BrowserTests(factory)
{
    [Fact]
    public async Task SessionIdIssuedBeforeSignInDoesNotWorkAfterTheVictimSignsIn()
    {
        // The attacker opens the site and is given a session ID without signing in...
        var attacker = NewBrowser();
        await attacker.Get("/account", TypedUrl);
        await attacker.Get("/login", SameOriginPage);
        // ...and the victim signs in on a browser that already holds it: a shared computer the
        // attacker used first, or a cookie written from a sibling subdomain (a __Host- cookie can't be).
        var victim = await SignIn("alice", attacker.Clone());
        Assert.Equal("alice", await SignedInAs(victim));
        Assert.True(await SignedInAs(attacker) != "alice", "the pre-login session ID opens Alice's account");
    }

    [Fact]
    public async Task AttackersOwnSessionPlantedInTheVictimsBrowserDoesNotBecomeTheVictims()
    {
        var bob = await SignIn("bob");
        var victim = await SignIn("alice", bob.Clone()); // Bob plants his signed-in session cookie in Alice's browser
        Assert.Equal("alice", await SignedInAs(victim));
        Assert.True(await SignedInAs(bob) != "alice", "Bob's session now opens Alice's account");
    }

    [Fact]
    public async Task SessionCookieIsHttpOnlySoPageScriptsCannotReadIt()
    {
        var cookie = await SessionCookie(await SignIn("alice"));
        Assert.True(cookie.Attributes.ContainsKey("httponly"), $"no HttpOnly in Set-Cookie: {cookie.Line}");
    }

    [Fact]
    public async Task SessionCookieIsSecureSoTheBrowserNeverSendsItOverPlainHttp()
    {
        var cookie = await SessionCookie(await SignIn("alice"));
        Assert.True(cookie.Attributes.ContainsKey("secure"), $"no Secure in Set-Cookie: {cookie.Line}");
    }

    [Fact]
    public async Task SessionCookieIsSameSiteLaxOrStrict()
    {
        var cookie = await SessionCookie(await SignIn("alice"));
        var sameSite = cookie.Attributes.GetValueOrDefault("samesite", "");
        Assert.True(Regex.IsMatch(sameSite, "^(lax|strict)$", RegexOptions.IgnoreCase), $"Set-Cookie: {cookie.Line}");
    }

    [Fact]
    public async Task SessionCookieHasTheHostPrefixPathSlashAndNoDomain()
    {
        var cookie = await SessionCookie(await SignIn("alice"));
        Assert.True(cookie.Name.StartsWith("__Host-", StringComparison.Ordinal), $"the session cookie is called {cookie.Name}");
        Assert.True(cookie.Attributes.ContainsKey("secure") && cookie.Attributes.GetValueOrDefault("path") == "/"
            && !cookie.Attributes.ContainsKey("domain"), $"Set-Cookie: {cookie.Line}");
    }

    [Fact]
    public async Task SessionLeftIdleFor31MinutesNoLongerWorks()
    {
        var alice = await SignIn("alice");
        Advance(IdleFor);
        Assert.True(await SignedInAs(alice) is null, $"the session still works after {IdleFor} idle minutes");
    }

    [Fact]
    public async Task SessionKeptBusyStopsWorking8HoursAfterSignIn()
    {
        var alice = await SignIn("alice");
        for (var t = PollEvery; t <= BusyUntil; t += PollEvery)
        {
            Advance(PollEvery);
            Assert.True(await SignedInAs(alice) == "alice", $"signed out after {t} busy minutes");
        }
        Advance(PastAbsoluteLimit - BusyUntil);
        Assert.True(await SignedInAs(alice) is null, $"the session still works {PastAbsoluteLimit} minutes after sign-in");
    }
}

Pitfalls

  • Session state accepts any session key it protected itself and has no regenerate method, so a key planted before sign-in stays the key after it. Session.Clear() and Remove() change the data, not the key.
  • Session state's cookie is HttpOnly and SameSite=Lax by default but its SecurePolicy is None: it is never marked Secure, even over HTTPS. Its IdleTimeout (20 minutes) drops the stored data, not the cookie.
  • Cookie authentication defaults to SecurePolicy = SameAsRequest, ExpireTimeSpan = 14 days and SlidingExpiration = true: behind a TLS-terminating proxy without forwarded headers the cookie is sent without Secure, and an idle ticket lives two weeks.
  • AuthenticationProperties.ExpiresUtc is not an absolute limit while SlidingExpiration is on: a renewal issues a new ticket with the same window from the renewal time (CookieAuthenticationHandler.RequestRefresh). Only AllowRefresh = false stops renewals, and then there is no idle timeout.
  • SignOutAsync deletes the cookie in this browser, but the ticket is self-contained, so a copy keeps working until it expires. When sign-out must end the session on the server, keep tickets in a CookieAuthenticationOptions.SessionStore (ITicketStore) or check a per-user stamp in OnValidatePrincipal.
  • No scanner checked here notices a user kept in ISession or reads the authentication cookie's options; CodeQL's cs/session-reuse covers only classic ASP.NET.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs session-cookies/csharp-aspnetcore does this for you).
  2. Against your own app, save the cookies from the sign-in page (curl -c jar -b jar https://your.app/login), post the form with its __RequestVerificationToken using the same jar, and check that the sign-in response sets a new authentication cookie and that the cookies from before sign-in alone don't open a protected page; check Secure, HttpOnly, SameSite and the name in that Set-Cookie line.
  3. Search for HttpContext.Session holding user IDs and for the cookie options (SecurePolicy, ExpireTimeSpan, OnValidatePrincipal): scanners don't check them.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix session fixation and session cookies in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepcsharp.dotnet.security.web-config-insecure-cookie-settings.web-config-insecure-cookie-settingsNo findings: it matches requireSSL="false" in a classic ASP.NET web.config. semgrep-rules (develop at 9bbf021) has no rule for ASP.NET Core session or authentication cookie options, or for session fixation.Checked: Semgrep 1.180.0 (CE)
CodeQLcs/session-reuseIn the default code-scanning suite, but it models only classic ASP.NET: a successful Membership.ValidateUser or FormsAuthentication.Authenticate followed by use of System.Web.SessionState.HttpSessionState without a call to Abandon() or Clear(). ASP.NET Core's ISession is not modelled, so no findings are expected here; note that it counts Clear() as ending the session.Checked: github/codeql main at b5b8165; not executed
CodeQLcs/web/cookie-secure-not-setNot expected to report anything: it reports CookieOptions without Secure passed to Response.Cookies.Append, and explicit SecurePolicy = CookieSecurePolicy.None assignments on a CookieBuilder or CookieAuthenticationOptions. impl/VulnerableSessions.cs inherits SecurePolicy None from SessionOptions' default without assigning it.Checked: github/codeql main at b5b8165; not executed
CodeQLcs/web/cookie-httponly-not-setNot expected to report anything: every implementation keeps HttpOnly on the session or authentication cookie, and the query reports HttpOnly set to false or cookies appended without it.Checked: github/codeql main at b5b8165; not executed
.NET code analysisCA5382Use secure cookies in ASP.NET CoreNo CA53xx warning for any implementation. CA5382 and CA5383 are off by default and check CookieOptions.Secure on Response.Cookies.Append, not the session or authentication cookie builders.Checked: .NET SDK 10.0.107 with -p:AnalysisMode=All -p:AnalysisModeSecurity=All

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC# 14
Runtime.NET 10.0.12 (SDK 10.0.112, ASP.NET Core runtime 10.0.12)
FrameworkASP.NET Core 10 minimal API with cookie authentication (fixed) or session state (vulnerable and wrong)
Microsoft.AspNetCore.App (shared framework: cookie authentication, session state, antiforgery)10.0.12 at run time
Microsoft.AspNetCore.Mvc.Testing10.0.12
xunit.v34.0.1 (Microsoft Testing Platform 2.4.0)

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the session-cookies/csharp-aspnetcorefolder.

Shell
dotnet restore tests/Portal.Tests --locked-mode
Shell
# Expected: every test passes
IMPL=fixed dotnet test --project tests/Portal.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Portal.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: a test fails
IMPL=wrong dotnet test --project tests/Portal.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs session-cookies/csharp-aspnetcore runs all three modes and checks the expected results.

Download the project ZIP, 33 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • dotnetSdk 10.0.112
  • aspNetCoreRuntime 10.0.12
  • mvcTesting 10.0.12
  • xunitV3 4.0.1
  • microsoftTestingPlatform 2.4.0
Matrix
fixed: 11 of 11 tests pass; vulnerable: 5 of 11 tests fail (5 exploit tests); wrong: 2 of 11 tests fail (2 exploit tests)
Test results
fixed 11/11 pass; vulnerable 5/11 fail (5 exploit tests: both fixation tests, Secure, __Host- and the absolute timeout; HttpOnly, SameSite=Lax and the idle timeout pass on session-state defaults); wrong 2/11 fail (both fixation tests), offline after install
Dependency freshness
Microsoft.AspNetCore.Mvc.Testing 10.0.12 (2026-09-08) is the current 10.0.x patch. xunit.v3 4.0.1 (2026-09-12) is used rather than 4.0.2 (2026-10-09), a bug-fix release under 14 days old. dotnet list package --vulnerable found nothing in the 49 locked packages. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Session Management Cheat Sheet (session ID renewal, cookie attributes, timeouts)
  2. Use cookie authentication without ASP.NET Core Identity (.NET 10)
  3. Session and state management in ASP.NET Core (.NET 10)
  4. ASP.NET Core source: CookieAuthenticationHandler (sliding expiration and renewal)
  5. ASP.NET Core source: SessionOptions (session cookie defaults)
  6. Integration tests in ASP.NET Core (WebApplicationFactory)
  7. MDN: Set-Cookie (Secure, HttpOnly, SameSite and the __Host- prefix)

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.