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:
Project IdentityUserUpgrade: Already using ASP.NET Identity (but the older .NET Framework version).
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.
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>publicclassFallbackPasswordHasher<TUser> : IPasswordHasher<TUser> whereTUser : class
{
// We use the default ASP.NET Core PasswordHasher for all new hashes and primary verification.privatereadonlyPasswordHasher<TUser> _newHasher = new PasswordHasher<TUser>();
///<summary>/// Generates a hash for a password using the modern (V3) standard.///</summary>publicstringHashPassword(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>privatestaticboolVerifyAspNetIdentityV2Hash(string storedHash, string password)
{
byte[] decoded;
try
{
decoded = Convert.FromBase64String(storedHash);
}
catch
{
returnfalse; // Not a valid Base64 string
}
// Identity V2 hashes are exactly 49 bytes long.if (decoded.Length != 1 + 16 + 32)
{
returnfalse;
}
// The version byte for Identity V2 is always 0x00.if (decoded[0] != 0x00)
{
returnfalse;
}
// Extract the salt (next 16 bytes starting at index 1).var salt = newbyte[16];
Buffer.BlockCopy(decoded, 1, salt, 0, 16);
// Extract the expected hash/subkey (next 32 bytes starting at index 17).var expectedSubkey = newbyte[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.usingvar 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!
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:
Project
IdentityUserUpgrade: Already using ASP.NET Identity (but the older .NET Framework version).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
IdentityUserUpgradeproject 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.configfile, specifically looking forpasswordFormatandhashAlgorithmType.<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.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
IPasswordHasherthat 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.SHA1because that was the default for my project. However,Rfc2898DeriveBytessupports other algorithms if your legacy system used them (e.g., SHA256, SHA512, or MD5). You can adjust theHashAlgorithmNameparameter in theVerifyAspNetIdentityV2Hashmethod to match your legacy configuration.Service Registration
Finally, to make this work, you must register your custom hasher in
Startup.cs(orProgram.cs). This must happen before the call toAddCmsAspNetIdentity.// 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!