Session Management in Pageless ASP.NET Web Forms (Self-Manage)

The framework’s built-in session doesn’t work in Pageless Architecture. Here’s why, and what to do about it.

ASP.NET Framework’s default built-in session management is not available in Pageless Architecture, therefore, we have to self manage it.

Content:

  • What Is Session State
  • Benefits of Self-Managing Session State
  • The Code (2 Levels Session Handling)
  • Why the Framework’s Built-in Session Doesn’t Work

What Is Session State

HTTP is stateless. Every request arrives with no memory of the previous one. Session state is the mechanism that bridges this gap — it lets the server remember who a user is across multiple requests.

In a typical login flow, the user submits credentials, the server validates them and stores a user object in memory, tagged with a unique session ID. That session ID is sent back to the browser as a cookie. On every subsequent request, the browser sends the cookie, the server reads the session ID, looks up the stored data, and knows who’s making the request.

In traditional ASP.NET Web Forms, this is handled by the framework automatically:

// Store a user object after login
Session["LoginUser"] = user;

// Retrieve it on any subsequent request
obUser user = Session["LoginUser"] as obUser;

The framework generates the session ID, sets the ASP.NET_SessionId cookie, manages the in-process memory store, and maps the cookie back to your data on every request. The developer never sees the machinery.

In Pageless Architecture, this machinery doesn’t activate. HttpContext.Current.Session is null on every request. The reason is explained at the end of this article. The practical consequence is straightforward: we manage session state ourselves.

And as it turns out, that’s a better outcome.

Benefits of Self-Managing Session State

When the framework manages session, it’s a black box. Data goes in, data comes out, and you trust the framework to handle the middle. When you manage session yourself, every step is visible and under your control.

Transparency. You can see exactly what happens on every request: cookie read, token lookup, user loaded. No hidden framework events, no IRequiresSessionState interfaces, no pipeline stage dependencies. When something goes wrong, you debug your own code, not the framework’s internals.

Visibility into active sessions. A ConcurrentDictionary holding all sessions gives you a live view. How many users are logged in right now? Who are they? When did they last make a request? Framework session provides none of this without third-party tooling.

Real-time control. Change a user’s role in the database and update their session entry — the new permissions take effect immediately. Need to force-logout a specific user? Remove their entry from the dictionary. With framework session, you can’t reach into another user’s session.

Predictable behavior. Framework session depends on the IIS handler pipeline in ways that are non-obvious and fragile. Self-managed session works the same way regardless of what handler IIS resolves, what pipeline stage runs, or whether .aspx files exist. It works because you wrote it. In self manage session, you do not wait for the IIS session, you do not wait for approval for IIS to provide that session state info. You own the session mechanics. You decide how and when it is available.

Consistent architecture. Pageless strips away ViewState, PostBack, Server Controls, and code-behind pages to expose the raw HTTP processing engine. Self-managing session completes that picture — you own the full HTTP cycle from request to response, with no framework abstractions in the way.

No dependency on fragile IIS/ASP.NET internals. The built-in session relies on the SessionStateModule correctly activating, which historically depends on the resolved handler implementing the marker interface IRequiresSessionState. In extensionless (“pageless”) setups, this activation can be unreliable or require specific web.config tweaks (such as runAllManagedModulesForAllRequests="true" combined with careful handler registration). The custom approach works reliably at Application_BeginRequest without any such tricks.

Performance and architectural purity. Stateless endpoints can be handled entirely at BeginRequest with zero session overhead. The two-level cache (fast in-memory + durable database) gives you the best of both worlds with minimal latency after the first post-restart request.

The Code

Overview

The session system has two levels:

Level 1 — In-Process Memory. A ConcurrentDictionary<string, SessionEntry> holds active sessions. Every request reads the session token from a cookie and looks it up here. This is a hash table lookup — effectively zero cost.

Level 2 — Database. A login_sessions table stores token-to-user mappings. This is the durable layer. When the application restarts (app pool recycle, deployment, server reboot), Level 1 is empty. The first request from each user misses Level 1, hits Level 2, reloads the session, and repopulates Level 1. From the user’s perspective, nothing happened — maybe one request was a millisecond slower.

The cookie holds a random token. Not a user ID, not a username — a cryptographically random string that means nothing outside your server.

SessionEntry

The data stored per session. This is not the full user object — it’s the minimum needed to identify and authorize the user within a request.

public class SessionEntry
{
    public int UserId { get; set; }
    public string Username { get; set; }
    public string DisplayName { get; set; }
    public int UserRole { get; set; }
    public DateTime Expiry { get; set; }
    public DateTime LastAccess { get; set; }
}

Level 1: In-Process Memory (SessionStore)

using System.Collections.Concurrent;

public static class SessionStore
{
    static readonly ConcurrentDictionary<string, SessionEntry> _sessions
        = new ConcurrentDictionary<string, SessionEntry>();

    public static SessionEntry Get(string token)
    {
        SessionEntry entry;
        if (!_sessions.TryGetValue(token, out entry))
            return null;

        if (entry.Expiry < DateTime.UtcNow)
        {
            _sessions.TryRemove(token, out _);
            return null;
        }

        entry.LastAccess = DateTime.UtcNow;
        return entry;
    }

    public static void Set(string token, SessionEntry entry)
    {
        _sessions[token] = entry;
    }

    public static void Remove(string token)
    {
        _sessions.TryRemove(token, out _);
    }

    /// <summary>Number of active sessions right now.</summary>
    public static int ActiveCount => _sessions.Count;

    /// <summary>Enumerate all active sessions (admin dashboard, monitoring).</summary>
    public static IEnumerable<SessionEntry> ActiveSessions => _sessions.Values;
}

Level 2: Database (SessionDb)

The database table:

CREATE TABLE login_sessions (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT NOT NULL,
    token VARCHAR(64) NOT NULL,
    date_created DATETIME NOT NULL,
    date_expiry DATETIME NOT NULL,
    UNIQUE KEY (token),
    INDEX (user_id),
    INDEX (date_expiry)
);

The token column is indexed and unique. Lookups are sub-millisecond.

public static class SessionDb
{
    /// <summary>
    /// Store a new session token in the database.
    /// </summary>
    public static void CreateSession(int userId, string token, DateTime expiry)
    {
        using (MySqlConnection conn = config.GetMainConnection())
        {
            conn.Open();
            using (MySqlCommand cmd = conn.CreateCommand())
            {
                MySqlExpress m = new MySqlExpress(cmd);
                var dic = new Dictionary<string, object>();
                dic["user_id"] = userId;
                dic["token"] = token;
                dic["date_created"] = DateTime.UtcNow;
                dic["date_expiry"] = expiry;
                m.Insert("login_sessions", dic);
            }
        }
    }

    /// <summary>
    /// Attempt to restore a session from the database.
    /// Called when Level 1 (memory) has no matching entry.
    /// Joins with the users table to load current user data.
    /// </summary>
    public static SessionEntry TryRestore(string token)
    {
        using (MySqlConnection conn = config.GetMainConnection())
        {
            conn.Open();
            using (MySqlCommand cmd = conn.CreateCommand())
            {
                MySqlExpress m = new MySqlExpress(cmd);
                var p = new Dictionary<string, object>();
                p["@token"] = token;
                p["@now"] = DateTime.UtcNow;

                obUser user = m.GetObject<obUser>(
                    "SELECT u.* FROM login_sessions ls " +
                    "JOIN users u ON u.id = ls.user_id " +
                    "WHERE ls.token = @token AND ls.date_expiry > @now " +
                    "LIMIT 1;", p);

                if (user == null || user.Id == 0)
                    return null;

                return new SessionEntry
                {
                    UserId = user.Id,
                    Username = user.Username,
                    DisplayName = user.DisplayName,
                    UserRole = user.UserRole,
                    Expiry = DateTime.UtcNow.AddDays(30),
                    LastAccess = DateTime.UtcNow
                };
            }
        }
    }

    /// <summary>Delete a specific session (logout).</summary>
    public static void DeleteSession(string token)
    {
        using (MySqlConnection conn = config.GetMainConnection())
        {
            conn.Open();
            using (MySqlCommand cmd = conn.CreateCommand())
            {
                MySqlExpress m = new MySqlExpress(cmd);
                var p = new Dictionary<string, object>();
                p["@token"] = token;
                m.Execute("DELETE FROM login_sessions WHERE token = @token;", p);
            }
        }
    }

    /// <summary>Delete all sessions for a user (password change, security reset).</summary>
    public static void DeleteAllForUser(int userId)
    {
        using (MySqlConnection conn = config.GetMainConnection())
        {
            conn.Open();
            using (MySqlCommand cmd = conn.CreateCommand())
            {
                MySqlExpress m = new MySqlExpress(cmd);
                var p = new Dictionary<string, object>();
                p["@uid"] = userId;
                m.Execute("DELETE FROM login_sessions WHERE user_id = @uid;", p);
            }
        }
    }

    /// <summary>Periodic cleanup of expired rows.</summary>
    public static void CleanupExpired()
    {
        using (MySqlConnection conn = config.GetMainConnection())
        {
            conn.Open();
            using (MySqlCommand cmd = conn.CreateCommand())
            {
                MySqlExpress m = new MySqlExpress(cmd);
                var p = new Dictionary<string, object>();
                p["@now"] = DateTime.UtcNow;
                m.Execute("DELETE FROM login_sessions WHERE date_expiry < @now;", p);
            }
        }
    }
}

Token Generation and Cookie Helpers

public static class SessionHelper
{
    const string CookieName = "sid";

    public static string GenerateToken()
    {
        byte[] bytes = new byte[32];
        using (var rng = new System.Security.Cryptography.RNGCryptoServiceProvider())
        {
            rng.GetBytes(bytes);
        }
        return BitConverter.ToString(bytes).Replace("-", "").ToLower(); // 64-char hex
    }

    public static void SetCookie(string token, DateTime expiry)
    {
        HttpCookie cookie = new HttpCookie(CookieName, token)
        {
            Expires = expiry,
            HttpOnly = true,
            Secure = HttpContext.Current.Request.IsSecureConnection,
            SameSite = SameSiteMode.Lax,
            Path = "/"
        };
        HttpContext.Current.Response.Cookies.Set(cookie);
    }

    public static string ReadCookie()
    {
        HttpCookie cookie = HttpContext.Current?.Request.Cookies[CookieName];
        return (cookie?.Value + "").Trim();
    }

    public static void DeleteCookie()
    {
        HttpCookie cookie = new HttpCookie(CookieName, "")
        {
            Expires = new DateTime(1970, 1, 1),
            HttpOnly = true,
            Path = "/"
        };
        HttpContext.Current.Response.Cookies.Set(cookie);
    }
}

AppSession — The Access Point

A static class for accessing the current user within any request handler:

public static class AppSession
{
    public static SessionEntry Current
    {
        get { return HttpContext.Current?.Items["CurrentUser"] as SessionEntry; }
    }

    public static bool IsLoggedIn => Current != null;
    public static bool IsAdmin => Current != null && Current.UserRole >= 2;
}

The Request Pipeline (Global.asax.cs)

Everything runs from Application_BeginRequest. Not Application_PostAcquireRequestState — that event only matters when waiting for the framework’s session module, which we are not using.

public class Global : HttpApplication
{
    protected void Application_BeginRequest(object sender, EventArgs e)
    {
        string path = Request.Path.ToLower().Trim().TrimEnd('/');

        // Skip static files
        if (path.StartsWith("/css/") || path.StartsWith("/js/") || path.StartsWith("/media/"))
            return;

        // --- Session restoration ---
        string token = SessionHelper.ReadCookie();

        if (!string.IsNullOrEmpty(token))
        {
            // Level 1: check in-process memory
            SessionEntry entry = SessionStore.Get(token);

            if (entry == null)
            {
                // Level 2: check database
                entry = SessionDb.TryRestore(token);
                if (entry != null)
                    SessionStore.Set(token, entry); // repopulate L1
            }

            if (entry != null)
                HttpContext.Current.Items["CurrentUser"] = entry;
        }

        // --- Routing ---
        switch (path)
        {
            case "":
            case "/dashboard":
                Dashboard.HandleRequest();
                return;
            case "/login":
                Login.HandleRequest();
                return;
            case "/api/login":
                LoginApi.HandleRequest();
                return;
            case "/logout":
                Logout.HandleRequest();
                return;
            // ... other routes
        }
    }
}

Login Flow

public static void DoLogin(string username, string password)
{
    // Validate credentials against database
    obUser user = /* fetch and verify from users table */;

    string token = SessionHelper.GenerateToken();
    DateTime expiry = DateTime.UtcNow.AddDays(30);

    // Level 2: persist to database (survives restarts)
    SessionDb.CreateSession(user.Id, token, expiry);

    // Level 1: cache in memory (fast access)
    SessionStore.Set(token, new SessionEntry
    {
        UserId = user.Id,
        Username = user.Username,
        DisplayName = user.DisplayName,
        UserRole = user.UserRole,
        Expiry = expiry,
        LastAccess = DateTime.UtcNow
    });

    // Set cookie in browser
    SessionHelper.SetCookie(token, expiry);
}

Logout Flow

public static void DoLogout()
{
    string token = SessionHelper.ReadCookie();

    if (!string.IsNullOrEmpty(token))
    {
        SessionStore.Remove(token);   // clear L1
        SessionDb.DeleteSession(token); // clear L2
    }

    SessionHelper.DeleteCookie();       // clear browser cookie
}

Usage in Request Handlers

public class Dashboard
{
    public static void HandleRequest()
    {
        if (!AppSession.IsLoggedIn)
        {
            HttpContext.Current.Response.Redirect("/login");
            return;
        }

        string name = AppSession.Current.DisplayName;
        int role = AppSession.Current.UserRole;
        // ... render dashboard
    }
}

Choosing Your Level

Not every application needs both levels.

Level 1 only (ephemeral): Good for single-server internal tools, admin panels, and applications where an occasional sign-out after deployment is acceptable. No database table needed. Sessions live in ConcurrentDictionary and die on app restart. Simpler to implement, zero external dependencies.

Application restarts happen when you update web.config, deploy new DLLs to bin/, modify files in App_Code/, recycle the app pool, or restart the server. For applications where the developer deploys occasionally, a disrupted user sees a sign-out and logs back in. For most internal tools, this is fine.

Level 1 + Level 2 (persistent): Good for public-facing applications, sites with long-lived sessions (“remember me”), and environments where app pool recycles happen frequently. The database table stores the minimum — a token-to-user-ID mapping with an expiry date. Not the entire user object, just enough to retrace the user from the users table. On an L1 miss, the system queries the database, reloads the session, and repopulates L1. The user never notices.

This Is Standard Industry Practice

Storing session tokens in a database is how most web frameworks work. PHP’s default session handler stores session data in files on disk. Django stores sessions in a django_session database table. Rails has ActiveRecord::SessionStore. Laravel’s database session driver writes to a sessions table. Express.js with connect-pg-simple stores sessions in PostgreSQL.

They all follow the same pattern: browser holds a token cookie, server maps that token to a user via a persistent store. The ConcurrentDictionary L1 cache in this design is actually more than most frameworks provide by default — most of them hit the database or file system on every single request.

Why the Framework’s Built-in Session Doesn’t Work

The ASP.NET session module (SessionStateModule) does not activate unconditionally. On every request, it checks whether the resolved IIS handler implements the IRequiresSessionState interface. If the handler does not implement it, the session module skips the request entirely. HttpContext.Current.Session remains null.

In traditional Web Forms, every .aspx request is handled by PageHandlerFactory, which implements IRequiresSessionState. The session module sees this interface, activates, reads the ASP.NET_SessionId cookie, loads the session data from the in-process store, and populates HttpContext.Current.Session. This happens automatically and invisibly.

In Pageless Architecture, there are no .aspx files. No physical file means no PageHandlerFactory. No PageHandlerFactory means no IRequiresSessionState. No IRequiresSessionState means the session module does nothing.

Setting runAllManagedModulesForAllRequests="true" in web.config does not solve this. That setting ensures managed modules run for all requests, but the session module still checks for IRequiresSessionState before it activates. The module runs, sees no interface, and exits.

It is possible to work around this by registering a custom IHttpHandler that implements IRequiresSessionState as a catch-all handler in web.config:

public class SessionRouteHandler : IHttpHandler, IRequiresSessionState
{
    public bool IsReusable => true;
    public void ProcessRequest(HttpContext context) { }
}
<handlers>
    <add name="SessionHandler" path="*" verb="*"
         type="MyApp.SessionRouteHandler"
         resourceType="Unspecified"
         preCondition="integratedMode,runtimeVersionv4.0" />
</handlers>

This works — it tricks the session module into activating by presenting a handler that claims to need session state. But it introduces side effects: the catch-all handler intercepts static file requests (.css, .js, images), it adds an invisible framework dependency that breaks if the handler registration is misconfigured, and the behavior depends on IIS handler resolution order, which is non-obvious.

Self-managing session avoids all of this. No dependency on handler interfaces, no pipeline stage requirements, no web.config tricks. The session works because your code reads a cookie, checks a dictionary, and loads a user. Every step is explicit, every step is yours.

The web.config You Still Need

The web.config handler registration is still necessary in Pageless Architecture, but for routing — not for session:

<system.webServer>
    <modules runAllManagedModulesForAllRequests="true" />
    <handlers>
        <remove name="PageHandlerFactory-Integrated" />
        <remove name="PageHandlerFactory-Integrated-4.0" />
        <add name="PageHandlerFactory-Integrated-4.0" path="*.aspx" verb="*"
             type="System.Web.UI.PageHandlerFactory"
             resourceType="Unspecified" requireAccess="Script"
             preCondition="integratedMode,runtimeVersionv4.0" />
    </handlers>
</system.webServer>

The resourceType="Unspecified" tells IIS not to check whether the requested file physically exists before passing the request to the managed pipeline. Without it, IIS returns 404 for extensionless URLs like /login or /api/login before your code ever runs. This setting ensures requests reach Application_BeginRequest, where your session restoration and routing logic takes over.