A complete guide to building a multi-tenant, high-performance session state server using Web Forms Pageless Architecture
Overview
This article demonstrates how to build a custom Session State Server using ASP.NET Web Forms Pageless Architecture (WPA). Instead of relying on Microsoft’s proprietary aspnet_state.exe service, we’ll create a fully transparent, customizable solution using the same WPA principles.
Features:
- Zero ASPX pages – Pure
Application_BeginRequesthandling - Multi-tenant – Single server supports multiple applications
- Two-level storage – In-process cache + optional database persistence
- Non-blocking writes – Background queue for database operations
- Sliding expiry – Sessions automatically extend on access
- Optional security – SHA256 signature validation
- Local-only access – Binds to 127.0.0.1 for security
Architecture
Storage Strategy
Level 1: In-Process Cache (Primary)
Two-level ConcurrentDictionary for app isolation:
ConcurrentDictionary<app_id, AppStore>
Benefits:
- O(1) lookup performance
- App isolation (App A cannot access App B’s sessions)
- Thread-safe concurrent access
- Easy to clear all sessions for one app
Level 2: Database Persistence (Optional)
- Writes: Non-blocking via
BlockingCollectionqueue - Reads: Always from in-process cache (fast)
- Startup: Load all sessions from database into cache
API Specification
All requests must be:
- Method: POST only
- Content-Type:
application/x-www-form-urlencodedorapplication/json - Origin: Local only (127.0.0.1)
Actions
| Action | Description |
|---|---|
set | Store or update session data |
get | Retrieve session data (extends expiry) |
delete | Remove single session |
clear_app | Remove all sessions for an app |
extend | Extend expiry for all sessions in an app |
ping | Health check |
stats | Server statistics |
Implementation
Project Structure
Data Models
// Core/Models.cs
using System;
using System.Collections.Concurrent;
namespace StateServer
{
/// <summary>
/// App configuration (from apps.json or auto-registered)
/// </summary>
public class AppConfig
{
public string AppId { get; set; }
public string Secret { get; set; } // null = no signature required
public int DefaultTimeout { get; set; } = 20; // minutes
}
/// <summary>
/// Per-app session store
/// </summary>
public class AppStore
{
public string AppId { get; set; }
public string Secret { get; set; }
public int DefaultTimeout { get; set; } = 20;
public ConcurrentDictionary<string, SessionEntry> Sessions { get; set; }
= new ConcurrentDictionary<string, SessionEntry>();
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
/// <summary>
/// Individual session entry
/// </summary>
public class SessionEntry
{
public string Token { get; set; }
public string Text { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime LastAccessedAt { get; set; }
}
/// <summary>
/// Database operation for background queue
/// </summary>
public class DbOperation
{
public DbOpType Type { get; set; }
public string AppId { get; set; }
public string Token { get; set; }
public string Text { get; set; }
public DateTime ExpiresAt { get; set; }
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
}
public enum DbOpType
{
Set,
Delete,
ClearApp
}
}Global.asax.cs – The Complete Server
// Global.asax.cs
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Web;
using Newtonsoft.Json;
namespace StateServer
{
public class Global : HttpApplication
{
#region Static Storage
// Primary storage: App ID โ App Store
private static ConcurrentDictionary<string, AppStore> _apps
= new ConcurrentDictionary<string, AppStore>(StringComparer.OrdinalIgnoreCase);
// App configurations (from apps.json)
private static Dictionary<string, AppConfig> _appConfigs
= new Dictionary<string, AppConfig>(StringComparer.OrdinalIgnoreCase);
// Database operation queue
private static BlockingCollection<DbOperation> _dbQueue
= new BlockingCollection<DbOperation>();
// Timers
private static Timer _cleanupTimer;
private static Thread _dbWorkerThread;
private static bool _dbEnabled = false;
private static string _dbConnectionString;
// Server start time for uptime calculation
private static DateTime _startTime;
#endregion
#region Application Lifecycle
protected void Application_Start(object sender, EventArgs e)
{
_startTime = DateTime.UtcNow;
// Load app configurations
LoadAppConfigs();
// Initialize database if configured
InitializeDatabase();
// Start cleanup timer (every 1 minute)
_cleanupTimer = new Timer(CleanupExpiredSessions, null,
TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
// Start database worker thread if enabled
if (_dbEnabled)
{
_dbWorkerThread = new Thread(DatabaseWorkerLoop)
{
IsBackground = true,
Name = "StateServer-DbWorker"
};
_dbWorkerThread.Start();
// Load existing sessions from database
LoadSessionsFromDatabase();
}
}
protected void Application_End(object sender, EventArgs e)
{
// Stop accepting new db operations
_dbQueue.CompleteAdding();
// Wait for queue to drain (max 10 seconds)
if (_dbWorkerThread != null && _dbWorkerThread.IsAlive)
{
_dbWorkerThread.Join(TimeSpan.FromSeconds(10));
}
_cleanupTimer?.Dispose();
}
#endregion
#region Request Handling
protected void Application_BeginRequest(object sender, EventArgs e)
{
// Security: Local only
if (!Request.IsLocal)
{
RejectRequest("Access denied: Local connections only", 403);
return;
}
// Skip non-API requests (favicon, etc.)
string path = Request.Path.ToLower();
if (path != "/" && path != "/api" && !path.EndsWith(".aspx"))
{
return;
}
// Set JSON response type
Response.ContentType = "application/json";
try
{
// Parse request parameters (form or JSON body)
var parameters = ParseRequestParameters();
string action = GetParam(parameters, "action", "").ToLower();
switch (action)
{
case "set":
HandleSet(parameters);
break;
case "get":
HandleGet(parameters);
break;
case "delete":
HandleDelete(parameters);
break;
case "clear_app":
HandleClearApp(parameters);
break;
case "extend":
HandleExtend(parameters);
break;
case "ping":
HandlePing();
break;
case "stats":
HandleStats(parameters);
break;
default:
WriteJson(new { success = false, message = $"Unknown action: {action}" });
break;
}
}
catch (Exception ex)
{
Response.StatusCode = 500;
WriteJson(new { success = false, message = ex.Message });
}
Response.End();
}
#endregion
#region Action Handlers
private void HandleSet(Dictionary<string, string> parameters)
{
string appId = GetParam(parameters, "app_id", "");
string token = GetParam(parameters, "session_token", "");
string text = GetParam(parameters, "text", "");
string signature = GetParam(parameters, "signature", "");
int timeout = GetParamInt(parameters, "timeout", 0);
// Validate required fields
if (string.IsNullOrEmpty(appId))
{
WriteJson(new { success = false, message = "app_id is required" });
return;
}
// Get or create app store
var appStore = GetOrCreateAppStore(appId);
// Validate signature if app has secret
if (!ValidateSignature(appStore, token, text, signature))
{
Response.StatusCode = 401;
WriteJson(new { success = false, message = "Invalid signature" });
return;
}
// Generate token if not provided
if (string.IsNullOrEmpty(token))
{
token = Guid.NewGuid().ToString("N");
}
// Determine timeout
if (timeout <= 0)
{
timeout = appStore.DefaultTimeout;
}
DateTime now = DateTime.UtcNow;
DateTime expiresAt = now.AddMinutes(timeout);
// Create or update session
var session = appStore.Sessions.AddOrUpdate(token,
// Add new
key => new SessionEntry
{
Token = token,
Text = text,
CreatedAt = now,
ExpiresAt = expiresAt,
LastAccessedAt = now
},
// Update existing
(key, existing) =>
{
existing.Text = text;
existing.ExpiresAt = expiresAt;
existing.LastAccessedAt = now;
return existing;
});
// Queue database operation (non-blocking)
if (_dbEnabled)
{
_dbQueue.TryAdd(new DbOperation
{
Type = DbOpType.Set,
AppId = appId,
Token = token,
Text = text,
ExpiresAt = expiresAt
});
}
WriteJson(new
{
success = true,
session_token = token,
expires_at = expiresAt.ToString("o")
});
}
private void HandleGet(Dictionary<string, string> parameters)
{
string appId = GetParam(parameters, "app_id", "");
string token = GetParam(parameters, "session_token", "");
string signature = GetParam(parameters, "signature", "");
// Validate required fields
if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(token))
{
WriteJson(new { success = false, message = "app_id and session_token are required" });
return;
}
// Get app store
if (!_apps.TryGetValue(appId, out var appStore))
{
WriteJson(new { success = true, found = false });
return;
}
// Validate signature if app has secret
if (!ValidateSignature(appStore, token, "", signature))
{
Response.StatusCode = 401;
WriteJson(new { success = false, message = "Invalid signature" });
return;
}
// Get session
if (!appStore.Sessions.TryGetValue(token, out var session))
{
WriteJson(new { success = true, found = false });
return;
}
// Check expiry
if (DateTime.UtcNow > session.ExpiresAt)
{
appStore.Sessions.TryRemove(token, out _);
WriteJson(new { success = true, found = false, reason = "expired" });
return;
}
// Sliding expiry: extend timeout on access
session.LastAccessedAt = DateTime.UtcNow;
session.ExpiresAt = DateTime.UtcNow.AddMinutes(appStore.DefaultTimeout);
WriteJson(new
{
success = true,
found = true,
text = session.Text,
expires_at = session.ExpiresAt.ToString("o")
});
}
private void HandleDelete(Dictionary<string, string> parameters)
{
string appId = GetParam(parameters, "app_id", "");
string token = GetParam(parameters, "session_token", "");
string signature = GetParam(parameters, "signature", "");
// Validate required fields
if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(token))
{
WriteJson(new { success = false, message = "app_id and session_token are required" });
return;
}
// Get app store
if (!_apps.TryGetValue(appId, out var appStore))
{
WriteJson(new { success = true, message = "App not found" });
return;
}
// Validate signature if app has secret
if (!ValidateSignature(appStore, token, "", signature))
{
Response.StatusCode = 401;
WriteJson(new { success = false, message = "Invalid signature" });
return;
}
// Remove session
appStore.Sessions.TryRemove(token, out _);
// Queue database operation
if (_dbEnabled)
{
_dbQueue.TryAdd(new DbOperation
{
Type = DbOpType.Delete,
AppId = appId,
Token = token
});
}
WriteJson(new { success = true });
}
private void HandleClearApp(Dictionary<string, string> parameters)
{
string appId = GetParam(parameters, "app_id", "");
string signature = GetParam(parameters, "signature", "");
// Validate required fields
if (string.IsNullOrEmpty(appId))
{
WriteJson(new { success = false, message = "app_id is required" });
return;
}
// Get app store
if (!_apps.TryGetValue(appId, out var appStore))
{
WriteJson(new { success = true, cleared_count = 0 });
return;
}
// Validate signature if app has secret
if (!ValidateSignature(appStore, "", "", signature))
{
Response.StatusCode = 401;
WriteJson(new { success = false, message = "Invalid signature" });
return;
}
// Count and clear
int count = appStore.Sessions.Count;
appStore.Sessions.Clear();
// Queue database operation
if (_dbEnabled)
{
_dbQueue.TryAdd(new DbOperation
{
Type = DbOpType.ClearApp,
AppId = appId
});
}
WriteJson(new { success = true, cleared_count = count });
}
private void HandleExtend(Dictionary<string, string> parameters)
{
string appId = GetParam(parameters, "app_id", "");
string signature = GetParam(parameters, "signature", "");
int timeout = GetParamInt(parameters, "timeout", 0);
// Validate required fields
if (string.IsNullOrEmpty(appId))
{
WriteJson(new { success = false, message = "app_id is required" });
return;
}
// Get app store
if (!_apps.TryGetValue(appId, out var appStore))
{
WriteJson(new { success = true, extended_count = 0 });
return;
}
// Validate signature if app has secret
if (!ValidateSignature(appStore, "", "", signature))
{
Response.StatusCode = 401;
WriteJson(new { success = false, message = "Invalid signature" });
return;
}
// Determine timeout
if (timeout <= 0)
{
timeout = appStore.DefaultTimeout;
}
// Extend all sessions
DateTime newExpiry = DateTime.UtcNow.AddMinutes(timeout);
int count = 0;
foreach (var session in appStore.Sessions.Values)
{
session.ExpiresAt = newExpiry;
session.LastAccessedAt = DateTime.UtcNow;
count++;
}
WriteJson(new { success = true, extended_count = count });
}
private void HandlePing()
{
WriteJson(new
{
success = true,
message = "pong",
timestamp = DateTime.UtcNow.ToString("o")
});
}
private void HandleStats(Dictionary<string, string> parameters)
{
string appId = GetParam(parameters, "app_id", "");
if (!string.IsNullOrEmpty(appId))
{
// Stats for specific app
if (_apps.TryGetValue(appId, out var appStore))
{
WriteJson(new
{
success = true,
app_id = appId,
total_sessions = appStore.Sessions.Count,
created_at = appStore.CreatedAt.ToString("o")
});
}
else
{
WriteJson(new { success = true, app_id = appId, total_sessions = 0 });
}
}
else
{
// Global stats
var uptime = DateTime.UtcNow - _startTime;
WriteJson(new
{
success = true,
total_apps = _apps.Count,
total_sessions = _apps.Values.Sum(a => a.Sessions.Count),
memory_mb = Math.Round(GC.GetTotalMemory(false) / 1024.0 / 1024.0, 2),
uptime = uptime.ToString(@"d\.hh\:mm\:ss"),
db_enabled = _dbEnabled,
db_queue_size = _dbQueue.Count
});
}
}
#endregion
#region Security
private bool ValidateSignature(AppStore appStore, string token, string text, string signature)
{
// No secret configured = no validation required
if (string.IsNullOrEmpty(appStore.Secret))
{
return true;
}
// Secret configured but no signature provided
if (string.IsNullOrEmpty(signature))
{
return false;
}
// Compute expected signature: SHA256(secret + "|" + token + "|" + text)
string input = $"{appStore.Secret}|{token}|{text}";
string expected = ComputeSha256(input);
return string.Equals(signature, expected, StringComparison.OrdinalIgnoreCase);
}
private string ComputeSha256(string input)
{
using (var sha256 = SHA256.Create())
{
byte[] bytes = Encoding.UTF8.GetBytes(input);
byte[] hash = sha256.ComputeHash(bytes);
return BitConverter.ToString(hash).Replace("-", "").ToLower();
}
}
#endregion
#region App Management
private AppStore GetOrCreateAppStore(string appId)
{
return _apps.GetOrAdd(appId, id =>
{
// Check if we have config for this app
if (_appConfigs.TryGetValue(id, out var config))
{
return new AppStore
{
AppId = id,
Secret = config.Secret,
DefaultTimeout = config.DefaultTimeout
};
}
// Auto-register with defaults (no secret)
return new AppStore
{
AppId = id,
Secret = null,
DefaultTimeout = 20
};
});
}
private void LoadAppConfigs()
{
try
{
string configPath = HttpContext.Current.Server.MapPath("~/App_Data/apps.json");
if (File.Exists(configPath))
{
string json = File.ReadAllText(configPath);
var config = JsonConvert.DeserializeObject<AppsConfigFile>(json);
if (config?.Apps != null)
{
foreach (var app in config.Apps)
{
_appConfigs[app.AppId] = app;
}
}
}
}
catch
{
// Config file is optional - continue without it
}
}
private class AppsConfigFile
{
public List<AppConfig> Apps { get; set; }
}
#endregion
#region Cleanup
private void CleanupExpiredSessions(object state)
{
try
{
DateTime now = DateTime.UtcNow;
int totalRemoved = 0;
foreach (var appStore in _apps.Values)
{
var expiredTokens = appStore.Sessions
.Where(kvp => now > kvp.Value.ExpiresAt)
.Select(kvp => kvp.Key)
.ToList();
foreach (var token in expiredTokens)
{
if (appStore.Sessions.TryRemove(token, out _))
{
totalRemoved++;
// Queue database delete
if (_dbEnabled)
{
_dbQueue.TryAdd(new DbOperation
{
Type = DbOpType.Delete,
AppId = appStore.AppId,
Token = token
});
}
}
}
}
// Optional: Log cleanup stats
// System.Diagnostics.Debug.WriteLine($"Cleanup removed {totalRemoved} expired sessions");
}
catch
{
// Swallow cleanup errors - don't crash the timer
}
}
#endregion
#region Database Operations
private void InitializeDatabase()
{
try
{
string configPath = HttpContext.Current.Server.MapPath("~/App_Data/dbconfig.txt");
if (File.Exists(configPath))
{
_dbConnectionString = File.ReadAllText(configPath).Trim();
_dbEnabled = !string.IsNullOrEmpty(_dbConnectionString);
}
}
catch
{
_dbEnabled = false;
}
}
private void LoadSessionsFromDatabase()
{
if (!_dbEnabled) return;
try
{
using (var conn = new MySqlConnector.MySqlConnection(_dbConnectionString))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = @"
SELECT app_id, token, text, expires_at
FROM state_sessions
WHERE expires_at > UTC_TIMESTAMP()";
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
string appId = reader.GetString(0);
string token = reader.GetString(1);
string text = reader.IsDBNull(2) ? "" : reader.GetString(2);
DateTime expiresAt = reader.GetDateTime(3);
var appStore = GetOrCreateAppStore(appId);
appStore.Sessions[token] = new SessionEntry
{
Token = token,
Text = text,
CreatedAt = DateTime.UtcNow,
ExpiresAt = expiresAt,
LastAccessedAt = DateTime.UtcNow
};
}
}
}
}
}
catch
{
// Database load failure is not fatal - start with empty cache
}
}
private void DatabaseWorkerLoop()
{
foreach (var op in _dbQueue.GetConsumingEnumerable())
{
try
{
ProcessDbOperation(op);
}
catch
{
// Log error but continue processing queue
}
}
}
private void ProcessDbOperation(DbOperation op)
{
using (var conn = new MySqlConnector.MySqlConnection(_dbConnectionString))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
switch (op.Type)
{
case DbOpType.Set:
cmd.CommandText = @"
INSERT INTO state_sessions (app_id, token, text, expires_at, updated_at)
VALUES (@app_id, @token, @text, @expires_at, UTC_TIMESTAMP())
ON DUPLICATE KEY UPDATE
text = @text,
expires_at = @expires_at,
updated_at = UTC_TIMESTAMP()";
cmd.Parameters.AddWithValue("@app_id", op.AppId);
cmd.Parameters.AddWithValue("@token", op.Token);
cmd.Parameters.AddWithValue("@text", op.Text ?? "");
cmd.Parameters.AddWithValue("@expires_at", op.ExpiresAt);
break;
case DbOpType.Delete:
cmd.CommandText = "DELETE FROM state_sessions WHERE app_id = @app_id AND token = @token";
cmd.Parameters.AddWithValue("@app_id", op.AppId);
cmd.Parameters.AddWithValue("@token", op.Token);
break;
case DbOpType.ClearApp:
cmd.CommandText = "DELETE FROM state_sessions WHERE app_id = @app_id";
cmd.Parameters.AddWithValue("@app_id", op.AppId);
break;
}
cmd.ExecuteNonQuery();
}
}
}
#endregion
#region Helper Methods
private Dictionary<string, string> ParseRequestParameters()
{
var parameters = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// Form parameters
foreach (string key in Request.Form.AllKeys)
{
if (!string.IsNullOrEmpty(key))
{
parameters[key] = Request.Form[key];
}
}
// Query string parameters
foreach (string key in Request.QueryString.AllKeys)
{
if (!string.IsNullOrEmpty(key))
{
parameters[key] = Request.QueryString[key];
}
}
// JSON body
if (Request.ContentType?.Contains("application/json") == true)
{
try
{
Request.InputStream.Position = 0;
using (var reader = new StreamReader(Request.InputStream))
{
string json = reader.ReadToEnd();
var jsonParams = JsonConvert.DeserializeObject<Dictionary<string, object>>(json);
if (jsonParams != null)
{
foreach (var kvp in jsonParams)
{
parameters[kvp.Key] = kvp.Value?.ToString() ?? "";
}
}
}
}
catch { }
}
return parameters;
}
private string GetParam(Dictionary<string, string> parameters, string key, string defaultValue)
{
return parameters.TryGetValue(key, out var value) ? value : defaultValue;
}
private int GetParamInt(Dictionary<string, string> parameters, string key, int defaultValue)
{
if (parameters.TryGetValue(key, out var value) && int.TryParse(value, out int result))
{
return result;
}
return defaultValue;
}
private void WriteJson(object obj)
{
Response.Write(JsonConvert.SerializeObject(obj));
}
private void RejectRequest(string message, int statusCode)
{
Response.StatusCode = statusCode;
Response.ContentType = "application/json";
Response.Write(JsonConvert.SerializeObject(new { success = false, message }));
Response.End();
}
#endregion
}
}Database Schema (Optional)
-- MySQL schema for persistent session storage
CREATE TABLE state_sessions (
app_id VARCHAR(100) NOT NULL,
token VARCHAR(100) NOT NULL,
text LONGTEXT,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (app_id, token),
INDEX idx_expires (expires_at),
INDEX idx_app (app_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Cleanup job (run periodically via MySQL Event or external scheduler)
-- DELETE FROM state_sessions WHERE expires_at < UTC_TIMESTAMP();App Configuration File (Optional)
// App_Data/apps.json
{
"apps": [
{
"app_id": "abc_book_store",
"secret": "your-secret-key-here",
"default_timeout": 30
},
{
"app_id": "client_site_2",
"secret": null,
"default_timeout": 20
},
{
"app_id": "dev_app",
"secret": null,
"default_timeout": 60
}
]
}Client Library
Use this client class in your WPA web applications to communicate with the state server:
// StateServerClient.cs
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json;
namespace MyWebApp
{
public class StateServerClient
{
private readonly string _serverUrl;
private readonly string _appId;
private readonly string _secret;
public StateServerClient(string serverUrl, string appId, string secret = null)
{
_serverUrl = serverUrl.TrimEnd('/');
_appId = appId;
_secret = secret;
}
/// <summary>
/// Store session data. Returns the session token.
/// </summary>
public string Set(string token, string text, int? timeoutMinutes = null)
{
var parameters = new NameValueCollection
{
{ "action", "set" },
{ "app_id", _appId },
{ "session_token", token ?? "" },
{ "text", text ?? "" }
};
if (timeoutMinutes.HasValue)
{
parameters.Add("timeout", timeoutMinutes.Value.ToString());
}
// Add signature if secret is configured
if (!string.IsNullOrEmpty(_secret))
{
parameters.Add("signature", ComputeSignature(token ?? "", text ?? ""));
}
var response = PostRequest(parameters);
return response?.session_token;
}
/// <summary>
/// Retrieve session data. Returns null if not found.
/// </summary>
public string Get(string token)
{
var parameters = new NameValueCollection
{
{ "action", "get" },
{ "app_id", _appId },
{ "session_token", token }
};
// Add signature if secret is configured
if (!string.IsNullOrEmpty(_secret))
{
parameters.Add("signature", ComputeSignature(token, ""));
}
var response = PostRequest(parameters);
if (response?.success == true && response?.found == true)
{
return response.text;
}
return null;
}
/// <summary>
/// Retrieve and deserialize session data.
/// </summary>
public T Get<T>(string token) where T : class
{
string text = Get(token);
if (string.IsNullOrEmpty(text))
return null;
try
{
return JsonConvert.DeserializeObject<T>(text);
}
catch
{
return null;
}
}
/// <summary>
/// Store serialized object as session data.
/// </summary>
public string Set<T>(string token, T data, int? timeoutMinutes = null)
{
string text = JsonConvert.SerializeObject(data);
return Set(token, text, timeoutMinutes);
}
/// <summary>
/// Delete a session.
/// </summary>
public bool Delete(string token)
{
var parameters = new NameValueCollection
{
{ "action", "delete" },
{ "app_id", _appId },
{ "session_token", token }
};
if (!string.IsNullOrEmpty(_secret))
{
parameters.Add("signature", ComputeSignature(token, ""));
}
var response = PostRequest(parameters);
return response?.success == true;
}
/// <summary>
/// Clear all sessions for this app.
/// </summary>
public int ClearAll()
{
var parameters = new NameValueCollection
{
{ "action", "clear_app" },
{ "app_id", _appId }
};
if (!string.IsNullOrEmpty(_secret))
{
parameters.Add("signature", ComputeSignature("", ""));
}
var response = PostRequest(parameters);
return response?.cleared_count ?? 0;
}
/// <summary>
/// Extend expiry for all sessions.
/// </summary>
public int ExtendAll(int? timeoutMinutes = null)
{
var parameters = new NameValueCollection
{
{ "action", "extend" },
{ "app_id", _appId }
};
if (timeoutMinutes.HasValue)
{
parameters.Add("timeout", timeoutMinutes.Value.ToString());
}
if (!string.IsNullOrEmpty(_secret))
{
parameters.Add("signature", ComputeSignature("", ""));
}
var response = PostRequest(parameters);
return response?.extended_count ?? 0;
}
/// <summary>
/// Health check.
/// </summary>
public bool Ping()
{
try
{
var parameters = new NameValueCollection
{
{ "action", "ping" }
};
var response = PostRequest(parameters);
return response?.success == true;
}
catch
{
return false;
}
}
#region Private Methods
private dynamic PostRequest(NameValueCollection parameters)
{
using (var client = new WebClient())
{
byte[] responseBytes = client.UploadValues(_serverUrl, "POST", parameters);
string responseJson = Encoding.UTF8.GetString(responseBytes);
return JsonConvert.DeserializeObject<dynamic>(responseJson);
}
}
private string ComputeSignature(string token, string text)
{
string input = $"{_secret}|{token}|{text}";
using (var sha256 = SHA256.Create())
{
byte[] bytes = Encoding.UTF8.GetBytes(input);
byte[] hash = sha256.ComputeHash(bytes);
return BitConverter.ToString(hash).Replace("-", "").ToLower();
}
}
#endregion
}
}Usage in WPA Application
// Global.asax.cs in your main web application
public class Global : HttpApplication
{
// State server client
private static StateServerClient _stateClient;
protected void Application_Start(object sender, EventArgs e)
{
// Initialize state server client
_stateClient = new StateServerClient(
serverUrl: "http://127.0.0.1:8090",
appId: "abc_book_store",
secret: "your-secret-key-here" // null if no signature required
);
// Verify connection
if (!_stateClient.Ping())
{
throw new Exception("Cannot connect to State Server!");
}
}
protected void Application_BeginRequest(object sender, EventArgs e)
{
// Get session token from cookie
string sessionToken = Request.Cookies["sid"]?.Value;
if (string.IsNullOrEmpty(sessionToken))
{
// New visitor - generate token
sessionToken = Guid.NewGuid().ToString("N");
Response.Cookies.Add(new HttpCookie("sid", sessionToken)
{
HttpOnly = true,
Expires = DateTime.Now.AddDays(30)
});
}
// Load user session from state server
var userSession = _stateClient.Get<UserSession>(sessionToken);
// Store in request context for handlers
Context.Items["SessionToken"] = sessionToken;
Context.Items["UserSession"] = userSession;
// Handle request...
HandleRequest();
Response.End();
}
// Login handler example
private void HandleLogin(string username, string password)
{
// Validate credentials...
var user = ValidateUser(username, password);
if (user != null)
{
string sessionToken = (string)Context.Items["SessionToken"];
// Store user session in state server
var userSession = new UserSession
{
UserId = user.Id,
Username = user.Username,
IsAdmin = user.IsAdmin,
LoginTime = DateTime.UtcNow
};
_stateClient.Set(sessionToken, userSession, timeoutMinutes: 60);
// Update request context
Context.Items["UserSession"] = userSession;
}
}
// Logout handler example
private void HandleLogout()
{
string sessionToken = (string)Context.Items["SessionToken"];
_stateClient.Delete(sessionToken);
Context.Items["UserSession"] = null;
}
}
public class UserSession
{
public int UserId { get; set; }
public string Username { get; set; }
public bool IsAdmin { get; set; }
public DateTime LoginTime { get; set; }
}Deployment
Running the State Server
- Create new ASP.NET Web Forms project (empty)
- Add
Global.asaxandGlobal.asax.cswith the code above - Add
App_Data/apps.jsonfor app configuration - Optionally add
App_Data/dbconfig.txtwith MySQL connection string - Run on dedicated port (e.g., 8090)
IIS Express:
iisexpress /path:C:\StateServer /port:8090
Or configure in IIS:
- Bind to 127.0.0.1:8090 only
- Application Pool: .NET 4.8, IntegratedWeb.config
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.web>
<compilation debug="true" targetFramework="4.8" />
<httpRuntime targetFramework="4.8" maxRequestLength="10240" />
</system.web>
</configuration>Security Considerations
- Local Only: Server only accepts connections from 127.0.0.1
- Signature Validation: SHA256 signature prevents tampering when secret is configured
- No External Access: Never expose state server to public network
- Secret Rotation: Change app secrets periodically
- Firewall Rules: Block port 8090 from external access
Performance Characteristics
| Operation | Performance |
|---|---|
| Set (cache only) | < 1ms |
| Get | < 1ms |
| Delete | < 1ms |
| Set (with DB queue) | < 1ms (non-blocking) |
| DB write (background) | ~5-10ms per operation |
| Cleanup cycle | Runs every 1 minute |
Comparison with ASP.NET StateServer
| Feature | ASP.NET StateServer | WPA State Server |
|---|---|---|
| Protocol | Binary (proprietary) | HTTP + JSON |
| Inspection | Cannot view data | Full visibility |
| Multi-tenant | No | Yes (app isolation) |
| Customization | None | Full control |
| Persistence | Memory only | Memory + Database |
| Signature auth | No | Optional SHA256 |
| Monitoring | Limited | Stats API |
| Learning curve | Configuration | Code (transparent) |
Conclusion
Building your own Session State Server with WPA architecture provides complete transparency and control over session management. The two-level storage strategy (in-process cache + database) ensures both performance and persistence, while the non-blocking write queue keeps response times minimal.
This approach demonstrates the power of WPA architecture: a fully functional, production-ready service built with zero ASPX pages, pure Application_BeginRequest handling, and complete visibility into every aspect of the system.
This implementation serves as both a practical session state solution and a demonstration of WPA architecture principles applied to infrastructure services.
Photo by A Chosen Soul on Unsplash
