Amit Mittal
Dec 1, 2025
visibility 648
star star star star star
(1 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

error Please login to comment.
Latest blogs
Architecting an Enterprise-Grade Development Pipeline in Optimizely SaaS CMS

Most enterprise teams show up to Optimizely SaaS CMS with a clear roadmap for their release pipeline: DEV → QA → Stage → Prod. Four logical...

Vipin Banka | Jul 12, 2026

Bynder DAM Connector for Optimizely SaaS CMS: Improved Metadata Property Synchronization

While working with the Bynder DAM Connector for Optimizely SaaS CMS , one of the key areas I explored was how Bynder asset metadata is synchronized...

Vipin Banka | Jul 11, 2026

Optimizely DXP: Every Supported Culture, One Searchable Page

Quick one for anyone building multi-language sites on Optimizely DXP. I put together a reference tool listing all 806 supported cultures. More...

Adnan Zameer | Jul 10, 2026 |

A day in the life of an Optimizely OMVP: London Meetup 2026

On 2nd July 2026 the Optimizely London Developer Meetup returned to The Lightwell, and the running theme across the evening was less about individu...

Graham Carr | Jul 10, 2026

Optimizely’s Summer ’26 Roadmap: The CMS Is Starting to Look Less Like a Publishing Tool and More Like Marketing Infrastructure

Optimizely’s Summer ’26 Product Roadmap event was not just a list of product updates. At least, that is not the part I found most interesting. The...

Augusto Davalos | Jul 9, 2026

Optimizely Content JS SDK v2.1.0 — What's New and Why It Matters

  v2.1.0 of the Optimizely Content JS SDK and CLI landed on July 7, 2026. This is a substantial release bringing a wave of capabilities for...

Vipin Banka | Jul 8, 2026