In this post I describe a problem that I've been asked about several times related to session state. The scenario goes something like this:

  • Scaffold a new ASP.NET Core application
  • Set a string in session state for a user, e.g. HttpContext.Session.SetString("theme", "Dark");
  • On the next request, try to load the value from session using HttpContext.Session.GetString("theme"); but get back null!
  • "Gah, this stupid framework doesn't work" (╯°□°)╯︵ ┻━┻

The cause of this "issue" is the interaction between the GDPR features introduced in ASP.NET Core 2.1 and the fact that session state uses cookies. In this post I describe why you see this behaviour, as well as some ways to handle it.

The GDPR features were introduced in ASP.NET Core 2.1, so if you're using 2.0 or 1.x you won't see these problems. Bear in mind that 1.x are falling out of support on June 27 2019, and 2.0 is already unsupported, so you should consider upgrading your apps to 2.1 where possible.

Session State in ASP.NET Core

As I stated above, if you're using ASP.NET Core 2.0 or earlier, you won't see this problem. I'll demonstrate the old "expected" behaviour using an ASP.NET Core 2.0, to show how people experiencing the issue typically expect session state to behave. Then I'll create the equivalent app in ASP.NET Core 2.1, and show that session state no longer seems to work.

What is session state?

Session state is a feature that harks back to ASP.NET (non-Core) in which you can store and retrieve values server-side for a user browsing your site. Session state was often used quite extensively in ASP.NET apps, but was problematic for various reasons), primarily performance and scalability.

Session state in ASP.NET Core is somewhat dialled back. You should think of it more like a per-user cache. From a technical point of view, session state in ASP.NET Core requires two separate parts:

  • A cookie. Used to assign a unique identifier (the session ID) to a user.
  • A distributed cache. Used to store items associated with a given session ID.

Where possible, I would avoid using session state if you can get away without it. There's a lot of caveats around its usage that can easily bite you if you're not aware of them. For example:

  • Sessions are per-browser, not per logged-in user
  • Session cookies (and so sessions) should be deleted when the browser session ends, but might not be.
  • If a session doesn't have any values in it, it will be deleted, generating a new session ID
  • The GDPR issue described in this post!

That covers what session state is and how it works. In the next section I'll create a small app that tracks which pages you've visited by storing a list in session state, and then displays the list on the home page.

Using session state in an ASP.NET Core 2.0 app

To demonstrate the change in behaviour related to the 2.0 to 2.1 upgrade, I'm going to start by building an ASP.NET Core 2.0 app. As I apparently still have a bazillion .NET Core SDKs installed on my machine, I'm going to use the 2.0 SDK (version number 2.1.202) to scaffold a 2.0 project template.

Start by creating a global.json to pin the SDK version in your app directory:

dotnet new globaljson --sdk-version 2.1.202

Then scaffold a new ASP.NET Core 2.0 MVC app using dotnet new:

dotnet new mvc --framework netcoreapp2.0

Session state is not configured by default, so you need to add the required services. Update ConfigureServices in Startup.cs to add the session services. By default, ASP.NET Core will use an in-memory session store, which is fine for testing purposes, but will need to be updated for a production environment:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    services.AddSession(); // add session
}

You also need to add the session middleware to your pipeline. Only middleware added after the session middleware will have a access to session state, so you typically add it just before the MVC middleware in Startup.Configure:

 public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    // ...other config
    app.UseSession();
    app.UseMvc(routes =>
    {
        routes.MapRoute(
            name: "default",
            template: "{controller=Home}/{action=Index}/{id?}");
    });
}

For this simple (toy) example I'm going to retrieve and set a string session value with each page view, using the session-key "actions". As you browse between various pages, the session value will expand into a semicolon-separated list of action names. Update your HomeController to use the RecordInSession function, as shown below:

public class HomeController : Controller
{
    public IActionResult Index()
    {
        RecordInSession("Home");
        return View();
    }

    public IActionResult About()
    {
        RecordInSession("About");
        return View();
    }

    private void RecordInSession(string action)
    {
        var paths = HttpContext.Session.GetString("actions") ?? string.Empty;
        HttpContext.Session.SetString("actions", paths + ";" + action);
    }
}

Note: Session.GetString(key) is an extension method in the Microsoft.AspNetCore.Http namespace

Finally, we'll display the current value of the "actions" key in the homepage, Index.chstml:

@using Microsoft.AspNetCore.Http
@{
    ViewData["Title"] = "Home Page";
}

<div>
    @Context.Session.GetString("actions")
</div>

If you run the application and browse around a few times, you'll see the session action-list build up. In the example below I visited the Home page three times, the About page twice, and the Contact page once:

Session working correctly

If you view the cookies associated with the page, you will see the .AspNetCore.Session cookie that holds an encrypted session ID. If you delete this cookie, you'll see the "actions" value is reset, and the list is lost.

AspNetCore.Session cookie used for tracking session id

This is the behaviour most people expect with session state, so no problems there. The difficulties arise when you try the same thing using an ASP.NET Core 2.1 / 2.2 app.

Session state problems in ASP.NET Core 2.1/2.2

To create the ASP.NET Core 2.2 app, I used pretty much the same behaviour, but this time I did not pin the SDK. I have the ASP.NET Core 2.2 SDK installed (2.2.102) so the following generates an ASP.NET Core 2.2 MVC app:

dotnet new mvc

You still need to explicitly install session state, so update Startup as before, by adding services.AddSession() to ConfigureServices and app.UseSession() to Configure.

The newer 2.2 templates have been simplified compared to previous versions, so for consistency I copied across the HomeController from the 2.0 app. I also copied across the Index.chtml, About.chtml, and Contact.cshtml view files. Finally, I updated Layout.cshtml to add links for the About and Contact pages in the header.

These two apps, while running different versions of ASP.NET Core, are pretty much the same. Yet this time, if you click around the app, the home page always shows just a single visit to the home page, and doesn't record any visits to other pages:

Session not working correctly

Don't click the privacy policy banner - you'll see why shortly!

Also, if you check your cookies, you'll find there aren't any! The .AspNetCore.Session cookie is noticeably absent:

No session cookie

Everything is apparently configured correctly, and the session itself appears to be working (as the value set in the HomeController.Index action can be successfully retrieved in Index.cshtml). But it seems like session state isn't being saved between page reloads and navigations.

So why does this happen in ASP.NET Core 2.1 / 2.2 where it worked in ASP.NET Core 2.0?

Why does this happen? GDPR

The answer is due to some new features added in ASP.NET Core 2.1. In order to help developers conform to GDPR regulations that came into force in 2018, ASP.NET Core 2.1 introduced some additional extension points, as well as updates to the templates. The documentation for these changes is excellent so I'll just summarise the pertinent changes here:

  • A cookie consent dialog. By default, ASP.NET Core won't write "non-essential" cookies to the response until a user clicks the consent dialog
  • Cookies can be marked essential or non-essential. Essential cookies are sent to the browser regardless of whether consent is provided, non-essential cookies require consent.
  • Session cookies are considered non-essential, so sessions can't be tracked across navigations or page reloads until the user provides their consent.
  • Temp data is non-essential. The TempData provider stores values in cookies in ASP.NET Core 2.0+, so TempData will not work until the user provides their consent.

So the problem is that we require consent to store cookies from the user. If you click "Accept" on the privacy banner, then ASP.NET Core is able to write the session cookie, and the expected functionality is restored:

Session state working correctly again

So if you need to use session state in your 2.1/2.2 app, what should you do?

Working with session state in ASP.NET Core 2.1+ apps

Depending on the app you're building, you have several options available to you. Which one is best for you will depend on your use case, but be aware that these features were added for a reason - to help developers comply with GDPR.

If you're not in the EU, and so you think "GDPR doesn't apply to me", be sure to read this great post from Troy Hunt - it's likely GDPR still applies to you!

The main options I see are:

  1. Accept that session state may not be available until users provide consent.
  2. Disable features that require session state until consent is provided.
  3. Disable the cookie consent requirement.
  4. Mark the session cookie as essential.

I'll go into a bit more detail for each option below, just remember to consider that your choice might affect your conformance to GDPR!

1. Accept the existing behaviour

The "easiest" option is to just to accept the existing behaviour. Session state in ASP.NET Core should typically only be used for ephemeral data, so your application should be able to handle the case that session state is not available.

Depending on what you're using it for, that may or may not be possible, but it's the easiest way to work with the existing templates, and the least risky in terms of your exposure to GDPR issues.

2. Disable optional features

The second option is very similar to the first, in that you keep the existing behaviour in place. The difference is that option 1 treats session state simply as a cache, so you always assume session values can come and go. Option 2 takes a slightly different approach, in that it segments off areas of your application that require session state, and makes them explicitly unavailable until consent is given.

For example, you might require session state to drive a "theme-chooser" that stores whether a user prefers a "light" or "dark" theme. If consent is not given, then you simply hide the "theme-chooser" until they have given consent.

This feels like an improvement over option 1, primarily because of the improved user experience. If you don't account for features requiring session state, it could be confusing for the user. For example, if you implemented option 1 and so always show the "theme-chooser", the user could keep choosing the dark theme, but it would never remember their choice. That sounds frustrating!

There's just one big caveat for this approach. Always remember that session state could go away at any moment. You should treat it like a cache, so you shouldn't build features assuming a) it'll always be there (even if the user has given consent), or b) that you'll have one session per real user (different browsers will have different session IDs for the same logical user).

If you're sure that you don't need the cookie consent feature, you can easily disable the requirement in your apps. The default template even calls this out explicitly in Startup.ConfigureServices where you configure the CookiePolicyOptions:

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<CookiePolicyOptions>(options =>
    {
        // This lambda determines whether user consent for non-essential cookies is needed for a given request.
        options.CheckConsentNeeded = context => true;
        options.MinimumSameSitePolicy = SameSiteMode.None;
    });

    services.AddSession(); // added to enable session
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}

The CheckConsentNeeded property is a predicate that is called by the framework to check whether non-essential cookies should be written to the response. If the function returns true (as above, the default in the template), then non-essential cookies are skipped. Change this to false and session state will work without requiring cookies to be explicitly accepted.

4. Mark session cookies as essential

Disabling the cookie consent feature entirely may be a bit heavy handed for your applications. If that's the case, but you want the session state to be written even before the user accepts cookies, you can mark the session cookie as essential.

Just to reiterate, session state was considered non-essential for good reason. Make sure you can justify your decisions and potentially seek advice before making changes like this or option 3.

There is an overload of services.AddSession() that allows you to configure SessionOptions in your Startup file. You can change various settings such as session timeout, and you can also customise the session cookie. To mark the cookie as essential, set IsEssential to true:

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<CookiePolicyOptions>(options =>
    {
        options.CheckConsentNeeded = context => true; // consent required
        options.MinimumSameSitePolicy = SameSiteMode.None;
    });

    services.AddSession(opts => 
    {
        opts.Cookie.IsEssential = true; // make the session cookie Essential
    });
    services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}

With this approach, the cookie consent banner will still be shown, and non-essential cookies will not be written until it's clicked. But session state will function immediately, before consent is given, as it's considered essential:

Privacy banner plus session state

Summary

In this post I described an issue that I've been asked about several times, where developers find that their session state isn't saving correctly. This is commonly due to the GDPR features introduced in ASP.NET Core 2.1 for cookie consent and non-essential cookies.

I showed an example of the issue in action, and how it differs between a 2.0 app and a 2.2 app. I described how session state relies on a session cookie that is considered non-essential by default, and so is not written to the response until a user provides consent.

Finally, I described four ways to handle this behaviour: do nothing and accept it; disable features that rely on session state until consent is given; remove the consent requirement; and make the session cookie an essential cookie. Which option is best for you will depend on the app you're building, as well as your exposure to GDPR and similar legislation.