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; });}
"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.
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.
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.
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.
You can observe it in the Network tab in Chrome Developer Tools.
Setting absolute authentication cookie lifetime
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(3)) { await RejectPrincipalAsync(context); return; }
await base.ValidatePrincipal(context); }
private static async Task RejectPrincipalAsync( CookieValidatePrincipalContext context) { context.RejectPrincipal(); await context.HttpContext.SignOutAsync(); }}
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>();}
Therefore, if RejectPrincipalAsync
is invoked, the server kills the cookie by setting its expiration date to 1970.
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);}
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.
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.