Updated on
ASP.NET Core Identity hashes passwords with PasswordHasher<TUser>, and its defaults are PBKDF2 with HMAC-SHA512, a 128-bit random salt, a 256-bit subkey, and 100,000 iterations.
The result is a single Base64 string that carries its own settings. A leading format-marker byte says which version wrote it, so the hasher can read a hash produced years ago, verify it correctly, and tell us it now needs rewriting.
Let’s dive in.
Where Does the Password Hasher Fit in Registration and Login?
When discussing password hashing, we typically consider two scenarios.
Firstly, when a user sets up a new password during registration or updates an existing one, we must hash it before storing it in a database.
Let’s take a look at how such a process typically works:

The user enters their details into the form, and the service responsible for user management processes the registration request.
At this point, the service sends the password to the password hasher to hash it before storing it in the database.
Secondly, to authenticate the user, we need to ensure that the hash of the password entered at login matches the hashed password stored in the database:

The user enters their credentials into the form, and the user management service processes the login request.
In this case, the service retrieves the user’s hashed password from the database and uses the password hasher to compare the stored hashed password with the hash of the password entered by the user.
Fortunately, ASP.NET Core Identity provides a solution for managing user authentication, with password security at its core. The framework’s default password hashing mechanism is implemented through the PasswordHasher<TUser> class, which uses the Password-Based Key Derivation Function 2, also known as PBKDF2.
How Do We Hash a Password With PasswordHasher<TUser>?
PasswordHasher<TUser> is the class ASP.NET Core Identity registers for IPasswordHasher<TUser>, and HashPassword() is the one method we call to turn a plain password into a stored string.
The method takes a user and a password, and the default implementation ignores the user entirely. Passing null compiles and works. Custom implementations may fold user data into the hash, which is why the parameter is there at all.
What happens next depends on one setting. PasswordHasherOptions.CompatibilityMode defaults to IdentityV3, so HashPasswordV3() runs. Setting it to IdentityV2 routes the call to HashPasswordV2() instead.
Either path does the same four things. It generates a fresh random salt from a RandomNumberGenerator, derives a subkey with PBKDF2, packs a format marker plus the salt and the subkey into one byte array, and Base64-encodes the result.
Hashing the same password twice therefore never returns the same string. The salt is new every time, and it travels inside the hash so verification can find it again.
IPasswordHasher<TUser> Interface
First, let’s have a look at the IPasswordHasher<TUser> interface:
public interface IPasswordHasher<TUser> where TUser : class
{
string HashPassword(TUser user, string password);
PasswordVerificationResult VerifyHashedPassword(TUser user, string hashedPassword,
string providedPassword);
}
This is a generic interface that takes a class representing the user in our system as a generic TUser parameter. It provides two methods that address the previously mentioned scenarios.
We use the HashPassword() method to secure a password. It requires two parameters: the user parameter of type TUser and the password we want to hash.
Next, we have the VerifyHashedPassword() method that is used for password validation. It takes three parameters: the user parameter of TUser type, along with two strings representing the hashed and tested passwords.
The PasswordHasher<TUser> Constructor
Now, let’s have a look at the PasswordHasher<TUser> class constructor:
public PasswordHasher(IOptions<PasswordHasherOptions>? optionsAccessor = null)
{
var options = optionsAccessor?.Value ?? DefaultOptions;
_compatibilityMode = options.CompatibilityMode;
switch (_compatibilityMode)
{
case PasswordHasherCompatibilityMode.IdentityV2:
// nothing else to do
break;
case PasswordHasherCompatibilityMode.IdentityV3:
_iterCount = options.IterationCount;
if (_iterCount < 1)
{
throw new InvalidOperationException(Resources.InvalidPasswordHasherIterationCount);
}
break;
default:
throw new InvalidOperationException(Resources.InvalidPasswordHasherCompatibilityMode);
}
_rng = options.Rng;
}
To start, the PasswordHasher<TUser> class loads the configuration from the provided PasswordHasherOptions instance, which reaches the constructor through the options pattern.
However, the most interesting part is the switch statement which shows us that the PasswordHasher<TUser> class can work in two modes.
First, IdentityV2 mode, which is compatible with ASP.NET Identity versions 1 and 2. According to documentation, this mode supports PBKDF2 with 128-bit salt, 256-bit subkey, 1000 iterations, and HMAC-SHA1.
Second, IdentityV3 mode, which is compatible with ASP.NET Identity version 3. Likewise, this mode supports PBKDF2 with 128-bit salt and 256-bit subkey. However, this mode supports 100000 iterations and HMAC-SHA512.
Hashing Passwords With the HashPassword Method
Next, let’s have a look at a HashPassword method:
public virtual string HashPassword(TUser user, string password)
{
ArgumentNullThrowHelper.ThrowIfNull(password);
if (_compatibilityMode == PasswordHasherCompatibilityMode.IdentityV2)
{
return Convert.ToBase64String(HashPasswordV2(password, _rng));
}
else
{
return Convert.ToBase64String(HashPasswordV3(password, _rng));
}
}
The HashPassword() method takes two parameters: a user of the generic TUser type and a password as a string.
The default implementation does not use the user parameter at all. That is because users can be represented differently in various systems. However, in custom implementations of the IPasswordHasher<TUser> interface, user details could be incorporated into the hashing process.
Furthermore, depending on the compatibility mode set, the method then calls either HashPasswordV2 or HashPasswordV3, and returns the result from the appropriate version coded using Base64.
Hashing Passwords With the HashPasswordV2 Method
Firstly, we will start with HashPasswordV2() method:
private static byte[] HashPasswordV2(string password, RandomNumberGenerator rng)
{
const KeyDerivationPrf Pbkdf2Prf = KeyDerivationPrf.HMACSHA1;
const int Pbkdf2IterCount = 1000;
const int Pbkdf2SubkeyLength = 256 / 8;
const int SaltSize = 128 / 8;
byte[] salt = new byte[SaltSize];
rng.GetBytes(salt);
byte[] subkey = KeyDerivation.Pbkdf2(password, salt, Pbkdf2Prf,
Pbkdf2IterCount, Pbkdf2SubkeyLength);
var outputBytes = new byte[1 + SaltSize + Pbkdf2SubkeyLength];
outputBytes[0] = 0x00;
Buffer.BlockCopy(salt, 0, outputBytes, 1, SaltSize);
Buffer.BlockCopy(subkey, 0, outputBytes, 1 + SaltSize, Pbkdf2SubkeyLength);
return outputBytes;
}
The HashPasswordV2() method begins by setting constants critical in the hashing process.
It utilizes the HMACSHA1 pseudo-random function, as specified by the Pbkdf2Prf constant.
It also defaults to 1000 iterations as defined by Pbkdf2IterCount constant. In short, this iteration count represents the number of times the algorithm applies the key derivation, making brute-force attacks increasingly computationally expensive as the count increases.
Additionally, the method initializes a byte array for the salt with a length determined by the SaltSize constant. Then it fills this array with random bytes to ensure salt uniqueness for each hashing operation, using a cryptographic random number generator.
Then, the KeyDerivation.Pbkdf2 function generates a cryptographic key by applying the HMACSHA1 function to the password and salt over the specified number of iterations.
Finally, it prepares the output byte array to store the final hash. The first byte of the array is set as a format marker (0x00) to identify this as a Version 2 hash. Then, it copies the salt and subkey into the array.
HashPasswordV3 Method to Hash Passwords
Next, let’s analyze the HashPasswordV3() method:
private byte[] HashPasswordV3(string password, RandomNumberGenerator rng)
{
return HashPasswordV3(
password, rng,
prf: KeyDerivationPrf.HMACSHA512,
iterCount: _iterCount,
saltSize: 128 / 8,
numBytesRequested: 256 / 8);
}
private static byte[] HashPasswordV3(string password, RandomNumberGenerator rng,
KeyDerivationPrf prf, int iterCount, int saltSize, int numBytesRequested)
{
byte[] salt = new byte[saltSize];
rng.GetBytes(salt);
byte[] subkey = KeyDerivation.Pbkdf2(password, salt, prf, iterCount, numBytesRequested);
var outputBytes = new byte[13 + salt.Length + subkey.Length];
outputBytes[0] = 0x01;
WriteNetworkByteOrder(outputBytes, 1, (uint)prf);
WriteNetworkByteOrder(outputBytes, 5, (uint)iterCount);
WriteNetworkByteOrder(outputBytes, 9, (uint)saltSize);
Buffer.BlockCopy(salt, 0, outputBytes, 13, salt.Length);
Buffer.BlockCopy(subkey, 0, outputBytes, 13 + saltSize, subkey.Length);
return outputBytes;
}
We can see that the HashPasswordV3() implementation is a set of two methods.
The first method acts as an interface that accepts a password and a random number generator instance. It then delegates to a more detailed implementation, specifying several additional parameters.
In short, it sets the pseudo-random function to HMACSHA512, the number of iterations the hash function will cycle through, the size of the salt in bytes, and the number of bytes requested for the resulting hash.
It is worth pointing out that this approach allows us to customize the hashing process, as parameters such as salt size or key length are not hardcoded.
The second method begins by generating a salt using the specified saltSize in a similar fashion as HashPasswordV2() method.
Next, the KeyDerivation.Pbkdf2 function generates a cryptographic key by applying the HMACSHA512 function to the password and salt over the specified number of iterations.
The method then prepares an output byte array to store the complete hash. It sets the first byte of the array as a format marker (0x01) to mark this as a Version 3 hash.
Lastly, it stores the pseudo-random function, iteration count, and salt size in the array in network byte order followed by the salt and subkey.
Which PBKDF2 Parameters Does the Default Password Hasher Use?
The default is PBKDF2 with HMAC-SHA512, a 128-bit salt, a 256-bit subkey, and 100,000 iterations. That combination is IdentityV3, and it is what a new ASP.NET Core Identity application uses unless we change PasswordHasherOptions.
IdentityV2 is the compatibility path for hashes written by ASP.NET Identity 1 and 2. It uses PBKDF2 with HMAC-SHA1, the same 128-bit salt and 256-bit subkey, and 1,000 iterations.
Only one of those four values is configurable. PasswordHasherOptions.IterationCount moves the iteration count, while the pseudo-random function, the salt size, and the subkey size are passed as literals in the HashPasswordV3() overload.
The defaults moved once, which matters when reading older material. Before ASP.NET Core 7, IdentityV3 meant HMAC-SHA256 with 10,000 iterations. Hashes written under those settings still verify correctly, and the hasher reports that they need rewriting under the current ones.
| Setting | IdentityV2 | IdentityV3 (default) | Configurable? |
|---|---|---|---|
| Key derivation function | PBKDF2 | PBKDF2 | No |
| Pseudo-random function | HMAC-SHA1 | HMAC-SHA512 | No |
| Iterations | 1,000 | 100,000 | Yes, via PasswordHasherOptions.IterationCount |
| Salt size | 128 bits (16 bytes) | 128 bits (16 bytes) | No |
| Subkey size | 256 bits (32 bytes) | 256 bits (32 bytes) | No |
| Format marker byte | 0x00 | 0x01 | No |
| Parameters stored in the hash | None | PRF, iterations, salt length | No |
| Compatible with | ASP.NET Identity 1 and 2 | ASP.NET Core Identity 3 and later | n/a |
The one knob in that table is worth a sentence of its own. PasswordHasherOptions exposes exactly two properties, CompatibilityMode and IterationCount, so raising the iteration count is the only way to strengthen the default hasher without replacing it. We set it where we configure ASP.NET Core Identity in the project.
Microsoft’s reference for that property says the same thing in one line: “Gets or sets the number of iterations used when hashing passwords using PBKDF2. Default is 100,000.”
How Does VerifyHashedPassword Check a Password?
Now that we know how the hashing process works, let’s see how to validate the password:
public virtual PasswordVerificationResult VerifyHashedPassword(TUser user, string hashedPassword,
string providedPassword)
{
ArgumentNullThrowHelper.ThrowIfNull(hashedPassword);
ArgumentNullThrowHelper.ThrowIfNull(providedPassword);
byte[] decodedHashedPassword = Convert.FromBase64String(hashedPassword);
if (decodedHashedPassword.Length == 0)
{
return PasswordVerificationResult.Failed;
}
switch (decodedHashedPassword[0])
{
case 0x00:
if (VerifyHashedPasswordV2(decodedHashedPassword, providedPassword))
{
return (_compatibilityMode == PasswordHasherCompatibilityMode.IdentityV3)
? PasswordVerificationResult.SuccessRehashNeeded
: PasswordVerificationResult.Success;
}
else
{
return PasswordVerificationResult.Failed;
}
case 0x01:
if (VerifyHashedPasswordV3(decodedHashedPassword, providedPassword,
out int embeddedIterCount, out KeyDerivationPrf prf))
{
if (embeddedIterCount < _iterCount)
{
return PasswordVerificationResult.SuccessRehashNeeded;
}
if (prf == KeyDerivationPrf.HMACSHA1 || prf == KeyDerivationPrf.HMACSHA256)
{
return PasswordVerificationResult.SuccessRehashNeeded;
}
return PasswordVerificationResult.Success;
}
else
{
return PasswordVerificationResult.Failed;
}
default:
return PasswordVerificationResult.Failed;
}
}
As we can see, the method starts by validating that neither the hashed password nor the provided password is null.
Then, it decodes the hashed password from a Base64 string into a byte array and checks if the decoded hashed password is empty returning a PasswordVerificationResult.Failed if true, indicating an invalid or corrupted hash.
Next, it reads the format marker from the beginning of the hashed password to determine the version of the hashing algorithm.
Finally, depending on the format marker one of the flows is executed:
- If the password is hashed using
IdentityV2mode, it callsVerifyHashedPasswordV2() - If it is hashed using
IdentityV3, it callsVerifyHashedPasswordV3() - In the case of unknown format markers, it indicates an unrecognized or corrupted hash format
In general, both VerifyHashedPasswordV2() and VerifyHashedPasswordV3() operate on the same principle. They hash the provided password using the original hashed password’s salt and hashing parameters and then compare the resulting byte arrays in constant time.
Additionally, if the password is correct it checks if the system is running in a compatibility mode that requires rehashing.
When verifying the password, the hasher returns one of three values:
PasswordVerificationResult.Successif the password is correctPasswordVerificationResult.SuccessRehashNeededif the password is correct but we should update the hash to a new algorithmPasswordVerificationResult.Failedif the password is incorrect
Applications that go through UserManager get this for free. CheckPasswordAsync() rehashes and saves the password itself when verification returns SuccessRehashNeeded, while code calling IPasswordHasher<TUser> directly, as this sample does, has to do it explicitly.
What Is Stored Inside an ASP.NET Core Identity Password Hash?
The stored value is a single Base64 string, and it carries everything verification needs except the password itself.
An IdentityV3 hash decodes to 61 bytes. One format-marker byte of 0x01 comes first, then three big-endian 32-bit integers holding the pseudo-random function, the iteration count, and the salt length, then the 16-byte salt and the 32-byte subkey.
An IdentityV2 hash decodes to 49 bytes and has no header at all. A marker byte of 0x00 is followed by the 16-byte salt and the 32-byte subkey. Its parameters live as constants in the code rather than as fields in the hash.
That header is why we can read the settings straight off the string. Every hash written with today’s defaults is 84 characters long and starts with AQAAAAIAAYag, because a marker of 0x01, PRF 2, and 100,000 iterations always encode to those twelve characters.
Look at the header group on the left of the first strip: those thirteen bytes are the whole difference between the two formats.

| Offset | Length | IdentityV3 (0x01) | IdentityV2 (0x00) |
|---|---|---|---|
| 0 | 1 byte | Format marker 0x01 | Format marker 0x00 |
| 1 | 4 bytes | PRF id, big-endian (2 = HMAC-SHA512) | not present; salt starts at offset 1 |
| 5 | 4 bytes | Iteration count, big-endian | not present |
| 9 | 4 bytes | Salt length in bytes, big-endian | not present |
| 13 | 16 bytes | Salt | salt occupies offsets 1 to 16 |
| 29 | 32 bytes | Subkey | subkey occupies offsets 17 to 48 |
| Total | 61 bytes, 84 Base64 characters | 49 bytes, 68 Base64 characters |
The prefix is a useful era tell when we inherit a database. Twelve Base64 characters cover the first nine bytes, so they are fixed by the marker, the pseudo-random function, and the iteration count alone. A hash written under the pre-ASP.NET Core 7 defaults, meaning HMAC-SHA256 at 10,000 iterations, starts with AQAAAAEAACcQ instead.
What Security Features Does the Password Hasher Provide?
Four mechanisms do the work here, and none of them are optional in the default hasher.
A fresh 128-bit salt goes into every hash, so two users who pick the same password still get different stored values, and precomputed rainbow tables are useless against them.
PBKDF2 with HMAC-SHA512 is deliberately slow. It is a key derivation function rather than a general-purpose hash, chosen precisely because speed is the attacker’s advantage and not ours.
The iteration count multiplies that cost. At the default of 100,000, every guess costs an attacker 100,000 HMAC operations, and raising the number raises their bill and our login latency together.
Comparison runs in constant time. CryptographicOperations.FixedTimeEquals() compares the derived subkey against the stored one without returning early on the first differing byte, so the time a failed login takes leaks nothing about how much of the guess was correct.
Salting
Salting involves adding a random string to each password before it undergoes the hashing process.
This means that even if two users choose the same password, their stored password hashes will be distinct due to the unique salts used. By doing so, salting prevents attackers from successfully using precomputed hash tables known as rainbow tables.
Cryptographic Hash Function
The default configuration of PasswordHasher<TUser> uses the Password-Based Key Derivation Function 2 with HMAC-SHA512. HMAC-SHA256 was the default before ASP.NET Core 7, and the hasher still verifies hashes made with it while reporting that they need rewriting.
This hash function extends the time and computational resources required to generate a hash, making it difficult and time-consuming for attackers to crack passwords.
Iterative Hashing
As mentioned earlier, while the number of iterations enhances security against brute-force attacks, it also impacts the performance of the hashing operation.
Let’s verify this using the BenchmarkDotNet library:
| Method | Mean | Error | StdDev | |----------------------------- |---------:|--------:|--------:| | PasswordHasherWithIdentityV2 | 517.6 us | 3.61 us | 2.82 us | | Method | IterationCount | Mean | Error | StdDev | |----------------------------- |--------------- |-------------:|------------:|------------:| | PasswordHasherWithIdentityV3 | 1000 | 610.4 us | 8.50 us | 7.09 us | | PasswordHasherWithIdentityV3 | 10000 | 6,019.2 us | 40.25 us | 35.68 us | | PasswordHasherWithIdentityV3 | 100000 | 58,748.8 us | 487.55 us | 407.13 us | | PasswordHasherWithIdentityV3 | 1000000 | 589,053.7 us | 4,246.79 us | 5,215.44 us |
From our results, the PasswordHasherV2() executes faster than the PasswordHasherV3() when using the same 1000 iterations, but PasswordHasherV3() employs a more secure cryptographic hashing function.
Furthermore, raising the number of iterations proportionally increases the execution time of PasswordHasherV3() method. The default setting for iterations in PasswordHasherV3() is 100,000.
Constant-Time Comparison
Comparing the derived subkey with the stored one byte by byte would finish early on the first mismatch, and how early it finished would tell an attacker how much of their guess was right.
PasswordHasher<TUser> avoids that. Both verification methods end with CryptographicOperations.FixedTimeEquals(), which always reads every byte of both arrays and takes the same time whether the first byte differs or none of them do.
Best Practices for Password Security in ASP.NET Core
Finally, it’s worth pointing out the critical importance of following recognized best practices when dealing with passwords, some of which we covered in another article, and it is also worth knowing how hashing passwords with BCrypt instead compares.
Firstly, we should use the latest version of ASP.NET Core Identity, as it includes the most up-to-date security features and fixes. Regularly updating our framework ensures that we benefit from the latest improvements and security patches.
Secondly, while ASP.NET Core Identity defaults to PBKDF2 with HMAC-SHA512, we should avoid reducing the default number of iterations used for hashing. These iterations make the hashing process slower, significantly increasing the difficulty for attackers attempting to crack the passwords using brute-force methods.
Moreover, we should enforce strong password policies. This includes requiring passwords that combine upper and lowercase letters, numbers, and special characters, as well as setting a minimum password length. These measures help prevent common password attacks such as dictionary attacks.
Incorporating multi-factor authentication requires users to provide two or more verification factors to gain access, significantly reducing the risk of unauthorized access, even if someone compromises a password.
Lastly, regularly educating users about safe password practices such as avoiding the reuse of passwords across different sites and recognizing phishing attempts can help maintain the system’s security.
Conclusion
In conclusion, understanding the internal workings of ASP.NET Core Identity’s default password hasher is beneficial for maintaining secured web applications.
Through our detailed exploration of PasswordHasherV2 and PasswordHasherV3, we’ve seen how different versions apply cryptographic methods to enhance password protection.
PasswordHasherV3, using a more secure cryptographic function and higher default iteration count, offers superior security, albeit with a longer execution time than its predecessor.
As developers, we must stay informed and proactive about implementing the most secure authentication methods, ensuring our applications remain robust against threats and safeguard user data effectively.
Tested with .NET 10.0.10 and ASP.NET Core Identity 10.0.10.
