Take the community feedback survey now.

Amit Mittal
Dec 1, 2025
  35
(0 votes)

Migrating Optimizely 11 to 12: SQL Membership & Legacy Hashes (Part 2)

In [Part 1], we handled the migration of users who were already using ASP.NET Identity. Now, we tackle the more complex scenario: the MembershipUsersUpgrade project.

This project is still using the legacy SQL Server Membership Provider (aspnet_* tables). This adds two layers of complexity:

  1. Data Migration: We need to physically move user data from the old aspnet_Users tables to the new AspNetUsers tables.

  2. Complex Hashing: The SQL Membership provider used a specific (and somewhat quirky) implementation of HMACSHA512 (or SHA1/SHA256) that handles salts and keys differently than modern standards.

 

The Strategy: The "Legacy Container" Column

 

To keep the migration smooth, we won't try to convert the old passwords to the new format during the SQL migration (which is impossible without the user's plain-text password).

Instead, we will create a temporary container column called LegacyPasswordSalt in our new table. We will store the old Hash, the Salt, and the Format in this one column. When the user logs in, our code will parse this column, verify the old password, and then automatically upgrade them to the new format.

 

Step 1: The SQL Migration Script

 

I created an idempotent SQL script to handle this. It does three things:

  1. Adds the LegacyPasswordSalt column to the new table.

  2. Migrates Users, Roles, and mappings.

  3. Concatenates the old password data into the new column using a pipe (|) delimiter.

SQL
/*
================================================================================
ASP.NET Membership to ASP.NET Core Identity Migration Script
================================================================================
*/
SET NOCOUNT ON;

-- 1. Add LegacyPasswordSalt column to store old hash/salt data
IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE Name = N'LegacyPasswordSalt' AND Object_ID = Object_ID(N'dbo.AspNetUsers'))
BEGIN
    PRINT 'Adding column [LegacyPasswordSalt] to [dbo].[AspNetUsers]...';
    ALTER TABLE [dbo].[AspNetUsers] ADD [LegacyPasswordSalt] NVARCHAR(MAX) NULL;
END

GO

BEGIN TRANSACTION;
BEGIN TRY
    -- 2. INSERT USERS
    PRINT 'Migrating users...';

    INSERT INTO dbo.AspNetUsers (
        Id, UserName, NormalizedUserName, Email, NormalizedEmail, 
        EmailConfirmed, PasswordHash, SecurityStamp, ConcurrencyStamp, 
        PhoneNumber, PhoneNumberConfirmed, TwoFactorEnabled, 
        LockoutEnd, LockoutEnabled, AccessFailedCount, 
        LegacyPasswordSalt, -- <--- The Key Column
        IsApproved, CreationDate, LastLoginDate, LastLockoutDate, IsLockedOut
    )
    SELECT
        u.UserId,
        u.UserName,
        UPPER(u.UserName),
        m.Email,
        UPPER(m.Email),
        1, -- EmailConfirmed
        -- We store the formatted legacy string in PasswordHash strictly as a placeholder
        (m.Password + '|' + CAST(m.PasswordFormat AS VARCHAR(10)) + '|' + m.PasswordSalt), 
        NEWID(), NEWID(), NULL, 0, 0, 
        m.LastLockoutDate, 1, 0,
        -- Actual Legacy Data storage: Hash|Format|Salt
        (m.Password + '|' + CAST(m.PasswordFormat AS VARCHAR(10)) + '|' + m.PasswordSalt), 
        1, m.CreateDate, m.LastLoginDate, m.LastLockoutDate, 0
    FROM dbo.aspnet_Users u
    LEFT JOIN dbo.aspnet_Membership m ON u.UserId= m.UserId
    WHERE NOT EXISTS (SELECT 1 FROM dbo.AspNetUsers anp WHERE anp.Id = u.UserId)
      AND m.Password IS NOT NULL;

    PRINT 'Users migrated successfully.';

    -- 3. INSERT ROLES
    PRINT 'Migrating roles...';
    INSERT INTO dbo.AspNetRoles (Id, Name, NormalizedName)
    SELECT RoleId, RoleName, UPPER(RoleName)
    FROM dbo.aspnet_Roles r
    WHERE NOT EXISTS (SELECT 1 FROM dbo.AspNetRoles anr WHERE anr.Id = r.RoleId);

    -- 4. INSERT USER ROLES
    PRINT 'Migrating user-role relationships...';
    INSERT INTO dbo.AspNetUserRoles (UserId, RoleId)
    SELECT UserId, RoleId FROM dbo.aspnet_UsersInRoles ur
    WHERE NOT EXISTS (SELECT 1 FROM dbo.AspNetUserRoles anur WHERE anur.UserId = ur.UserId AND anur.RoleId = ur.RoleId);

    COMMIT TRANSACTION;
    PRINT 'Migration script completed successfully.';
END TRY
BEGIN CATCH
    IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
    PRINT 'Error: ' + ERROR_MESSAGE();
    THROW;
END CATCH

 

Step 2: Extending the Identity User

 

We need to tell ASP.NET Core Identity about our new column.

C#
using Microsoft.AspNetCore.Identity;
using System.ComponentModel.DataAnnotations.Schema;

public class MyCustomUser : IdentityUser // Or ApplicationUser depending on your setup
{
    [Column(TypeName = "nvarchar(max)")]
    public string? LegacyPasswordSalt { get; set; }
}

 

Step 3: The Membership Password Hasher

 

This is the most critical part. The old SqlMembershipProvider used a specific logic for HMACSHA512. It didn't just pass the salt to the constructor; it had a specific way of padding the key if the salt length differed from the key length.

Warning: You must use Encoding.Unicode (Little Endian UTF-16) for the password bytes. Modern systems use UTF-8, but legacy ASP.NET Membership used Unicode. If you change this, the hashes will not match.

C#
using Microsoft.AspNetCore.Identity;
using System;
using System.Security.Cryptography;
using System.Text;

public class FallbackPasswordHasher : PasswordHasher<MyCustomUser>
{
    // The legacy field format: Hash|Format|Salt
    private const int LegacyHashIndex = 0;
    private const int LegacyFormatIndex = 1; 
    private const int LegacySaltIndex = 2;
    private const int ExpectedLegacyPropertyCount = 3;
    private const int HashedPasswordFormat = 1; // 1 = Hashed

    public override PasswordVerificationResult VerifyHashedPassword(MyCustomUser user, string hashedPassword, string providedPassword)
    {
        // 1. Modern Hash Check
        // If LegacyPasswordSalt is empty, the user has already been migrated or is new.
        if (string.IsNullOrEmpty(user.LegacyPasswordSalt))
        {
            return base.VerifyHashedPassword(user, hashedPassword, providedPassword);
        }

        // 2. Legacy Hash Fallback
        string[] passwordProperties = user.LegacyPasswordSalt.Split('|');
        if (passwordProperties.Length < ExpectedLegacyPropertyCount)
        {
            return PasswordVerificationResult.Failed;
        }

        string legacyHashBase64 = passwordProperties[LegacyHashIndex];
        string saltBase64 = passwordProperties[LegacySaltIndex];
            
        // We assume format is '1' (Hashed).
        // If you have users with format '0' (Clear), handling that here is a security risk.
        byte[] providedHashBytes = HashLegacyPassword(providedPassword, HashedPasswordFormat, saltBase64);
        
        if (providedHashBytes == null) return PasswordVerificationResult.Failed;

        byte[] storedHashBytes;
        try
        {
            storedHashBytes = Convert.FromBase64String(legacyHashBase64);
        }
        catch (FormatException)
        {
            return PasswordVerificationResult.Failed;
        }

        // 3. Secure Comparison
        if (CryptographicOperations.FixedTimeEquals(providedHashBytes, storedHashBytes))
        {
            // Success! Return 'SuccessRehashNeeded' to trigger an automatic database update.
            return PasswordVerificationResult.SuccessRehashNeeded;
        }

        return PasswordVerificationResult.Failed;
    }

    public override string HashPassword(MyCustomUser user, string password)
    {
        // When creating a NEW hash (e.g. Change Password, or Re-hashing after login),
        // we must clear the legacy field to ensure future logins use the modern standard.
        user.LegacyPasswordSalt = null;
        return base.HashPassword(user, password);
    }

    /// <summary>
    /// Replicates the specific HMACSHA512 logic used by the legacy SqlMembershipProvider.
    /// </summary>
    private byte[] HashLegacyPassword(string password, int passwordFormat, string salt)
    {
        if (passwordFormat != HashedPasswordFormat) return null;
        if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(salt)) return null;

        byte[] passwordBytes;
        byte[] saltBytes;
            
        try
        {
            // IMPORTANT: Membership provider used Unicode (UTF-16), not UTF-8.
            passwordBytes = Encoding.Unicode.GetBytes(password);
            saltBytes = Convert.FromBase64String(salt);
        }
        catch
        {
            return null;
        }

        using (var hmac = (KeyedHashAlgorithm)HashAlgorithm.Create("HMACSHA512"))
        {
            // --- Replicated ASP.NET Membership Key Logic ---
            // Do not refactor this block. It handles specific key padding used by the old provider.
            if (hmac.Key.Length == saltBytes.Length)
            {
                hmac.Key = saltBytes;
            }
            else if (hmac.Key.Length < saltBytes.Length)
            {
                var key = new byte[hmac.Key.Length];
                Buffer.BlockCopy(saltBytes, 0, key, 0, key.Length);
                hmac.Key = key;
            }
            else 
            {
                var key = new byte[hmac.Key.Length];
                for (var i = 0; i < key.Length;)
                {
                    var len = Math.Min(saltBytes.Length, key.Length - i);
                    Buffer.BlockCopy(saltBytes, 0, key, i, len);
                    i += len;
                }
                hmac.Key = key;
            }
            // -----------------------------------------------

            return hmac.ComputeHash(passwordBytes);
        }
    }
}

 

Conclusion

 

Just like in Part 1, don't forget to register this in your Startup.cs before the Identity initialization:

C#
services.AddScoped(typeof(IPasswordHasher<>), typeof(FallbackPasswordHasher<>));
services.AddCmsAspNetIdentity<MyCustomUser>();

This solution provides a seamless experience. Users log in, the system detects the old "pipe-delimited" legacy data, verifies it using the old logic, and instantly upgrades them to the modern secure standard without them ever knowing.

Dec 01, 2025

Comments

Please login to comment.
Latest blogs
Migrating Optimizely 11 to 12: Handling Legacy Password Hashes (Part 1)

Recently, I was tasked with a complex migration: moving existing users from two Optimizely 11 projects to the new Optimizely 12 (ASP.NET Core). The...

Amit Mittal | Dec 1, 2025

Step by step process of creating a custom tool for Opal AI in Google Cloud

I had the opportunity of participating in the Opal AI Hackathon challenge, where we built a custom tool using Optimizely's Opal Python SDK. This...

Aniket | Dec 1, 2025

Mastering Optimizely DXP: How to Download Blobs Like a Pro with PowerShell

In 2021 I wrote a blog post with detailed instuctions on how to download blobs from Optimizely DXP environment. I at least have used that blog post...

Antti Alasvuo | Nov 30, 2025