Skip to main content

Strangler Fig Strategy: Sharing Auth Cookies Between Legacy ASP.NET and Next.js

 

The Hook: The "Unauthenticated" Glitch

You are migrating a monolithic ASP.NET application to Next.js using the Strangler Fig pattern. You have set up a reverse proxy (YARP, Nginx, or CloudFront) to route /app to Next.js and everything else to the legacy backend.

The user logs in via the legacy ASP.NET login form. They are redirected to the homepage successfully. Then, they click a link pointing to the new Next.js dashboard.

Result: They are immediately redirected back to the login page.

Despite the browser sending the .AspNetCore.Cookies (or .ASPXAUTH) cookie with every request, Next.js treats the user as a stranger. You effectively have two isolated silos sharing a domain but failing to share state.

The Root Cause: Incompatible Cryptography

The issue is not network routing; it is cryptographic serialization.

  1. Serialization: When a user logs in, ASP.NET creates an AuthenticationTicket. It serializes this .NET object into binary.
  2. Encryption: It encrypts and signs this binary data using the ASP.NET Data Protection API (DPAPI) in .NET Core, or machineKey in .NET Framework.
  3. The Cookie: This encrypted blob is stored in the cookie.

When Next.js (Node.js/Edge Runtime) receives this cookie, it faces two insurmountable walls:

  1. Decryption: It does not natively possess the DPAPI ring or the logic to decrypt the machineKey format.
  2. Deserialization: Even if you decrypted it, the payload is a serialized C# object. Node.js cannot hydrate a C# ClaimsPrincipal object.

To bridge this gap, we must abandon the proprietary .NET Ticket format in favor of an interoperable standard: JWT (JSON Web Token).

The Fix: The JWT-Cookie Bridge

We will not rewrite the login logic yet. Instead, we will modify the legacy ASP.NET app to package the session into a format Next.js can read, and configure Next.js to verify it.

Step 1: Configure Legacy ASP.NET to Issue JWT Cookies

Instead of letting ASP.NET serialize the ticket its default way, we inject a custom ITicketFormat. This forces the authentication middleware to serialize the user session as a signed JWT stored inside the cookie.

Prerequisites: Install System.IdentityModel.Tokens.Jwt via NuGet.

File: AuthenticationExtensions.cs (ASP.NET Core)

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

public class JwtTicketFormat : ISecureDataFormat<AuthenticationTicket>
{
    private readonly string _algorithm;
    private readonly TokenValidationParameters _validationParameters;

    public JwtTicketFormat(string algorithm, TokenValidationParameters validationParameters)
    {
        _algorithm = algorithm;
        _validationParameters = validationParameters;
    }

    // 1. Serialize: Called when ASP.NET creates the cookie
    public string Protect(AuthenticationTicket data) => Protect(data, null);

    public string Protect(AuthenticationTicket data, string? purpose)
    {
        var handler = new JwtSecurityTokenHandler();
        
        // Extract claims from the existing ASP.NET identity
        var principal = data.Principal;
        var securityKey = _validationParameters.IssuerSigningKey;
        var credentials = new SigningCredentials(securityKey, _algorithm);

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(principal.Claims),
            Expires = data.Properties.ExpiresUtc?.UtcDateTime ?? DateTime.UtcNow.AddHours(1),
            SigningCredentials = credentials,
            Issuer = _validationParameters.ValidIssuer,
            Audience = _validationParameters.ValidAudience
        };

        var token = handler.CreateToken(tokenDescriptor);
        return handler.WriteToken(token);
    }

    // 2. Deserialize: Called when ASP.NET reads the cookie
    public AuthenticationTicket? Unprotect(string? protectedText) => Unprotect(protectedText, null);

    public AuthenticationTicket? Unprotect(string? protectedText, string? purpose)
    {
        if (string.IsNullOrEmpty(protectedText)) return null;

        var handler = new JwtSecurityTokenHandler();
        try
        {
            var principal = handler.ValidateToken(protectedText, _validationParameters, out var validToken);
            
            // Reconstruct the AuthenticationTicket for ASP.NET's internal use
            return new AuthenticationTicket(principal, new AuthenticationProperties
            {
                ExpiresUtc = validToken.ValidTo,
                IsPersistent = true
            }, CookieAuthenticationDefaults.AuthenticationScheme);
        }
        catch (Exception)
        {
            return null; // Invalid token
        }
    }
}

File: Program.cs (or Startup.cs)

var builder = WebApplication.CreateBuilder(args);

// Ideally, load this from KeyVault or Environment Variables
var secretKey = Encoding.UTF8.GetBytes(builder.Configuration["Auth:SecretKey"]);
var signingKey = new SymmetricSecurityKey(secretKey);

var tokenParams = new TokenValidationParameters
{
    ValidateIssuer = true,
    ValidateAudience = true,
    ValidateLifetime = true,
    ValidateIssuerSigningKey = true,
    ValidIssuer = "my-legacy-app",
    ValidAudience = "my-ecosystem",
    IssuerSigningKey = signingKey,
    ClockSkew = TimeSpan.Zero 
};

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = "AuthToken"; // Shared cookie name
        options.Cookie.HttpOnly = true;
        options.Cookie.SameSite = SameSiteMode.Lax; // Important for top-level navigation
        options.Cookie.Path = "/"; // Accessible to all routes
        
        // Inject the custom format logic
        options.TicketDataFormat = new JwtTicketFormat(SecurityAlgorithms.HmacSha256, tokenParams);
    });

Step 2: Configure Next.js Middleware to Consume the Cookie

Next.js (specifically the App Router) needs to intercept requests, read the cookie, and verify the signature using the exact same secret key.

Prerequisites: npm install jose (Lightweight JWT library, works in Edge Runtime).

File: middleware.ts

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';

// Must match the key used in C# exactly
const SECRET_KEY = new TextEncoder().encode(process.env.AUTH_SECRET_KEY);

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('AuthToken')?.value;

  // 1. If no token, redirect to legacy login
  if (!token) {
    const loginUrl = new URL('/account/login', request.url); // Point to legacy login
    loginUrl.searchParams.set('returnUrl', request.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  try {
    // 2. Verify JWT signature using the shared secret
    const { payload } = await jwtVerify(token, SECRET_KEY, {
      issuer: 'my-legacy-app',
      audience: 'my-ecosystem',
    });

    // 3. (Optional) Pass user data to headers for Server Components
    const requestHeaders = new Headers(request.headers);
    requestHeaders.set('x-user-id', payload.sub as string);
    requestHeaders.set('x-user-email', payload.email as string);

    return NextResponse.next({
      request: {
        headers: requestHeaders,
      },
    });

  } catch (error) {
    // 4. Token invalid or expired -> Redirect to login
    console.error('Auth validation failed:', error);
    const loginUrl = new URL('/account/login', request.url);
    return NextResponse.redirect(loginUrl);
  }
}

export const config = {
  // Apply to all private routes
  matcher: ['/dashboard/:path*', '/settings/:path*'],
};

The Explanation

Why this works

By implementing ISecureDataFormat, we hijacked the final step of the ASP.NET authentication pipeline. We replaced the opaque binary blob with a standard JWT.

  1. Interoperability: JWT is text-based (Base64Url) and follows a universal specification (RFC 7519).
  2. Statelessness: The cookie contains all necessary claims (User ID, Roles, Email). Next.js does not need to query a database or call the legacy API to validate the session—it relies on the cryptographic signature.
  3. Edge Compatibility: The jose library in the middleware runs in the Next.js Edge Runtime (Cloudflare Workers / Vercel Edge), ensuring extremely low latency verification before the request even hits your React Server Components.

Security Considerations

  1. Secret Management: The AUTH_SECRET_KEY must be identical in both apps. Rotate this key regularly using a secret manager (AWS Secrets Manager / Azure Key Vault).
  2. Cookie Size: JWTs are larger than opaque session IDs. Keep claims minimal. If the header gets too large, Nginx or browser limits (4KB) will reject it.
  3. Logout: Since JWTs are stateless, "logging out" on the client (deleting the cookie) doesn't invalidate the token on the server. To handle immediate revocation (e.g., banned user), you would need a shared Redis cache or a short token expiration (e.g., 15 minutes) with a refresh token pattern.

Conclusion

The Strangler Fig pattern fails if your users feel the friction of crossing the boundary between "Old App" and "New App." By standardizing your auth token format to JWT, you decouple the authentication mechanism from the implementation details of the framework. This allows you to aggressively migrate features to Next.js while the Legacy ASP.NET app quietly continues to handle the complexity of user identity.