Mert Özen Further With Every Line
Security Authentication and Authorization — #02

ASP.NET Core Cookie Authentication in Depth: Session Lifetime, Sliding Expiration, Server-Side Sessions and Revocation

Mert Özen Nov 8, 2026 11 min 3 views
ASP.NET Core Cookie Authentication in Depth: Session Lifetime, Sliding Expiration, Server-Side Sessions and Revocation

Sliding expiration alone can keep a session alive forever; you have to add an absolute timeout yourself with OnValidatePrincipal, and revocation needs state on the server (ITicketStore or a version stamp). We show the cookie ticket's contents, the 14-day default, renewal at half the window, chunking, server-side sessions in Redis and signing out of all devices with real outputs.

Version note: The code in this post was built and run with .NET 10 (ASP.NET Core 10.0.13 runtime), StackExchange.Redis 3.4.31 and Redis 8.2.

Guess First

Bookstore's cookie authentication is configured like this: ExpireTimeSpan = 30 minutes, SlidingExpiration = true. Alan from the support team signs in at 9 a.m. and refreshes the orders screen every 10 minutes all day. At what time does his session end?

Answer: never. Sliding expiration pushes the deadline forward on every active request; as long as Alan keeps the tab open, the cookie can stay valid for weeks. The same is true for a user whose cookie was stolen: if the attacker sends a request every 10 minutes, the session lives forever. In this post we open up cookie authentication: what is in the ticket, how its lifetime is computed, how to put an upper bound on sliding and how to really kill a session from the server. Cookie flags and session fixation were covered in post 13 of the Web Security (OWASP Top 10) series; the focus here is lifetime and revocation.

The ASP.NET Core cookie is not a session identifier, it is the ticket itself. When SignInAsync is called, the handler turns the AuthenticationTicket (principal, claims and properties) into binary with TicketSerializer, encrypts and signs it with Data Protection and writes it into the cookie as Base64Url. It is no coincidence that the value always starts with CfDJ8: that is the Base64 view of the fixed magic header of a Data Protection payload. Data Protection itself and why the key ring must be shared were covered in post 8 of the Web Security series; two consequences matter here.

First, the server is stateless. The key is enough to decode the ticket; there is no database call. Second, as the cookie grows, every request grows. In the lab I added test permission claims at login and measured Set-Cookie:

ExtraClaims=0    bookstore.auth   475 byte
ExtraClaims=40   bookstore.auth   3248 byte
ExtraClaims=120  bookstore.auth=chunks-3
                 bookstore.authC1 4000 byte
                 bookstore.authC2 4000 byte
                 bookstore.authC3 795 byte

Browsers enforce a limit of roughly 4 KB per cookie. That is why ASP.NET Core uses ChunkingCookieManager: the default ChunkSize is 4050 characters; when the value exceeds it, the cookie is split into chunks and the main cookie gets chunks-3. It works, but it means the browser sends a Cookie header of more than 9 KB on every request. If you hit the header limit of the reverse proxy in front of you (nginx's default large_client_header_buffers is 4 buffers of 8 KB), the user never reaches the application and sees the error in their own browser. Keep the claims in the cookie small, or move to the server-side session we will see shortly.

ExpireTimeSpan, IsPersistent and Sliding Expiration

Instead of writing the defaults from memory, I printed them:

var cookieDefaults = new CookieAuthenticationOptions();
Console.WriteLine($"ExpireTimeSpan={cookieDefaults.ExpireTimeSpan} SlidingExpiration={cookieDefaults.SlidingExpiration}");
Console.WriteLine($"ChunkSize={ChunkingCookieManager.DefaultChunkSize}");
Console.WriteLine($"SecurityStamp ValidationInterval={new SecurityStampValidatorOptions().ValidationInterval}");
ExpireTimeSpan=14.00:00:00 SlidingExpiration=True
ChunkSize=4050
SecurityStamp ValidationInterval=00:30:00

Fourteen days with sliding on: too long even for a bookstore. There are two separate lifetimes here and they are often confused. ExpireTimeSpan sets ExpiresUtc inside the ticket; the server checks it on every request and rejects an expired ticket. The cookie's lifetime in the browser depends on IsPersistent. With IsPersistent = false the cookie has no expires and the browser deletes it on close (the "continue where you left off" feature of modern browsers can bring such cookies back). With true, expires is set to the end of the ticket. What decides security is ExpiresUtc on the server; the browser lifetime is a convenience setting.

The sliding expiration rule is clear too: if a request arrives after half of the window has passed, the handler issues a new ticket and rewrites the cookie. To see the effect I ran the lab with a 20-second idle and a 45-second absolute lifetime (the production values in appsettings.json are 30 minutes and 8 hours):

{
  "SessionLifetime": {
    "Idle": "00:30:00",
    "Absolute": "08:00:00"
  }
}
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "bookstore.auth";
        options.ExpireTimeSpan = lifetime.Idle;
        options.SlidingExpiration = true;
        options.Events.OnValidatePrincipal = SessionValidator.ValidateAsync;
    });

Ada signs in and then calls /me at intervals. Each block shows the elapsed time, whether the response has a Set-Cookie, and the ticket's issuedUtc/expiresUtc:

--- t=5s
HTTP/1.1 200 OK
{"name":"ada@bookstore.test","issuedUtc":"2026-11-08T05:28:03+00:00","expiresUtc":"2026-11-08T05:28:23+00:00","authTime":"2026-11-08T05:28:03.1934430+00:00"}
--- t=12s
HTTP/1.1 200 OK
Set-Cookie: bookstore.auth=CfDJ8EINq_wm4TFCv7guzy6SHCfG5f4xuARXUq-Et7jj...
{"name":"ada@bookstore.test","issuedUtc":"2026-11-08T05:28:03+00:00","expiresUtc":"2026-11-08T05:28:23+00:00","authTime":"2026-11-08T05:28:03.1934430+00:00"}
--- t=21s
HTTP/1.1 200 OK
{"name":"ada@bookstore.test","issuedUtc":"2026-11-08T05:28:15+00:00","expiresUtc":"2026-11-08T05:28:35+00:00","authTime":"2026-11-08T05:28:03.1934430+00:00"}
--- t=30s
HTTP/1.1 200 OK
Set-Cookie: bookstore.auth=CfDJ8EINq_wm4TFCv7guzy6SHCc33HMzsEg2b9ijesPr...
{"name":"ada@bookstore.test","issuedUtc":"2026-11-08T05:28:15+00:00","expiresUtc":"2026-11-08T05:28:35+00:00","authTime":"2026-11-08T05:28:03.1934430+00:00"}
--- t=39s
HTTP/1.1 200 OK
{"name":"ada@bookstore.test","issuedUtc":"2026-11-08T05:28:33+00:00","expiresUtc":"2026-11-08T05:28:53+00:00","authTime":"2026-11-08T05:28:03.1934430+00:00"}
--- t=48s
HTTP/1.1 401 Unauthorized
Set-Cookie: bookstore.auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; httponly

At the fifth second half the window had not passed, so the cookie was not renewed. At the twelfth second it was; the new ticket shows up on the next request: issuedUtc moved to 05:28:15 and expiresUtc to 05:28:35. These two lines reveal an important detail: sliding also changes IssuedUtc on every renewal. So you cannot ask IssuedUtc "when did the user sign in?". The 401 at the 48th second does not come from sliding but from the absolute limit we are about to build.

When I waited 22 seconds without any request, the handler itself rejected the ticket and logged Cookies was not authenticated. Failure message: Ticket expired. That is the idle timeout: with sliding on, ExpireTimeSpan really means "the longest period of inactivity". If you want to turn sliding off for a particular sign-in, set AuthenticationProperties.AllowRefresh = false.

Absolute Timeout: An Upper Bound with OnValidatePrincipal

The cookie handler has no built-in absolute timeout setting. The OWASP (Open Worldwide Application Security Project) Session Management Cheat Sheet suggests an absolute timeout between 4 and 8 hours for an office worker who uses the application all day, and stresses that timeouts must be enforced on the server. We build it with the OnValidatePrincipal event. It runs on every request after the ticket is decoded and its expiry checked, and gives us the chance to reject the principal.

Since we cannot leave the sign-in moment to IssuedUtc, we write a separate value into Items at login:

var properties = new AuthenticationProperties();
properties.Items["auth_time"] = time.GetUtcNow().ToString("O", CultureInfo.InvariantCulture);

await context.SignInAsync(new ClaimsPrincipal(new ClaimsIdentity(claims, "Cookies", "name", "role")), properties);

And the validator reads it:

public static class SessionValidator
{
    public static async Task ValidateAsync(CookieValidatePrincipalContext context)
    {
        IServiceProvider services = context.HttpContext.RequestServices;
        SessionLifetimeOptions lifetime = services.GetRequiredService<IOptions<SessionLifetimeOptions>>().Value;
        TimeProvider time = services.GetRequiredService<TimeProvider>();
        ILogger logger = services.GetRequiredService<ILoggerFactory>().CreateLogger("Bookstore.Sessions");

        // IssuedUtc changes on every sliding renewal, so the absolute limit is computed from auth_time
        if (!context.Properties.Items.TryGetValue("auth_time", out string? raw)
            || DateTimeOffset.Parse(raw!, CultureInfo.InvariantCulture) + lifetime.Absolute < time.GetUtcNow())
        {
            logger.LogInformation("Session rejected: absolute lifetime exceeded for {User}", context.Principal?.Identity?.Name);
            context.RejectPrincipal();
            await context.HttpContext.SignOutAsync();
            return;
        }

        ClaimsPrincipal principal = context.Principal!;
        string userId = principal.FindFirstValue("sub")!;
        string current = await services.GetRequiredService<SessionVersionStore>().GetAsync(userId);
        if (principal.FindFirstValue("session_version") != current)
        {
            logger.LogInformation("Session rejected: session version changed for {User}", userId);
            context.RejectPrincipal();
            await context.HttpContext.SignOutAsync();
        }
    }
}

RejectPrincipal() makes the request anonymous; SignOutAsync() deletes the cookie in the browser, otherwise the browser keeps sending the dead cookie. We also reject a ticket without an auth_time value (for example an old cookie issued before this code was deployed): fail closed. The 48th second in the output above was the result of this code, and the log had this line:

info: Bookstore.Sessions[0]
      Session rejected: absolute lifetime exceeded for ada@bookstore.test

The second half of the validator, the session_version check, makes sense in the revocation section.

Server-Side Sessions: ITicketStore and Redis

Instead of putting the whole ticket into the cookie, you can put only a key there and keep the ticket on the server. When you give CookieAuthenticationOptions.SessionStore an ITicketStore, the handler writes the ticket to the store and puts into the cookie only a small ticket that contains the key returned by the store. With Redis:

public sealed class RedisTicketStore(IConnectionMultiplexer redis, TimeProvider time) : ITicketStore
{
    private readonly IDatabase _db = redis.GetDatabase();

    public async Task<string> StoreAsync(AuthenticationTicket ticket)
    {
        string key = "session:" + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(16));
        await RenewAsync(key, ticket);
        string? userId = ticket.Principal.FindFirstValue("sub");
        if (userId is not null)
        {
            await _db.SetAddAsync($"user:{userId}:sessions", key);
        }
        return key;
    }

    public async Task RenewAsync(string key, AuthenticationTicket ticket)
    {
        byte[] data = TicketSerializer.Default.Serialize(ticket);
        // The Redis key lives as long as the ticket; sliding renewals extend it
        TimeSpan ttl = (ticket.Properties.ExpiresUtc ?? time.GetUtcNow().AddHours(1)) - time.GetUtcNow();
        await _db.StringSetAsync(key, data, ttl);
    }

    public async Task<AuthenticationTicket?> RetrieveAsync(string key)
    {
        RedisValue data = await _db.StringGetAsync(key);
        return data.IsNull ? null : TicketSerializer.Default.Deserialize((byte[])data!);
    }

    public Task RemoveAsync(string key) => _db.KeyDeleteAsync(key);

    public async Task<int> RemoveAllForUserAsync(string userId)
    {
        string setKey = $"user:{userId}:sessions";
        RedisValue[] keys = await _db.SetMembersAsync(setKey);
        foreach (RedisValue key in keys)
        {
            await _db.KeyDeleteAsync((string)key!);
        }
        await _db.KeyDeleteAsync(setKey);
        return keys.Length;
    }
}

We attach the store to the cookie options through DI (Dependency Injection); since the AddCookie lambda cannot reach services, named options are used:

builder.Services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
    .Configure<RedisTicketStore>((options, store) => options.SessionStore = store);

Signing in with 120 extra claims, the 9 KB cookie split into three chunks was replaced by a single 304-character cookie. The ticket went to Redis:

$ docker exec authwork1-redis redis-cli KEYS '*'
session:af1d9fbfac5145347058f49e804071be
session:da27711f9ba69bb61d162805a45350e4
session:2011b13ed7303e9e40c8489200c6f478
user:ada@bookstore.test:sessions
$ docker exec authwork1-redis redis-cli TTL session:af1d9fbfac5145347058f49e804071be
1800
$ docker exec authwork1-redis redis-cli GET session:af1d9fbfac5145347058f49e804071be | strings | head -5
Cookies
Cookies
name
role{
ada@bookstore.test

Three things stand out. The TTL (Time To Live) is 1800 seconds, the same as the idle lifetime; sliding calls RenewAsync on every renewal and extends it. Ada signed in from two devices but there are three sessions: signing in a second time on the laptop without logging out left the first session orphaned, and it will stay in Redis until its TTL runs out. The third is more serious: TicketSerializer does not encrypt, so the claims are plain text in Redis. The ticket in the cookie was protected by Data Protection; moving it to Redis lost that protection. Isolate Redis from the network, turn on its authentication, or encrypt the ticket with an IDataProtector before writing it.

Revocation: "Sign Out of All Devices"

SignOutAsync only deletes the cookie in that browser. If a stateless cookie was copied, the copy stays valid until it expires; the server has nowhere to say "stop accepting this ticket". Revocation needs state on the server, and there are two ways to get it.

If you use server-side sessions, revocation means deleting from the store. When Ada presses "sign out of all devices" on the laptop:

app.MapPost("/sessions/revoke-all", async (HttpContext context, SessionVersionStore versions, RedisTicketStore store) =>
{
    string userId = context.User.FindFirstValue("sub")!;
    await versions.BumpAsync(userId);
    int removed = await store.RemoveAllForUserAsync(userId);
    await context.SignOutAsync();
    return TypedResults.Ok(new { removedServerSessions = removed });
}).RequireAuthorization();
laptop 200
phone 200
$ curl -s -b d1.txt -c d1.txt -X POST http://localhost:19120/sessions/revoke-all
{"removedServerSessions":3}
phone 401

info: Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationHandler[7]
      Cookies was not authenticated. Failure message: Identity missing in session store

The phone's cookie is still properly encrypted and signed; but the key inside it has no counterpart in Redis, and the handler says "Identity missing in session store".

If you want to stay with stateless cookies, the second way is a version stamp: at login write the user's current session_version into the ticket as a claim, compare it with the current value in OnValidatePrincipal, and increment the value when revoking. The same scenario without a store:

phone 200
{"removedServerSessions":0}
HTTP/1.1 401 Unauthorized
Set-Cookie: bookstore.auth=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; samesite=lax; httponly

info: Bookstore.Sessions[0]
      Session rejected: session version changed for grace@bookstore.test

ASP.NET Core Identity offers a ready-made form of this idea: SecurityStamp. The stamp is regenerated when the password changes, when the 2FA (Two-Factor Authentication) setting changes or when you call UpdateSecurityStampAsync, and SecurityStampValidator rejects cookies carrying an old stamp. The difference: Identity does not hit the database on every request; it checks at the ValidationInterval we printed above (30 minutes by default). So with default settings "sign out of all devices" in Identity can be delayed by up to 30 minutes. Shortening that interval means one more query per request; this side of Identity is covered in posts 3 and 4.

API Requests on .NET 10: 401/403 Instead of a Redirect

Before .NET 10 the cookie handler returned a 302 to the login page for an expired session too, and the Angular client received HTML (HyperText Markup Language) instead of a 401. On .NET 10, endpoints that carry IApiEndpointMetadata ([ApiController], minimal APIs (Application Programming Interfaces) that read or write JSON (JavaScript Object Notation), endpoints returning TypedResults) get 401/403. That is why you see 401 rather than 302 in the outputs above; every endpoint in the lab returns TypedResults. We saw in post 1 of the series why an endpoint returning Results.Ok still redirects.

In Production

Measure and bound. OnValidatePrincipal runs on every request; every Redis or database call you put in it adds to the latency of every request. Instead of checking the version on every request you can check at an interval as Identity does (for example, write the last check time into Items and check once a minute); then you have consciously accepted a revocation delay. Which value is right depends on how urgent revocation is: seconds at a bank, minutes at a bookstore.

Think about the moment Redis is unreachable. With server-side sessions, no Redis means nobody can sign in and nobody can stay signed in; with a version stamp every request fails. Choose this knowingly: fail closed (safe, but an outage), or continue with the last known value for a short while? On the identity side the right answer is usually fail closed.

If you run more than one instance, two things must be shared: the Data Protection key ring (otherwise one instance cannot decode the cookie another wrote) and the session store. Clean up the sets for orphaned sessions too; the user:...:sessions set above does not drop session keys that expired by TTL on its own. RemoveAllForUserAsync harmlessly tries keys that are already gone, but to stop the set from growing give it a TTL as well, or clean it up periodically.

Common Pitfalls

First, computing the absolute timeout from IssuedUtc. Because sliding updates IssuedUtc on every renewal, this check never fires; you need to store the sign-in moment separately.

Second, taking ExpireTimeSpan for the cookie's lifetime in the browser. A session cookie has no expires in the browser but the ticket has a lifetime on the server; think of them separately.

Third, taking SignOutAsync for revocation. Logout only signs out of that browser. For password changes, account lockouts and "suspicious sign-in" scenarios you need a server-side revocation mechanism.

Fourth, forgetting that the ticket is not encrypted once you move to ITicketStore. The claims in Redis are plain text; anyone who can reach Redis can read users' claims.

Fifth, not calling SignOutAsync() after RejectPrincipal(). The request becomes anonymous, but the browser keeps sending the invalid cookie on every request and the validator does the same work every time.

A Small Experiment

★☆☆ Run the lab with --SessionLifetime:Idle=00:00:20, set AllowRefresh = false at login and send a request every 5 seconds. Show with the Set-Cookie lines that the cookie is no longer renewed and the session ends at the 20th second.

★★☆ Change RedisTicketStore so that it encrypts the ticket bytes with an IDataProtector before writing to Redis. Confirm that the e-mail address no longer appears in the redis-cli GET output.

★★★ Change the version check so that it runs at most once a minute instead of on every request: write the last check time into context.Properties.Items and renew the cookie with context.ShouldRenew = true when it changes. Then measure the longest delay between "sign out of all devices" and the phone being dropped.

We built cookie lifetime and revocation ourselves. In the next post we move to the architecture of ASP.NET Core Identity, which brings most of this ready-made: UserManager, SignInManager, the stores and how to design your own user entity.