Your Logout Button Is Lying: ASP.NET Session Security Done Right
A developer receives a JIRA ticket: “Implement ISO 27001 Control A.9.4.3 for session management.” The ticket contains an 87-page PDF and a two-week deadline. The developer searches StackOverflow for “ASP.NET session timeout.” The first answer suggests Session.Timeout = Int32.MaxValue for “better user experience.”
This is how security controls become checkbox theater. The code compiles, users can log in, and the feature gets marked complete. Six months later, a penetration tester discovers that sessions never expire, and the audit finding costs more to remediate than doing it right would have.
The standard mandates outcomes: sessions must timeout after inactivity, absolute limits must exist regardless of activity, and authentication artifacts must be protected from interception. ASP.NET Core provides the primitives—cookie authentication with sliding and absolute expiration, JWT bearer tokens with refresh rotation, secure cookie flags. But these are configuration knobs, not security guarantees.
Misconfigured authentication satisfies functional requirements (users can log in) while violating every security principle (sessions never expire, tokens leak through insecure channels, logout doesn’t invalidate anything). The gap between “it works” and “it’s secure” is where breaches happen.
This article contrasts fatal misconfigurations with implementations that actually pass security audits. The examples are production patterns—configurations running in certified systems today.
What Auditors Actually Test
Security auditors don’t read your documentation. They test:
- Idle timeout: Does the session terminate after 15 minutes of inactivity?
- Absolute timeout: Does an 8-hour session expire even with continuous activity?
- Secure transmission: Are cookies
HttpOnly,Secure, andSameSite=Strict? - Logout invalidation: Does clicking logout actually destroy the token?
- Audit trail: Are session events logged for forensic analysis?
Failures delay certifications and trigger remediation projects. Worse, they indicate systemic gaps—if session management is wrong, what else got copy-pasted from StackOverflow without verification?
Fatal Example: Session Chaos
I’ve seen this configuration in production codebases. It appears functional—users authenticate, sessions persist, the application works. But it fails every security audit:
// Program.cs - Fatal session management
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// Fatal: No expiration - sessions never timeout
options.ExpireTimeSpan = TimeSpan.MaxValue;
options.SlidingExpiration = false;
// Fatal: Cookie accessible to JavaScript
options.Cookie.HttpOnly = false;
// Fatal: Cookie works over HTTP
options.Cookie.SecurePolicy = CookieSecurePolicy.None;
// Fatal: No CSRF protection
options.Cookie.SameSite = SameSiteMode.None;
// Fatal: Events not hooked for logging
options.Events = new CookieAuthenticationEvents();
});
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Fatal: Logout doesn't actually sign out
app.MapPost("/logout", async (HttpContext context) =>
{
// Just redirects - cookie remains valid!
return Results.Redirect("/");
});
app.Run();
Why this fails:
TimeSpan.MaxValue= sessions persist forever. An employee leaves, their session remains valid indefinitely.HttpOnly = false= XSS attacks steal tokens viadocument.cookie. One vulnerable dependency compromises every user.SecurePolicy = None= cookies transmit over HTTP in plaintext. Coffee shop WiFi becomes a credential harvesting operation.SameSite = None= CSRF attacks succeed. Malicious sites submit authenticated requests on behalf of victims.- No logging = zero visibility into compromises. You won’t know you’ve been breached until someone else tells you.
- Logout redirects without
SignOutAsync()= cookie remains valid. Users think they’re logged out. They’re not.
Every one of these is a direct audit finding. Together, they’re a security incident waiting for a trigger.
Secure Cookie Authentication
The compliant configuration (see Microsoft’s cookie authentication documentation for the full API reference):
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
// 15 minutes idle timeout with sliding window
options.ExpireTimeSpan = TimeSpan.FromMinutes(15);
options.SlidingExpiration = true;
// Secure cookie flags - all three are mandatory
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
options.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = async context =>
{
// Enforce 8-hour absolute timeout
var issuedUtc = context.Properties.IssuedUtc;
if (issuedUtc.HasValue &&
DateTimeOffset.UtcNow - issuedUtc.Value > TimeSpan.FromHours(8))
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync();
}
}
};
});
// Logout that actually works
app.MapPost("/logout", async (HttpContext context) =>
{
await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
context.Session.Clear();
return Results.Redirect("/");
}).RequireAuthorization();
What this achieves:
- Sliding expiration: 15-minute inactivity timeout, extended on each request
- Absolute timeout: 8-hour maximum regardless of activity (enforced in
OnValidatePrincipal) - XSS protection:
HttpOnlyprevents JavaScript access - HTTPS enforcement:
SecurePolicy.Alwaysblocks HTTP transmission - CSRF protection:
SameSite.Strictblocks cross-origin requests - Real logout:
SignOutAsync()destroys the cookie,Session.Clear()removes server state
For multi-instance deployments, persist Data Protection keys to shared storage (Azure Blob, Redis, file share). Without this, application restarts invalidate all sessions—and in a Kubernetes environment with rolling deployments, that means random logouts during every release.
JWT Tokens: Refresh Rotation
API-first architectures can’t use cookie-based sessions—there’s no browser to manage cookies. JWTs require a different approach: short-lived access tokens paired with rotating refresh tokens. The access token is stateless and self-contained; the refresh token is server-validated and revocable. Microsoft’s JWT bearer authentication documentation covers the foundational setup.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.Zero // No tolerance for expired tokens
};
});
// In production: use Redis or database, not in-memory
var refreshTokenStore = new Dictionary<string, RefreshTokenData>();
The token endpoint issues 15-minute access tokens and 7-day refresh tokens:
app.MapPost("/api/auth/token", (LoginRequest request) =>
{
var accessToken = GenerateAccessToken(userId); // 15 min expiry
var refreshToken = GenerateRefreshToken(); // Cryptographically random
refreshTokenStore[refreshToken] = new RefreshTokenData
{
UserId = userId,
ExpiresAt = DateTime.UtcNow.AddDays(7),
CreatedAt = DateTime.UtcNow // Track for absolute timeout
};
return Results.Ok(new { accessToken, refreshToken, expiresIn = 900 });
});
The refresh endpoint enforces rotation and absolute timeout:
app.MapPost("/api/auth/refresh", (RefreshRequest request) =>
{
if (!refreshTokenStore.TryGetValue(request.RefreshToken, out var tokenData))
return Results.Unauthorized();
// Check expiration
if (tokenData.ExpiresAt < DateTime.UtcNow)
{
refreshTokenStore.Remove(request.RefreshToken);
return Results.Unauthorized();
}
// Absolute timeout: 8 hours from initial login
if (DateTime.UtcNow - tokenData.CreatedAt > TimeSpan.FromHours(8))
{
refreshTokenStore.Remove(request.RefreshToken);
return Results.Unauthorized();
}
// Rotation: revoke old token, issue new one
refreshTokenStore.Remove(request.RefreshToken);
var newRefreshToken = GenerateRefreshToken();
refreshTokenStore[newRefreshToken] = tokenData with
{
ExpiresAt = DateTime.UtcNow.AddDays(7)
};
return Results.Ok(new { accessToken, refreshToken = newRefreshToken });
});
Key mechanisms:
- 15-minute access tokens limit stolen token exposure
- Token rotation prevents reuse attacks—each refresh invalidates the previous token
- Absolute timeout via
CreatedAtforces re-authentication after 8 hours - Logout revocation removes refresh token from storage immediately
Use RandomNumberGenerator for refresh tokens—64 bytes minimum. GUIDs aren’t cryptographically suitable for security tokens; they’re designed for uniqueness, not unpredictability. An attacker who can predict the next token value can forge valid refresh tokens.
Concurrent Session Limits
Some organizations require single-session enforcement—a new login invalidates existing sessions. Financial services, healthcare, and government systems often mandate this. It prevents credential sharing (one account, one user) and limits compromise impact (stealing credentials doesn’t grant parallel access). The implementation uses distributed caching for cross-instance session tracking:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
options.Events = new CookieAuthenticationEvents
{
OnSigningIn = async context =>
{
var cache = context.HttpContext.RequestServices
.GetRequiredService<IDistributedCache>();
var userId = context.Principal?.FindFirst("sub")?.Value;
if (userId is null) return;
var sessionId = Guid.NewGuid().ToString();
context.Properties.Items["SessionId"] = sessionId;
await cache.SetStringAsync(
$"session:{userId}",
sessionId,
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(8)
});
},
OnValidatePrincipal = async context =>
{
var cache = context.HttpContext.RequestServices
.GetRequiredService<IDistributedCache>();
var userId = context.Principal?.FindFirst("sub")?.Value;
var sessionId = context.Properties.Items.GetValueOrDefault("SessionId");
if (userId is null || sessionId is null) return;
var currentSession = await cache.GetStringAsync($"session:{userId}");
if (currentSession != sessionId)
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync();
}
}
};
When a user logs in from a new device, their previous session is invalidated on the next request. The Redis cache tracks which session ID is current—older sessions fail validation and force re-authentication.
This pattern also provides breach detection. If a user reports they were logged out unexpectedly, someone else authenticated with their credentials. That’s an incident worth investigating.
Practical Takeaways
Session security reduces to a few non-negotiable configurations:
Both timeout types are mandatory: 15-minute sliding expiration for idle sessions, 8-hour absolute maximum regardless of activity.
Cookie flags aren’t optional:
HttpOnly,Secure,SameSite=Strict. Each prevents a specific attack class.Logout must call
SignOutAsync(): Redirecting without destroying the token is cosmetic, not functional.JWT refresh tokens rotate: Each refresh invalidates the previous token. Store the original creation time for absolute timeout enforcement.
Log authentication events: Session creation, renewal, timeout, and logout provide the audit trail auditors expect.
Persist Data Protection keys: In multi-instance deployments, shared key storage prevents session invalidation on restarts.
These patterns aren’t theoretical—they’re what certified systems actually run. I’ve implemented them across multiple ISO 27001 and SOC 2 audits. The configurations pass because they address the actual threat model, not because they check compliance boxes.
Security controls work when enforced in code, not documented in PDFs. The auditor doesn’t care what your security policy says; they care what your application does when they test it.

Comments