Custom Password Hashing in ASP.NET Core: A Versioned, Upgradeable Design

Applications that do not use ASP.NET Core Identity still need a disciplined password-storage design. This often leads to a requirement phrased as “password encryption.” That phrase is dangerous because encryption is reversible: an application holding the key can recover every password. Password storage needs a deliberately one-way, slow derivation function instead.

The goal is not to invent cryptography. The .NET runtime already provides vetted primitives for random salt generation, PBKDF2, and constant-time comparison. Custom code should define the application contract around those primitives: the stored format, algorithm parameters, verification result, upgrade path, and operational controls.

This article builds such a contract for an ASP.NET Core application without Identity. It uses PBKDF2-HMAC-SHA512 because it is available in the platform, broadly understood, and suitable when configured with a current work factor. Argon2id is often the preferred choice for a new password store because it is memory-hard, but it requires an audited third-party library in .NET. A well-operated PBKDF2 implementation is substantially better than an unreviewed Argon2 integration or a fast hash such as SHA-256.

Start with a password-storage contract

A password record must contain more than a digest. Verification needs the algorithm, the version of the application format, the work factor, the salt, and the derived subkey. Storing this information beside the subkey is not a weakness. The salt and PBKDF2 parameters are not secrets; they make each stored password independently verifiable and upgradeable.

The following textual format is intentionally explicit:

1$pbkdf2-sha512$v=1$i=210000,l=32$<base64url-salt>$<base64url-subkey>

Each component has a clear purpose:

  • pbkdf2-sha512 identifies the password derivation algorithm.
  • v=1 identifies the application’s serialization contract.
  • i=210000,l=32 records the iteration count and subkey length.
  • The final two fields contain a unique random salt and the derived subkey.

The iteration value is an example, not a permanent constant. OWASP currently recommends at least 210,000 iterations for PBKDF2-HMAC-SHA512. The appropriate value also depends on production hardware and login-volume requirements. A measured target of roughly a fraction of a second for one verification is often useful, provided that capacity planning includes expected concurrent login attempts and abuse traffic.

Encoding the parameters into each record has an important operational effect: a future deployment can increase the configured iteration count and rehash a password at its next successful login. No global password reset and no database-wide plaintext operation is required.

Define a small, Identity-free abstraction

Password hashing is CPU-bound and has no asynchronous I/O. Keeping the interface synchronous makes that fact visible and avoids artificial Task allocation. Database reads, writes, rate limiting, and audit logging remain asynchronous at the application boundary.

 1public enum PasswordVerificationResult
 2{
 3    Failed = 0,
 4    Success = 1,
 5    SuccessRehashNeeded = 2
 6}
 7
 8public interface IPasswordHasher
 9{
10    string Hash(string password);
11
12    PasswordVerificationResult Verify(string storedHash, string password);
13}
14
15public sealed class PasswordHashingOptions
16{
17    public int IterationCount { get; init; } = 210_000;
18
19    public int SaltSize { get; init; } = 16;
20
21    public int SubkeyLength { get; init; } = 32;
22
23    public string? Pepper { get; init; }
24}

SuccessRehashNeeded is an application-level migration signal. It separates a correct credential from a current credential representation. The caller can authenticate the account and replace its stored hash in the same transaction or optimistic-concurrency workflow that records the successful login.

The Pepper is optional. Unlike a per-password salt, it is a shared secret stored outside the user database, for example in a managed secret store. It can reduce the value of a database-only compromise, but it does not replace a salt, an appropriate work factor, multi-factor authentication, or rate limiting. Pepper rotation needs a deliberate key-ring strategy or a password-reset process; changing one opaque configuration value without compatibility handling would invalidate every password.

Implement PBKDF2 with strict parsing and comparison

The implementation below relies on Rfc2898DeriveBytes.Pbkdf2, RandomNumberGenerator, and CryptographicOperations. It does not implement a hash function, random number generator, or comparison primitive itself.

  1using System.Globalization;
  2using System.Security.Cryptography;
  3using System.Text;
  4using Microsoft.AspNetCore.WebUtilities;
  5
  6public sealed class Pbkdf2PasswordHasher : IPasswordHasher
  7{
  8    private const string Algorithm = "pbkdf2-sha512";
  9    private const int FormatVersion = 1;
 10    private readonly PasswordHashingOptions _options;
 11
 12    public Pbkdf2PasswordHasher(PasswordHashingOptions options)
 13    {
 14        ArgumentNullException.ThrowIfNull(options);
 15
 16        if (options.IterationCount < 1)
 17        {
 18            throw new ArgumentOutOfRangeException(nameof(options), "The iteration count must be positive.");
 19        }
 20
 21        if (options.SaltSize < 16)
 22        {
 23            throw new ArgumentOutOfRangeException(nameof(options), "The salt must contain at least 16 bytes.");
 24        }
 25
 26        if (options.SubkeyLength < 16)
 27        {
 28            throw new ArgumentOutOfRangeException(nameof(options), "The subkey must contain at least 16 bytes.");
 29        }
 30
 31        _options = options;
 32    }
 33
 34    public string Hash(string password)
 35    {
 36        ArgumentException.ThrowIfNullOrWhiteSpace(password);
 37
 38        byte[] salt = RandomNumberGenerator.GetBytes(_options.SaltSize);
 39        byte[] passwordInput = GetPasswordInput(password);
 40
 41        try
 42        {
 43            byte[] subkey = Rfc2898DeriveBytes.Pbkdf2(
 44                passwordInput,
 45                salt,
 46                _options.IterationCount,
 47                HashAlgorithmName.SHA512,
 48                _options.SubkeyLength);
 49
 50            try
 51            {
 52                string encodedSalt = WebEncoders.Base64UrlEncode(salt);
 53                string encodedSubkey = WebEncoders.Base64UrlEncode(subkey);
 54
 55                return $"${Algorithm}$v={FormatVersion}$i={_options.IterationCount},l={_options.SubkeyLength}${encodedSalt}${encodedSubkey}";
 56            }
 57            finally
 58            {
 59                CryptographicOperations.ZeroMemory(subkey);
 60            }
 61        }
 62        finally
 63        {
 64            CryptographicOperations.ZeroMemory(passwordInput);
 65            CryptographicOperations.ZeroMemory(salt);
 66        }
 67    }
 68
 69    public PasswordVerificationResult Verify(string storedHash, string password)
 70    {
 71        if (string.IsNullOrWhiteSpace(storedHash) || string.IsNullOrEmpty(password))
 72        {
 73            return PasswordVerificationResult.Failed;
 74        }
 75
 76        if (!TryParse(storedHash, out PasswordHashParts? parts) || parts is null)
 77        {
 78            return PasswordVerificationResult.Failed;
 79        }
 80
 81        byte[] passwordInput = GetPasswordInput(password);
 82
 83        try
 84        {
 85            byte[] computedSubkey = Rfc2898DeriveBytes.Pbkdf2(
 86                passwordInput,
 87                parts.Salt,
 88                parts.IterationCount,
 89                HashAlgorithmName.SHA512,
 90                parts.SubkeyLength);
 91
 92            try
 93            {
 94                bool matches = CryptographicOperations.FixedTimeEquals(computedSubkey, parts.Subkey);
 95                if (!matches)
 96                {
 97                    return PasswordVerificationResult.Failed;
 98                }
 99
100                bool rehashNeeded = parts.Version != FormatVersion ||
101                    parts.IterationCount != _options.IterationCount ||
102                    parts.SubkeyLength != _options.SubkeyLength ||
103                    parts.Salt.Length != _options.SaltSize;
104
105                return rehashNeeded
106                    ? PasswordVerificationResult.SuccessRehashNeeded
107                    : PasswordVerificationResult.Success;
108            }
109            finally
110            {
111                CryptographicOperations.ZeroMemory(computedSubkey);
112            }
113        }
114        finally
115        {
116            CryptographicOperations.ZeroMemory(passwordInput);
117            CryptographicOperations.ZeroMemory(parts.Salt);
118            CryptographicOperations.ZeroMemory(parts.Subkey);
119        }
120    }
121
122    private byte[] GetPasswordInput(string password)
123    {
124        byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
125
126        if (string.IsNullOrEmpty(_options.Pepper))
127        {
128            return passwordBytes;
129        }
130
131        byte[] pepperBytes = Encoding.UTF8.GetBytes(_options.Pepper);
132
133        try
134        {
135            return HMACSHA256.HashData(pepperBytes, passwordBytes);
136        }
137        finally
138        {
139            CryptographicOperations.ZeroMemory(passwordBytes);
140            CryptographicOperations.ZeroMemory(pepperBytes);
141        }
142    }
143
144    private static bool TryParse(string storedHash, out PasswordHashParts? parts)
145    {
146        parts = null;
147        string[] fields = storedHash.Split('$', StringSplitOptions.None);
148
149        if (fields.Length != 6 || fields[0].Length != 0 || fields[1] != Algorithm)
150        {
151            return false;
152        }
153
154        if (!TryReadNamedInteger(fields[2], "v", out int version) ||
155            !TryReadParameters(fields[3], out int iterationCount, out int subkeyLength) ||
156            version < 1 || iterationCount < 1 || iterationCount > 10_000_000 ||
157            subkeyLength < 16 || subkeyLength > 64)
158        {
159            return false;
160        }
161
162        try
163        {
164            byte[] salt = WebEncoders.Base64UrlDecode(fields[4]);
165            byte[] subkey = WebEncoders.Base64UrlDecode(fields[5]);
166
167            if (salt.Length < 16 || salt.Length > 64 || subkey.Length != subkeyLength)
168            {
169                CryptographicOperations.ZeroMemory(salt);
170                CryptographicOperations.ZeroMemory(subkey);
171                return false;
172            }
173
174            parts = new PasswordHashParts(version, iterationCount, subkeyLength, salt, subkey);
175            return true;
176        }
177        catch (FormatException)
178        {
179            return false;
180        }
181    }
182
183    private static bool TryReadParameters(string value, out int iterationCount, out int subkeyLength)
184    {
185        iterationCount = 0;
186        subkeyLength = 0;
187        string[] parameters = value.Split(',', StringSplitOptions.None);
188
189        if (parameters.Length != 2)
190        {
191            return false;
192        }
193
194        return TryReadNamedInteger(parameters[0], "i", out iterationCount) &&
195            TryReadNamedInteger(parameters[1], "l", out subkeyLength);
196    }
197
198    private static bool TryReadNamedInteger(string value, string name, out int number)
199    {
200        number = 0;
201        string prefix = $"{name}=";
202
203        return value.StartsWith(prefix, StringComparison.Ordinal) &&
204            int.TryParse(
205                value.AsSpan(prefix.Length),
206                NumberStyles.None,
207                CultureInfo.InvariantCulture,
208                out number);
209    }
210
211    private sealed record PasswordHashParts(
212        int Version,
213        int IterationCount,
214        int SubkeyLength,
215        byte[] Salt,
216        byte[] Subkey);
217}

The parser is intentionally narrow. It rejects unknown algorithms, malformed field counts, unreasonable iteration counts, short salts, and mismatched subkey lengths before PBKDF2 is run. This is a denial-of-service control as well as a data-integrity check: an attacker should not be able to submit a manipulated stored value that requests billions of iterations.

CryptographicOperations.FixedTimeEquals matters after the expensive derivation. A normal equality comparison can stop at the first mismatch and may leak information through timing. Password verification should compare equal-length derived subkeys using the platform’s constant-time primitive.

Managed strings cannot be reliably erased because the runtime controls their lifetime and representation. The implementation therefore limits the lifetime of byte arrays that contain UTF-8 password material or derived output and clears those arrays in finally blocks. This is defense in depth, not a promise that a password never exists in process memory.

Register the service and preserve the hash as application data

The hasher has no dependency on an HTTP request or an Identity type. It can be registered as a singleton because its options are immutable and every operation uses local state.

 1WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
 2
 3string pepper = builder.Configuration["PasswordHashing:Pepper"]
 4    ?? throw new InvalidOperationException("A password pepper must be configured.");
 5
 6PasswordHashingOptions passwordHashingOptions = new()
 7{
 8    IterationCount = 210_000,
 9    SaltSize = 16,
10    SubkeyLength = 32,
11    Pepper = pepper
12};
13
14builder.Services.AddSingleton<IPasswordHasher>(
15    new Pbkdf2PasswordHasher(passwordHashingOptions));

The pepper belongs in a deployment secret mechanism, never in appsettings.json, source control, or the same database table as password hashes. The registration example reads a configuration value only to keep the application code transport-neutral; the actual configuration provider can be environment variables, a managed secret store, or another secure configuration source.

An account table needs one string column for the encoded hash, for example PasswordHash. The column should be sized for future formats rather than the current string alone; nvarchar(512) is generally comfortable for PBKDF2 and leaves room for an algorithm marker, base64 data, and parameter changes. Password history requires independently salted hashes for each previous password, not copies of the current hash.

Rehash only after a successful login

Verification returns SuccessRehashNeeded when a stored record has valid credentials but older parameters. The authentication flow should create a new hash only after the old hash is verified successfully:

 1PasswordVerificationResult result = passwordHasher.Verify(user.PasswordHash, submittedPassword);
 2
 3if (result == PasswordVerificationResult.Failed)
 4{
 5    return AuthenticationResult.InvalidCredentials;
 6}
 7
 8if (result == PasswordVerificationResult.SuccessRehashNeeded)
 9{
10    user.PasswordHash = passwordHasher.Hash(submittedPassword);
11    await userRepository.UpdateAsync(user, cancellationToken);
12}
13
14return AuthenticationResult.Authenticated;

The persistence update needs the same concurrency discipline as the rest of the user record. A row version or equivalent optimistic-concurrency token prevents two successful requests from silently overwriting one another. A conflict during rehashing is usually safe to retry by loading the new value and verifying again; it must not turn a valid password into an authentication failure.

This lazy migration model supports several future changes:

  1. Increase the PBKDF2 iteration count after performance testing.
  2. Add a new format version with a larger salt or subkey.
  3. Introduce Argon2id in a new marker while still verifying legacy PBKDF2 records.
  4. Retire a legacy verifier only after affected accounts have migrated or completed a reset flow.

An algorithm change should add a separate verifier for the old marker and make the new algorithm the only hash-producing path. Existing hashes must never be reinterpreted as a new format based on a guess.

Authentication security lives outside the hasher too

Correct password hashing is necessary but insufficient for a secure login endpoint. The endpoint must not reveal whether an account exists. One practical technique is to verify an unknown account against a fixed, valid dummy hash before returning the same generic failure response used for a wrong password. The dummy hash must use the current algorithm parameters so that timing remains comparable.

Rate limiting and progressive backoff need an account-aware and source-aware design. A limit keyed only by IP address is easy to bypass and can harm users behind shared networks. A limit keyed only by account identifier permits broad distributed guessing. Combining several bounded signals, logging unsuccessful attempts without passwords, and applying a short response delay is more resilient.

Password reset tokens are not passwords. They should be high-entropy, single-use, short-lived random values stored as hashes or protected tokens, and they should be invalidated after a successful reset. Session revocation is also part of a reset workflow: existing browser sessions, refresh tokens, application passwords, and recovery methods may need explicit review according to the product’s security model.

Multi-factor authentication helps, but it does not make weak password handling acceptable. Account recovery and password-reset workflows are particularly attractive targets because they can bypass the normal password-verification path.

Test the format as a compatibility boundary

The stored string is a long-lived database contract. Tests should treat it accordingly rather than covering only the happy path. Useful cases include:

  • A newly generated hash verifies with its original password and fails with a different password.
  • Two hashes of the same password differ because every call generates a new salt.
  • A hash generated with lower iterations verifies as SuccessRehashNeeded under a stronger configuration.
  • Truncated, invalid-base64, unknown-algorithm, oversized-iteration, and inconsistent-length records fail without throwing.
  • A hash produced with a pepper fails when verified with a different pepper.
  • A known legacy hash remains verifiable for as long as the migration policy requires it.

Performance tests belong in the deployment environment. PBKDF2 cost varies with CPU model, container limits, runtime version, parallel load, and the presence of abuse traffic. The chosen work factor should be periodically remeasured, and the configuration change should be rolled out with capacity monitoring rather than selected once and forgotten.

When not to build this layer

ASP.NET Core Identity already provides a mature password hasher, lockout support, token infrastructure, security-stamp behavior, and integration points for common authentication workflows. It is the lower-risk choice for most new applications. A custom hasher is justified when an application has an existing user model, a non-Identity authentication architecture, or a controlled migration from another platform.

Even then, the custom scope should remain small. The application owns the storage format and migration policy; well-reviewed platform or library code owns cryptographic primitives. A proprietary reversible encryption scheme, direct use of SHA-256 or MD5, a static salt, or a home-grown random-number generator all create avoidable failure modes.

Conclusion

An Identity-free ASP.NET Core application can store passwords safely without recreating a full identity framework. The essential design is a slow, salted, versioned one-way password derivation using established primitives, strict parsing, constant-time comparison, and an explicit rehash result.

The remaining work is operational rather than cryptographic: protect any pepper separately, rate-limit login attempts, conceal account enumeration, handle reset and session revocation correctly, and measure the work factor in production-like conditions. Those boundaries make a small custom password layer maintainable without treating password security as a single helper method.

For the underlying recommendations, see the OWASP Password Storage Cheat Sheet and the .NET cryptography APIs .


Let's Work Together

Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.

New Platforms
Modernization
Training & Consulting