Amit Mittal
Dec 1, 2025
  639
(2 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
Optimizely CMS 13 and the Alloy demo site

The Alloy demo site now runs on Optimizely CMS 13. Here’s a quick guide to getting it up and running locally.

Tomas Hensrud Gulla | Apr 10, 2026 |

A day in the life of an Optimizely OMVP: Getting Up to Speed with Optimizely CMS 13 — A Free Learning Path

If you're working with Optimizely and haven't started exploring CMS 13 yet, now's the time. I've put together a dedicated CMS 13 course within the...

Graham Carr | Apr 10, 2026

A day in the life of an Optimizely OMVP: Optimizely CMS 13 Is Here: The Features, The Add-Ons, and What's Coming Next

CMS 13 went GA on 31st March 2026, and after months of previews, webinars, and internal engineering sessions, it's finally real. I've been deep in...

Graham Carr | Apr 10, 2026

How to get the Page Tree back in Optimizely CMS

Lost the page tree in Optimizely CMS? Here's why it happens and the non-obvious fix to get it back.

Henning Sjørbotten | Apr 10, 2026 |