Authentication cookie lifetime and sliding expiration in ASP.NET Core

How to control authentication cookie lifetime, and why is sliding expiration potentially dangerous? This article contains an overview of this topic. It also shows the way to set absolute authentication cookie lifetime in ASP.NET Core.

Authentication cookie lifetime and sliding expiration in ASP.NET Core

The sample code

In the code below, we register a cookie authentication scheme. Say we have the requirement to invalidate this cookie after 30 minutes, if the user is not active. We set the ExpireTimeSpan property to handle that. To provide a nice user experience, we enable the SlidingExpiration flag, which extends the cookie lifetime automatically if the user actively uses the web app.

public void ConfigureServices(IServiceCollection services){    services.AddAuthentication(options =>    {        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;    }).AddCookie(options =>    {        options.ExpireTimeSpan = TimeSpan.FromMinutes(30);        options.Cookie.MaxAge = options.ExpireTimeSpan; // optional        options.SlidingExpiration = true;    });}
C#

"ExpireTimeSpan" vs "Cookie.MaxAge"

When configuring the cookie authentication scheme, you can optionally set the Cookie.MaxAge property (line 9 of the sample code).

How does it differ from ExpireTimeSpan? To be more precise, the ExpireTimeSpan defines a lifetime of the authentication ticket. The authentication ticket is a payload of an authentication cookie. These are two different things. The authentication ticket is stored in an encrypted shape in the authentication cookie in the user browser. The web app decrypts it on every request.

Authentication cookie payload

With Cookie.MaxAge, you control the authentication cookie lifetime. If the cookie, expires a browser purges it. If you don't set Cookie.MaxAge, it effectively becomes a session cookie and is deleted after closing a browser. Furthermore, if the underlying ticket expires, the cookie will still be there, but a server will treat a user as they were anonymous.

Session cookie
Session cookie - "Cookie.MaxAge" not set
Cookie with max age
Cookie with max age - "Cookie.MaxAge" set

Note that you should not rely solely on Cookie.MaxAge. It can be fully controlled by the user. Either use ExpireTimeSpan only or both ExpireTimeSpan and Cookie.MaxAge simultaneously!

There is yet another property named Cookie.Expiration. However, if you set that, you get an error like below. So don't use it at all.

OptionsValidationException: Cookie.Expiration is ignored, use ExpireTimeSpan instead.
Error

Sliding expiration explained

This is how the documentation describes the SlidingExpiration flag:

The SlidingExpiration is set to true to instruct the middleware to re-issue a new cookie with a new expiration time any time it processes a request which is more than halfway through the expiration window.

What does it mean? For instance, if ExpireTimeSpan is set to 30 minutes and the authentication cookie is 7 minutes age, then the HTTP request doesn't return a new cookie. When the cookie's age is, for example, 20 minutes, then a new cookie will be sent to a browser. In other words, if the cookie's age is more than 15 minutes (halfway through the expiration window), then the cookie is re-issued. Request invoked at 6th minute of new cookie's lifetime won't re-issue this cookie.

Sliding expiration diagram

You can observe it in the Network tab in Chrome Developer Tools.

Below treshold - cookie is not re-issued
Below treshold - cookie is not re-issued
Above treshold - cookie is re-issued
Above treshold - cookie is re-issued

The problem with SlidingExpiration enabled is that the authentication cookie could be potentially re-issued infinitely. That's not a good security practice. If a hacker took control of the account, they could use it forever.

To prevent this situation, we should set absolute authentication cookie lifetime. After a given period user won't be able to extend it anymore and will be kicked from the app. Unfortunately, ASP.NET Core does not have this option out of the box, so we need to handle it manually.

Let's assume that we want to set the absolute lifetime to 3 days. To track a cookie's lifetime and invalidate too old cookie, we override the CookieAuthenticationEvents class. In the SigningIn method, we store the UTC date-time when the cookie was issued. In the ValidatePrincipal method, we check how long ago the initial cookie was issued. If it's beyond 3 days, we reject the principle.

public class CustomCookieAuthenticationEvents : CookieAuthenticationEvents{    private const string TicketIssuedTicks = nameof(TicketIssuedTicks);
    public override async Task SigningIn(CookieSigningInContext context)    {        context.Properties.SetString(            TicketIssuedTicks,            DateTimeOffset.UtcNow.Ticks.ToString());
        await base.SigningIn(context);    }
    public override async Task ValidatePrincipal(        CookieValidatePrincipalContext context)    {        var ticketIssuedTicksValue = context            .Properties.GetString(TicketIssuedTicks);
        if (ticketIssuedTicksValue is null ||            !long.TryParse(ticketIssuedTicksValue, out var ticketIssuedTicks))        {            await RejectPrincipalAsync(context);            return;        }
        var ticketIssuedUtc =            new DateTimeOffset(ticketIssuedTicks, TimeSpan.FromHours(0));
        if (DateTimeOffset.UtcNow - ticketIssuedUtc > TimeSpan.FromDays(7))        {            await RejectPrincipalAsync(context);            return;        }
        await base.ValidatePrincipal(context);    }
    private static async Task RejectPrincipalAsync(        CookieValidatePrincipalContext context)    {        context.RejectPrincipal();        await context.HttpContext.SignOutAsync();    }}
C#

Then we register our custom events class and set EventsType to CustomCookieAuthenticationEvents type.

public void ConfigureServices(IServiceCollection services){    services.AddAuthentication(options =>    {        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;    }).AddCookie(options =>    {        options.ExpireTimeSpan = TimeSpan.FromMinutes(30);        options.Cookie.MaxAge = options.ExpireTimeSpan;        options.SlidingExpiration = true;        options.EventsType = typeof(CustomCookieAuthenticationEvents);    });
    services.AddTransient<CustomCookieAuthenticationEvents>();}
C#

Therefore, if RejectPrincipalAsync is invoked, the server kills the cookie by setting its expiration date to 1970.

Cookie is rejected

Note that with this solution in place, a potential hacker will still be able to use a captured account for 3 days. To have full control over users' sessions, you would need to implement your session storage. However, it's the topic for another article.

Where the ticks are stored?

Perhaps you're curious where the TicketIssuedTicks are stored. SetString is a member of the AuthenticationProperties class. After invoking the code below, ticks become part of the authentication ticket that has been described earlier.

public override async Task SigningIn(CookieSigningInContext context){    context.Properties.SetString(        TicketIssuedTicks,        DateTimeOffset.UtcNow.Ticks.ToString());
    await base.SigningIn(context);}
C#

Don't use built-in "IssuedUtc" property

If you run the debugger and set a breakpoint in the SigningIn method on the CustomCookieAuthenticationEvents class, you may notice that there are already the IssuedUtc property. It's not what we need since it indicates the date-time when the current cookie has been created. It will reset as soon the backend re-issues the authentication cookie. That's why we don't use it and go with a custom solution.

Already existing properties in the context

Conclusion

I hope this article will help you better understand how to control the authentication cookie (ticket) lifetime. It's worth remembering the difference between a cookie and a ticket. Bear in mind that you should not rely on the cookie expiration date. It ought to be set on a ticket level. Also, it's important to know that the default behavior of sliding expiration can be problematic from a security point of view.

Comments

The comments system is based on GitHub Issues API. Once you add your comment to the linked issue on my GitHub repository, it will show up below.