Once the routing shim is up and the first .NET 8 slice is answering a few URLs, the next thing that breaks is session. A user signs in on the old WebForms front door, clicks into the rebuilt slice, and the slice has no idea who they are or what is in their cart, their wizard, or their Session["SelectedBranchId"]. On a Classic ASP front end it is worse, because Session there is process-local by default and nothing outside the ASP worker can read it.
This tutorial builds the bridge: one shared store both sides can read, a small amount of code on each side, and a written list of which session keys are allowed to cross. It assumes you already have the legacy app and the new slice behind one hostname (we use YARP for that) and that sign-in itself is already shared. Session is the state after sign-in.
Decide what actually needs to cross
Do this before writing code. Dump the session keys the legacy app touches:
# WebForms / Classic ASP source tree
grep -rhoiE 'Session\s*\(\s*"[^"]+"\s*\)|Session\s*\[\s*"[^"]+"\s*\]' . \
| grep -oiE '"[^"]+"' | sort | uniq -c | sort -rn
On a mid-sized WebForms application this usually returns somewhere between 20 and 60 distinct keys. Most of them do not need to cross. Sort each one into three buckets:
- Identity and tenancy. User id, role list, selected company or branch, effective permissions. These must cross, and they must be trustworthy.
- Workflow state. Multi-page wizards, a shopping basket, "the record I was editing". These cross only if a workflow spans both systems. If a whole workflow lives inside one slice, keep its state local.
- Scratch. Caches, last-sort-order, a
DataTablesomeone parked in session to avoid a second query. These do not cross. Rebuild them on the new side.
Write the surviving list down as a contract. Ours is usually five to ten keys. Everything not on the list is invisible across the bridge, on purpose. This is the step that keeps a session bridge from turning into a permanent shared-mutable-state problem.
Stand up the shared store
Use SQL Server if the legacy app already depends on it, which it almost certainly does. That avoids introducing Redis into a change window and means the store is covered by the backup and failover the customer already runs.
CREATE TABLE dbo.BridgeSession
(
SessionKey CHAR(36) NOT NULL PRIMARY KEY,
Payload NVARCHAR(MAX) NOT NULL, -- JSON, allow-listed keys only
UpdatedUtc DATETIME2(3) NOT NULL,
ExpiresUtc DATETIME2(3) NOT NULL
);
CREATE INDEX IX_BridgeSession_ExpiresUtc ON dbo.BridgeSession (ExpiresUtc);
SessionKey is a GUID we mint ourselves and put in its own cookie. Do not reuse the ASP.NET ASP.NET_SessionId or Classic ASP ASPSESSIONID... value as the key. Those are owned by the platform, they rotate on events you do not control, and on Classic ASP the cookie name changes per application instance.
Expire rows on a schedule rather than in request code:
DELETE TOP (5000) FROM dbo.BridgeSession WHERE ExpiresUtc < SYSUTCDATETIME();
A SQL Agent job every five minutes is enough. Sizing: at 500 concurrent users and a 1 KB payload, the whole table is under a megabyte.
Write from the legacy side
WebForms
A module keeps this out of every page. It mints the cookie if absent, and writes the allow-listed keys at the end of each request.
public class BridgeSessionModule : IHttpModule
{
static readonly string[] Allowed =
{ "UserId", "CompanyId", "SelectedBranchId", "RoleList", "EffectivePermissions" };
public void Init(HttpApplication app)
{
app.PostAcquireRequestState += (s, e) => EnsureCookie(HttpContext.Current);
app.PostRequestHandlerExecute += (s, e) => Flush(HttpContext.Current);
}
static void EnsureCookie(HttpContext ctx)
{
if (ctx.Session == null) return;
if (ctx.Request.Cookies["bridge_sid"] != null) return;
var cookie = new HttpCookie("bridge_sid", Guid.NewGuid().ToString("D"))
{
HttpOnly = true, Secure = true, Path = "/"
};
cookie.SameSite = SameSiteMode.Lax;
ctx.Response.Cookies.Add(cookie);
ctx.Items["bridge_sid"] = cookie.Value;
}
static void Flush(HttpContext ctx)
{
if (ctx.Session == null) return;
var sid = ctx.Request.Cookies["bridge_sid"]?.Value
?? ctx.Items["bridge_sid"] as string;
if (sid == null) return;
var payload = new Dictionary<string, string>();
foreach (var key in Allowed)
{
var value = ctx.Session[key];
if (value == null) continue;
if (!(value is string || value is ValueType))
throw new InvalidOperationException(
$"Session key '{key}' is a {value.GetType().Name}; the bridge carries scalars only.");
payload[key] = Convert.ToString(value, CultureInfo.InvariantCulture);
}
BridgeStore.Upsert(sid, payload, ctx.Session.Timeout);
}
public void Dispose() { }
}
The type check is deliberate. Someone will eventually put a DataSet in session and the bridge should refuse it loudly at the source rather than serialize half of it.
BridgeStore.Upsert is one statement:
MERGE dbo.BridgeSession AS t
USING (SELECT @sid AS SessionKey) AS s ON t.SessionKey = s.SessionKey
WHEN MATCHED THEN UPDATE SET Payload = @payload,
UpdatedUtc = SYSUTCDATETIME(),
ExpiresUtc = DATEADD(MINUTE, @timeout, SYSUTCDATETIME())
WHEN NOT MATCHED THEN INSERT (SessionKey, Payload, UpdatedUtc, ExpiresUtc)
VALUES (@sid, @payload, SYSUTCDATETIME(),
DATEADD(MINUTE, @timeout, SYSUTCDATETIME()));
Skip the write when the payload is unchanged from the value you read at the start of the request. On a chatty WebForms app with postbacks on every control, that one comparison removes most of the database traffic the bridge would otherwise add.
Classic ASP
No modules, so it is an include called from the top of the pages that matter, plus a flush at the bottom. Keep it to the handful of pages that lead into the new slice rather than the whole site.
<%
Dim sid, cookies
sid = Request.Cookies("bridge_sid")
If Len(sid) = 0 Then
sid = Left(CreateObject("Scriptlet.TypeLib").Guid, 38)
sid = Mid(sid, 2, 36)
Response.Cookies("bridge_sid") = sid
Response.Cookies("bridge_sid").Path = "/"
Response.Cookies("bridge_sid").HttpOnly = True
Response.Cookies("bridge_sid").Secure = True
End If
Dim json
json = "{""UserId"":""" & Replace(CStr(Session("UserId")), """", "") & """," & _
"""CompanyId"":""" & Replace(CStr(Session("CompanyId")), """", "") & """}"
Dim cmd
Set cmd = Server.CreateObject("ADODB.Command")
cmd.ActiveConnection = ConnString
cmd.CommandText = "EXEC dbo.BridgeSessionUpsert ?, ?, ?"
cmd.Parameters.Append cmd.CreateParameter("@sid", 129, 1, 36, sid)
cmd.Parameters.Append cmd.CreateParameter("@payload", 203, 1, -1, json)
cmd.Parameters.Append cmd.CreateParameter("@timeout", 3, 1, , Session.Timeout)
cmd.Execute
Set cmd = Nothing
%>
Build the JSON by hand-escaping as above, or with a stored procedure that takes discrete parameters and does the JSON assembly in T-SQL. Do not concatenate unescaped session values into JSON; a customer name with a quote in it will produce an unparseable payload that the new side has to guess about.
Read from the .NET 8 side
Read once per request, expose it as a typed object, never write back. One-way traffic is what makes the bridge reviewable.
public sealed record BridgeSession(
string? UserId, string? CompanyId, string? SelectedBranchId, string[] Roles);
public sealed class BridgeSessionMiddleware(RequestDelegate next, IBridgeStore store)
{
public async Task Invoke(HttpContext ctx)
{
var sid = ctx.Request.Cookies["bridge_sid"];
if (sid is { Length: 36 } && Guid.TryParse(sid, out _))
{
var session = await store.TryRead(sid, ctx.RequestAborted);
if (session is not null) ctx.Items[nameof(BridgeSession)] = session;
}
await next(ctx);
}
}
Registration and a guard, so a slice that needs bridged state fails closed instead of rendering an empty page:
app.UseMiddleware<BridgeSessionMiddleware>();
app.MapGet("/orders/open", (HttpContext ctx) =>
{
if (ctx.Items[nameof(BridgeSession)] is not BridgeSession s || s.CompanyId is null)
return Results.Redirect("/legacy/login.aspx?reason=session");
return Results.Ok(OpenOrdersFor(s.CompanyId));
});
Two rules we hold to on every engagement:
- The bridge is not authentication. It carries the user id so the slice can scope a query. Authorization decisions come from the auth cookie or token the slice validates itself. If the only thing standing between an anonymous request and a customer's orders is a GUID cookie and a row in a table, you have built a session-fixation hole.
- Read-only in one direction. The legacy app writes, the new slice reads. If a slice genuinely must hand state back — a wizard that starts new and finishes old — pass it explicitly in the redirect or a signed token, not by writing into the shared row. Two writers on one JSON blob will lose updates the week you stop watching, and the loss will be silent.
Prove it with the harness
This is testable, which means it belongs in the characterization suite rather than in a click-through checklist. Drive the legacy login, then call the new slice with the same cookie jar:
[Fact]
public async Task BridgedSession_ScopesNewSliceToSameCompany()
{
var jar = new CookieContainer();
using var client = new HttpClient(new HttpClientHandler
{ CookieContainer = jar, AllowAutoRedirect = false })
{ BaseAddress = new Uri(BaseUrl) };
await LegacyLogin(client, "qa.user", "…"); // WebForms sign-in
await client.GetAsync("/branch/select.aspx?id=7"); // sets SelectedBranchId
var response = await client.GetAsync("/orders/open"); // new .NET 8 slice
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("\"branchId\":7", body);
}
Add the negative case as well: a request to /orders/open with no bridge_sid cookie, asserting the redirect. And one for expiry — set ExpiresUtc in the past, then assert the slice treats the session as absent rather than reading a stale row.
What to watch for
- Web farms and sticky sessions. If the legacy app runs InProc session behind a load balancer with sticky sessions, the bridge row is the only state that is not sticky. That is fine, but note it: the new slice will happily answer on a node the legacy session does not live on, and any bug where it silently falls back to "no session" will look like an intermittent load-balancer fault.
- Timeout mismatch. The legacy
sessionState timeoutand yourExpiresUtcmust agree, and both must be refreshed on legacy activity, not new-slice activity. A user who spends 30 minutes inside the new slice and clicks back into WebForms should get a consistent answer from both, not a live slice and a dead front end. - Cookie scope.
Path = "/"and one hostname. If the slice is served from a subdomain instead of a path, you are intoDomain=cookies and a wider blast radius; routing the slice under the same host through the shim is usually less work than getting cross-subdomain cookies right. - Retirement. The bridge is scaffolding and should be dated. When the last legacy page that writes a bridged key is gone, the module, the table and the cookie all go with it. Put that on the strangler plan when you build it, not later. We have seen a "temporary" session bridge outlive the migration it was built for and become the thing nobody dares touch.
If you are in the middle of this and the session keys will not sort cleanly into those three buckets, that is usually a sign the slice boundary is in the wrong place rather than a sign you need a bigger bridge. Moving the boundary is cheaper than the bridge, and we would rather tell you that than help you build the bridge.