blog post image
Andrew Lock avatar

Andrew Lock

~14 min read

Supporting legacy browsers and SameSite cookies without UserAgent sniffing in ASP.NET Core.

In my previous post, I described how SameSite cookies work and how they work as a security measure. I also mentioned the fact that the specification was updated in 2019, and that the behaviour of browsers from before 2019 is generally not compatible with modern browsers. That means there's no "simple" way for your SameSite cookies to work correctly in both legacy and modern browsers.

In this post I explore one way to get ASP.NET Core Identity SameSite cookies working with both legacy and modern browsers. The approach in this post is inspired by a suggestion from the Chrome dev team, and is an alternative to the "User-Agent sniffing" approach suggested in the ASP.NET Core documentation.

I'll start by describing what the problem is, and how the 2019 spec change caused it. I'll then outline the basic design of the proposed solution. Finally I'll demonstrate how we can shoehorn this in to a simple ASP.NET Core app that uses ASP.NET Core Identity with Cookie Authentication.

The approach I demonstrate in this post is really only a proof-of-concept; I haven't tested it in production, and there are known edge cases and issues with it, but maybe someone will find it useful or interesting!

2016 SameSite cookies vs 2019 SameSite cookies

SameSite cookies are an IETF draft standard that are designed to provide some protection against cross-site request forgery (CSRF) attacks. I described how they work and how they protect you in my previous post, so I suggest reading that one first if SameSite cookies are new to you.

The original SameSite cookie draft in 2016 defined 2 possible values for SameSite cookies:

  • Strict. Cookies are only sent when the navigation event originated from the same-site, e.g. you clicked a link within your own site.
  • Lax. Cookies are sent in all the Strict situations, plus when there's a top-level cross-site navigation event e.g. you clicked a link in an email that took you to the site.

If you don't specify a SameSite value in your cookie, the browser would not apply any protections, and would always send the cookie for any cross-site requests as normal (allowing for CORS restrictions).

In 2019, the SameSite draft standard was revised in a way that was not backwards compatible. There were two important changes for our discussion:

  • The spec introduced the None value. This new value means "always send the cookies", including cross-site.
  • The spec changed the default behaviour, when no SameSite value was provided. Instead of always sending the cookies (i.e. treating it the same as None), the browser would treat these cookies as Lax.

Another important thing to consider is the behaviour when the browser receives an unknown SameSite value:

  • In the 2016 spec, it treats the cookies as though they are Strict.
  • In the 2019 spec, it treats the cookies as through they are Lax.

The problem with all these changes may not be immediately apparent, but the problem arises when you need a cookie to be sent cross-site. I'll call that the "None" style. The question is, what value should you set on your cookie, so that both browsers implementing the 2016 spec and browsers implementing the 2019 spec will send a cookie cross-site?

SameSite cookie2016 browser2019 browserSuitable ?
StrictStrictStrict
LaxLaxLax
NoneStrictNone
<not set>NoneLax
<UNKNOWN VALUE>StrictLax

The table above shows that there's no single SameSite value you can apply to a cookie and have all browsers send the cookie cross-site:

  • If you apply the None value, 2019 browsers respect it, but 2016 browsers treat the value as Strict, because they don't understand the value.
  • If you don't set any value, 2016 browsers treat it as None, but 2019 browsers treat it as Lax.

So now what? What are you supposed to do?

Solving the None problem with user-agent sniffing

This wasn't just an academic issue, there were real problems with some OpenID Connect flows being impacted by this change for example. Thankfully, 4 years on, and with evergreen browsers, the problem is largely consigned to history: you can send None for these cookies, and the majority of browsers will be using the 2019 version.

But what if you do need to support a browser that uses the 2016 version of the standard? For ASP.NET Core apps, Microsoft's documentation suggest an approach that uses User-Agent sniffing:

  1. Configure a CookiePolicyOptions that sets MinimumSameSitePolicy=SameSiteMode.Unspecified.
  2. Add a callback to CookiePolicyOptions.OnAppendCookie. The callback checks to see if the User-Agent header of the request suggests the request is from a browser that doesn't support SameSite=None (i.e. a pre-2019 browser).
  3. If the user-agent doesn't support SameSite=None, remove the SameSite value (by setting it to SameSiteMode.Unspecified in code).

This approach works, but it requires that you have a reliable way of sniffing user-agents to work out which browser a request is from. If that's unreliable, and your sniffing library suggests that a site doesn't need SameSite=None when it does (or vice-versa), then you may well have a broken site. Microsoft provide a "sample" user-agent sniffer in their docs, but it's covered with warnings:

⚠ Warning

The following code is for demonstration only:

  • It should not be considered complete.
  • It is not maintained or supported.

So is there an alternative?

Solving the None problem by sending multiple cookies

I came across an alternative solution to the problem from Google when researching SameSite cookies for my previous post. I think it's both elegant and crude at the same time:

  • Whenever you set a cookie with SameSite=None, set a "legacy" cookie with the same value, without setting SameSite.
  • In your app, when attempting to read a cookie, check for the "main" cookie first. If that's not found, check for the "legacy" version, and use that if it's found.

Let's make this concrete: say you want to set a cookie with the value MyCookie=SomeValue. You need it to be sent cross-site, so you need to set SameSite=None. So the whole thing looks something like:

Set-Cookie: MyCookie=SomeValue; Secure; HttpOnly; SameSite=None

Note that Secure is required when you're setting SameSite=None

This cookie works on 2019 browsers, but to handle earlier browsers, you also set the following cookie:

Set-Cookie: MyCookie-$#legacy#$=SomeValue; Secure; HttpOnly;

We've appended the "-$#legacy#$" string to the cookie name (using a few symbols to risk chances of collision). This is set alongside the original, so both cookies are sent in the response:

Set-Cookie: MyCookie=SomeValue; Secure; HttpOnly; SameSite=None
Set-Cookie: MyCookie-$#legacy#$=SomeValue; Secure; HttpOnly;

When the browser makes a cross-site request, it will only send one of these values:

  • If the browser is 2019-spec compliant, it will send MyCookie because it's marked as SameSite=None, but not the legacy cookie because it will be treated as Lax.
  • If the browser is 2016-spec compliant, it will send MyCookie-$#legacy#$ because it doesn't have a SameSite value specified, and it won't send MyCookie because it treats the SameSite=None value as Strict.

In the app, we just need to check for both MyCookie and MyCookie-$#legacy#$. If we find either of them, we can use the provided value.

Note that for cross-site requests we will only ever see one of the values. For same-site requests we will likely see both values (depending on the specifics).

The good thing about this solution is: no user-agent sniffing! That means it should be reliable.

The downside is that you're sending the same cookie value twice which will increase your response sizes (and request sizes for same-site requests).

So that's the theory, now let's try to implement it in ASP.NET Core!

Solving the None problem for ASP.NET Core Identity

To test the solution, I created a simple ASP.NET Core Razor Pages app, using ASP.NET Core Identity. In this scenario, I'm imagining that I need to send the authentication cookie in a cross-site request. That's the scenario I described in a previous post.

An example of the various requests that need to be made in the scenario

To make that work, we need the cookie to be treated as SameSite=None. My goal in this test is to have that work in modern browsers but also in pre-2019 browsers.

I started by scaffolding the Razor Pages app with ASP.NET Core Identity using

dotnet new webapp -au Individual

If you run the app and login, you'll find that the authentication cookie is marked as SameSite=Lax (for good reason):

A screenshot of the cookies sent for a default ASP.NET Core app, showing that the auth cookie is sent with Lax

We can switch that to be SameSite=None using the snippet I added in my previous post:

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

// 👇 Add this
builder.Services.Configure<CookieAuthenticationOptions>(
    IdentityConstants.ApplicationScheme, 
    x => x.Cookie.SameSite = SameSiteMode.None);

Adding this snippet (after the other ASP.NET Core Identity configuration) ensures that ASP.NET Core Identity sets SameSite=None on the cookie. If we log out and back in again, we can see

A screenshot of the cookies sent after making the change, showing that the auth cookie is now sent with None

So far so good. But now we need to make sure an additional "legacy" cookie is set in the response, as well as ensure that ASP.NET Core Identity reads them.

Understanding our options

Theoretically, there are several places we could try to hook into the ASP.NET Core infrastructure to intercept the writing and reading of cookies. Each of these has pros-and cons:

  • The CookiePolicy options have callbacks you can use to intercept whenever a cookie is written to or deleted from the response.
    • We could easily add a callback to add the legacy cookie whenever a SameSite=None cookie is added.
    • This is only a solution to the "write" part. There's no way to intercept retrieving a cookie here, so this can only ever be a partial solution.
  • The IRequestCookiesFeature server feature provides a "lower-level" API for reading cookies from the request.
    • We could wrap the "raw" IRequestCookiesFeature with a version that intercepts queries for a cookie to also check the "legacy" version.
    • A bit fiddly to implement
    • Only a solution for the "read" part. Could be coupled with the CookiePolicy approach to form a complete solution.
  • The CookieAuthenticationOptions uses an ICookieManager to handle writing large cookies that exceed the maximum allowed value by browsers.
    • Handles both the reading and writing of cookies, so can form a complete solution for the authentication cookie.
    • Only used for cookie authentication, so not a "general" solution.

In this post, I show a solution that uses ICookieManager, as it achieves my original goal, and frankly, it's the easiest! A more complete solution would probably use all of the above types, but there are a few difficulties there too, as I'll touch on later.

Creating a custom ICookieManager

The CookieAuthenticationOptions expose an ICookieManager property, which is set to null initially. The ICookieManager interface is relatively simple, containing just three methods for managing cookies:

public interface ICookieManager
{
    string? GetRequestCookie(HttpContext context, string key);
    void AppendResponseCookie(HttpContext context, string key, string? value, CookieOptions options);
    void DeleteCookie(HttpContext context, string key, CookieOptions options);
}

If you don't configure a customer ICookieManager on CookieAuthenticationOptions, then ASP.NET Core uses a ChunkingCookieManager. This handles splitting a cookie with a long value (more than 4050 characters) into multiple cookies in the response, as well as piecing them back together again in the request.

For my solution, I created a simple wrapper around the ChunkingCookieManager, something like this:

internal sealed class CookieManagerWrapper : ICookieManager
{
    const string LegacySuffix = "-$#legacy#$";
    private readonly ICookieManager _cookies = new ChunkingCookieManager();

    public string? GetRequestCookie(HttpContext context, string key)
        =>_cookies.GetRequestCookie(context, key);

    public void AppendResponseCookie(HttpContext context, string key, string? value, CookieOptions options)
        => _cookies.AppendResponseCookie(context, key, value, options);

    public void DeleteCookie(HttpContext context, string key, CookieOptions options)
        => _cookies.DeleteCookie(context, key, options);
}

Right now, this is literally just a wrapper, it doesn't add any functionality, but we'll update that shortly.

To use our custom CookieManagerWrapper we call Configure<> on the cookie options:

builder.Services.Configure<CookieAuthenticationOptions>(
    IdentityConstants.ApplicationScheme, 
    x => x.CookieManager = new CookieManagerWrapper());

Add this after configuring your other ASP.NET Core Identity services. With this in place, we can update the CookieManagerWrapper to add the functionality we need.

As a reminder, when we set a SameSite=None cookie to the response, we want to set a cookie with the same value, without a SameSite setting. To achieve this, we can update CookieManagerWrapper.AppendResponseCookie():

public void AppendResponseCookie(HttpContext context, string key, string? value, CookieOptions options)
{
    // Set the normal cookie
    _cookies.AppendResponseCookie(context, key, value, options);

    // If the cookie we're setting is SameSite=None, set the legacy cookie
    if (options.SameSite == SameSiteMode.None)
    {
        // Create a copy of the options (HttpOnly, Secure etc)
        // but remove the SameSite setting by setting to Unspecified
        var clonedOptions = new CookieOptions(options)
        {
            SameSite = SameSiteMode.Unspecified
        };

        // Append the LegacySuffix to the cookie name, and set the cookie
        _cookies.AppendResponseCookie(context, $"{key}{LegacySuffix}", value, clonedOptions);    
    }
}

The code for deleting the cookie looks almost identical - when we delete the "original" cookie, we want to make sure we delete the legacy cookie too:

public void DeleteCookie(HttpContext context, string key, CookieOptions options)
{
    // Delete the normal cookie
    _cookies.DeleteCookie(context, key, options);

    // If the cookie we're setting is SameSite=None, delete the legacy cookie
    if (options.SameSite == SameSiteMode.None)
    {
        // Create a copy of the options (HttpOnly, Secure etc)
        // but remove the SameSite setting by setting to Unspecified
        var clonedOptions = new CookieOptions(options)
        {
            SameSite = SameSiteMode.Unspecified
        };

        // Append the LegacySuffix to the cookie name, and delete the cookie
        _cookies.DeleteCookie(context, $"{key}{LegacySuffix}", clonedOptions);
    }
}

That handles the response side, now we need to make sure we retrieve the cookie.

Finally, we can update CookieManagerWrapper.GetRequestCookie() to try reading the legacy cookie if the normal cookie fails:

public string? GetRequestCookie(HttpContext context, string key)
{
    // Try to read the normal cookie
    if (_cookies.GetRequestCookie(context, key) is { } value)
    {
        // We found it, all good
        return value;
    }

    // Try to get the legacy cookie instead
    return _cookies.GetRequestCookie(context, $"{key}{LegacySuffix}");
}

And with that, we're finished, let's try it out!

Taking it for a spin

If we open our app, login to the site, we'll now see two authentication cookies:

  • .AspNetCore.Identity.Application which is set with SameSite=None
  • .AspNetCore.Identity.Application-$#legacy#$ which is set without a SameSite value

A screenshot of the two auth cookies

What's more, we can now delete the original auth cookie, but we're still authenticated via the legacy cookie. That means when we're using a legacy browser and our app receives a cross-site request which doesn't include the SameSite=None cookie, we'll be able to read the legacy cookie and stay authenticated, success!

What's the catch?

There are basically two downsides to this implementation:

  • Sending a big cookie twice is wasteful most of the time
  • This implementation only works for authentication cookies created by the CookieAuthenticationHandler. If you're "manually" setting cookies then you may want to look at the other approach I mentioned.

So, should you do this? Probably not 🤷‍♂️ Hopefully noone needs to do this these days, and if you don't explicitly need it, then don't. But if you do…I'd be interested to hear how you have solved the problem!

The full code

You can find the full code for the sample on GitHub (link below), but for completeness, I've included the full solution here too:

using System.Security.Claims;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using cross_origin_aspnetcore_identity.Data;
using Microsoft.AspNetCore.Authentication.Cookies;

var builder = WebApplication.CreateBuilder(args);

// Add Identity services to the container.
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlite(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();

// 👇 This sets the cookie to use SameSite=None
builder.Services.Configure<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme, 
    x => x.Cookie.SameSite = SameSiteMode.None);

// 👇 This configures our custom Cookie Manager
builder.Services.Configure<CookieAuthenticationOptions>(IdentityConstants.ApplicationScheme, 
    x => x.CookieManager = new CookieManagerWrapper());

builder.Services.AddRazorPages();

// Configure CORS 
builder.Services.AddCors(options =>
{
    options.AddPolicy(name: "AllowAndrewLock", policy
        => policy.WithOrigins("https://andrewlock.net")
            .AllowCredentials()
            .WithMethods("GET"));
});

var app = builder.Build();

app.UseStaticFiles();
app.UseRouting();

app.UseCors();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();

app.MapGet("/api", (ClaimsPrincipal user) => user.Identity?.Name ?? "<unknown>")
    .RequireCors("AllowAndrewLock");

app.Run();

// 👇 The cookie manager implementation
internal sealed class CookieManagerWrapper : ICookieManager
{
    const string LegacySuffix = "-$#legacy#$";
    private readonly ICookieManager _cookies = new ChunkingCookieManager();

    public string? GetRequestCookie(HttpContext context, string key)
    {
        if (_cookies.GetRequestCookie(context, key) is { } value)
        {
            return value;
        }

        return _cookies.GetRequestCookie(context, $"{key}{LegacySuffix}");
    }

    public void AppendResponseCookie(HttpContext context, string key, string? value, CookieOptions options)
    {
        _cookies.AppendResponseCookie(context, key, value, options);

        if (options.SameSite == SameSiteMode.None)
        {
            var clonedOptions = new CookieOptions(options)
            {
                SameSite = SameSiteMode.Unspecified
            };
            _cookies.AppendResponseCookie(context, $"{key}{LegacySuffix}", value, clonedOptions);    
        }
    }

    public void DeleteCookie(HttpContext context, string key, CookieOptions options)
    {
        _cookies.DeleteCookie(context, key, options);
        if (options.SameSite == SameSiteMode.None)
        {
            var clonedOptions = new CookieOptions(options)
            {
                SameSite = SameSiteMode.Unspecified
            };
            _cookies.DeleteCookie(context, $"{key}{LegacySuffix}", clonedOptions);
        }
    }
}

Summary

In my previous post, I described how SameSite cookies work and how they work as a security measure. I also mentioned the fact that the specification was updated in 2019, and that the behaviour of browsers from before 2019 is generally not compatible with modern browsers. This is due to differences in the way SameSite=None and "unspecified" behave.

In this post I explored one way to get ASP.NET Core Identity SameSite cookies working with both legacy and modern browsers simultaneously. I showed that you could implement an ICookieManager that checks whenever a cookie is set. If the cookie has SameSite=None, the cookie manager automatically sets a duplicate cookie, without a SameSite flag. Modern browsers will send the SameSite=None for cross-site requests, whereas legacy browsers won't, and vice versa for the unspecified SameSite cookie.

When the app receives a cross-site request and the app tries to read a cookie, the ICookieManager checks for the presence of the original cookie. If that fails (because the request was from a legacy browser, for example), the ICookieManager automatically checks for the "legacy" cookie. In this way, the app can support browsers that support both the 2016 and 2019 versions of the specification.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?