Take the community feedback survey now.

Amit Mittal
Dec 1, 2025
  34
(0 votes)

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 core requirement for both projects was seamlessness—existing users needed to be able to log in with their current passwords without being forced to reset them.

This presented two distinct scenarios:

  1. Project IdentityUserUpgrade: Already using ASP.NET Identity (but the older .NET Framework version).

  2. Project MembershipUsersUpgrade: Still using the legacy SQL Server Membership Provider.

In this post, I will focus on Project IdentityUserUpgrade. Even though this project was already using Identity, moving from .NET Framework to .NET Core changes the underlying hashing algorithm. We need a way to bridge that gap.

 

The Investigation: Analyzing the Web.Config

 

Since the IdentityUserUpgrade project was already on Identity tables, I didn't need to migrate data between SQL tables. However, I needed to understand how the passwords were encrypted.

I started by checking the legacy web.config file, specifically looking for passwordFormat and hashAlgorithmType.

XML
<membership hashAlgorithmType="SHA1">
  <providers>
    <add name="DefaultConnection" ... passwordFormat="Hashed" />
  </providers>
</membership>

The key takeaways here were:

  • passwordFormat: Hashed (This is crucial for the solution below).

  • hashAlgorithmType: SHA1.

Note: You may not explicitly see these values in your web.config if the project was using the system defaults, but SHA1 was the standard for older Identity V2 implementations.

 

The Solution: The Fallback Password Hasher

 

ASP.NET Core Identity (V3) uses PBKDF2 with HMAC-SHA256 (or SHA512 in newer versions) and a distinct binary format. The old Identity (V2) used PBKDF2 with HMAC-SHA1.

To solve this, we need a custom IPasswordHasher that attempts to verify the password using the new format first. If that fails, it falls back to the legacy format. If the legacy format matches, we report success and instruct Identity to re-hash the password immediately.

Here is the implementation:

using Microsoft.AspNetCore.Identity;
using System;
using System.Security.Cryptography;

/// <summary>
/// A password hasher that supports legacy ASP.NET Identity V2 hashes (PBKDF2-SHA1)
/// while ensuring all new passwords are created using the modern Identity V3 standard.
/// </summary>
public class FallbackPasswordHasher<TUser> : IPasswordHasher<TUser> where TUser : class
{
    // We use the default ASP.NET Core PasswordHasher for all new hashes and primary verification.
    private readonly PasswordHasher<TUser> _newHasher = new PasswordHasher<TUser>();

    /// <summary>
    /// Generates a hash for a password using the modern (V3) standard.
    /// </summary>
    public string HashPassword(TUser user, string password)
    {
        return _newHasher.HashPassword(user, password);
    }

    /// <summary>
    /// Verifies a password against a stored hash.
    /// Tries the modern hasher first; if it fails, falls back to the legacy V2 verifier.
    /// </summary>
    public PasswordVerificationResult VerifyHashedPassword(TUser user, string hashedPassword, string providedPassword)
    {
        if (string.IsNullOrEmpty(hashedPassword))
        {
            return PasswordVerificationResult.Failed;
        }

        // 1. Attempt to verify using the current (modern) standard.
        var result = _newHasher.VerifyHashedPassword(user, hashedPassword, providedPassword);

        // If the modern hasher recognizes it, return immediately.
        if (result == PasswordVerificationResult.Success || result == PasswordVerificationResult.SuccessRehashNeeded)
        {
            return result;
        }

        // 2. If modern verification failed, check if this is a legacy ASP.NET Identity V2 hash.
        if (VerifyAspNetIdentityV2Hash(hashedPassword, providedPassword))
        {
            // CRITICAL: Return 'SuccessRehashNeeded'.
            // This tells the Identity system: "Login allowed, but re-hash this password 
            // with the new format and update the database immediately."
            return PasswordVerificationResult.SuccessRehashNeeded;
        }

        // 3. Password matches neither format.
        return PasswordVerificationResult.Failed;
    }

    /// <summary>
    /// Manually verifies a hash against the specific binary layout and parameters of ASP.NET Identity V2.
    /// Format: [1 byte: Version (0x00)] + [16 bytes: Salt] + [32 bytes: Subkey] = 49 bytes total.
    /// </summary>
    private static bool VerifyAspNetIdentityV2Hash(string storedHash, string password)
    {
        byte[] decoded;
        try
        {
            decoded = Convert.FromBase64String(storedHash);
        }
        catch
        {
            return false; // Not a valid Base64 string
        }

        // Identity V2 hashes are exactly 49 bytes long.
        if (decoded.Length != 1 + 16 + 32)
        {
            return false;
        }

        // The version byte for Identity V2 is always 0x00.
        if (decoded[0] != 0x00)
        {
            return false;
        }

        // Extract the salt (next 16 bytes starting at index 1).
        var salt = new byte[16];
        Buffer.BlockCopy(decoded, 1, salt, 0, 16);

        // Extract the expected hash/subkey (next 32 bytes starting at index 17).
        var expectedSubkey = new byte[32];
        Buffer.BlockCopy(decoded, 1 + 16, expectedSubkey, 0, 32);

        // Re-compute the hash using the provided password and extracted salt.
        // Identity V2 explicitly used PBKDF2 with 1000 iterations and HMAC-SHA1.
        // NOTE: If your legacy app used a different iteration count, update '1000' below.
        using var deriveBytes = new Rfc2898DeriveBytes(password, salt, 1000, HashAlgorithmName.SHA1);
        var actualSubkey = deriveBytes.GetBytes(32);

        // Cryptographic comparison (constant time) to prevent timing attacks.
        return CryptographicOperations.FixedTimeEquals(actualSubkey, expectedSubkey);
    }
}

 

A Note on Algorithms

 

The code above explicitly handles HashAlgorithmName.SHA1 because that was the default for my project. However, Rfc2898DeriveBytes supports other algorithms if your legacy system used them (e.g., SHA256, SHA512, or MD5). You can adjust the HashAlgorithmName parameter in the VerifyAspNetIdentityV2Hash method to match your legacy configuration.

 

Service Registration

 

Finally, to make this work, you must register your custom hasher in Startup.cs (or Program.cs). This must happen before the call to AddCmsAspNetIdentity.

C#
// Register the fallback hasher
services.AddScoped(typeof(IPasswordHasher<>), typeof(FallbackPasswordHasher<>));

// Then add CMS Identity
services.AddCmsAspNetIdentity<ApplicationUser>();

With this setup, old users can log in seamlessly. Upon their first login, the system will detect the old hash, verify it, and automatically upgrade the database entry to the new secure format.

Stay tuned for Part 2, where I will tackle the more challenging MembershipUsersUpgrade project involving SQL Membership providers!

Dec 01, 2025

Comments

Please login to comment.
Latest blogs
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 MembershipUs...

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