← All writing
articleJan 29, 202514 min read

Secure Password Hashing in .NET

Choosing and operating password hashing in .NET with ASP.NET Core Identity, Argon2id or PBKDF2, versioned envelopes, peppers, upgrades, and capacity controls.

.NETC#Security
Secure Password Hashing in .NET cover illustration

Password hashing has to remain useful after the credential table has left the application boundary. Assume an attacker can read every stored hash, salt, parameter, and account identifier and can test guesses offline without calling the login endpoint.

That threat model rules out encryption, SHA-256, and a clever application-specific transform. Encryption is reversible when the key is found. A fast hash lets specialized hardware test guesses too cheaply. A homegrown construction creates a format the team has to cryptographically review and operate for years.

The .NET decision is usually narrower: use ASP.NET Core Identity’s password hasher as part of Identity, or use a mature implementation of a current password-hashing function when owning a separate credential system is genuinely necessary.

First decide whether the application should own passwords

An external identity provider can remove password verification from the application, but it does not remove authentication design. The application still owns token validation, account linking, recovery consequences, authorization, and provider outage behavior.

If the application uses ASP.NET Core Identity, start with its IPasswordHasher<TUser> and UserManager. The framework stores algorithm metadata and supports a SuccessRehashNeeded result so old hashes can be upgraded after a successful login. Replacing only the hashing call while bypassing lockout, security stamps, reset tokens, and user lifecycle usually makes the whole system less coherent.

For a separate credential service, write down why Identity is not the boundary before implementing storage.

Choose the function from the deployment constraint

Current OWASP guidance prefers Argon2id for new password storage. It is memory-hard as well as CPU-expensive, which raises the cost of massively parallel guessing. .NET’s base class library does not provide Argon2id, so using it requires a maintained, reviewed library and operational confidence in its native or managed implementation.

PBKDF2 remains widely available through .NET and is relevant where FIPS-validated implementations are required. OWASP’s current FIPS-oriented baseline is PBKDF2-HMAC-SHA-256 with at least 600,000 iterations. That number is a floor from current guidance, not a permanent tuning value and not proof that the endpoint has enough capacity.

Scrypt is another memory-hard option when Argon2id is unavailable. BCrypt remains common, but its typical input-length limits and older cost model need explicit handling. Do not silently truncate a password to fit an algorithm.

The decision record should include:

  • algorithm and implementation/library version;
  • parameter values and benchmark hardware;
  • p50, p95, and peak concurrent verification cost;
  • memory budget per verification;
  • compliance constraint, if any;
  • maximum accepted password length and pre-hash policy, if required;
  • upgrade and rollback path.

Salt is unique; pepper is separate

A salt is a random value generated independently for every stored password. It prevents identical passwords from sharing one hash and defeats precomputed tables across accounts. The salt is not secret and belongs in the hash envelope.

byte[] salt = RandomNumberGenerator.GetBytes(16);

Never use a global salt, username, user ID, timestamp, or Random. Modern password-hashing libraries often generate and encode the salt automatically; prefer that path to assembling fields manually.

A pepper is different. It is a secret shared across many password records and stored outside the credential database, ideally in a secret manager or HSM-backed service. One practical construction applies a keyed HMAC to the password hash, or follows the chosen library’s documented secret-input facility.

A pepper can reduce the value of a database-only breach. It also introduces key availability, rotation, disaster recovery, and latency concerns. If the pepper is lost, valid passwords cannot be verified. If it is rotated, existing hashes cannot be recomputed without the password, so the envelope needs a pepper key version and the system needs a gradual migration or forced-reset plan.

Do not put the pepper in the same connection string, database row, or deployment artifact as the hashes and call it a second factor.

Store a self-describing envelope

The stored value must say how to verify itself. A conceptual envelope is:

$scheme$version$parameters$salt$hash$pepper-key-version

For PBKDF2, that might carry the PRF, iteration count, salt, and output. For Argon2id, it carries version, memory, iterations, parallelism, salt, and output. Use a standard encoded form from the chosen library where possible rather than inventing a parser.

Treat envelope parsing as untrusted input even though it came from the database. A corrupted record must not request enormous memory or billions of iterations. Validate algorithm allowlists, numeric ranges, decoded lengths, and total field length before invoking the expensive function.

Keep the account identifier and status outside the hash envelope. The same format should work during a user rename, and verification should not depend on mutable profile data.

A PBKDF2 example is a primitive, not the authentication service

When PBKDF2 is the justified choice, the BCL operation looks like this:

const int Iterations = 600_000;
const int OutputBytes = 32;

byte[] derived = Rfc2898DeriveBytes.Pbkdf2(
    password,
    salt,
    Iterations,
    HashAlgorithmName.SHA256,
    OutputBytes);

Verification derives the candidate using the parameters stored in the envelope and compares bytes in fixed time:

bool valid = CryptographicOperations.FixedTimeEquals(
    storedHash,
    candidateHash);

Clear temporary byte arrays where practical, especially if a library materializes password bytes. Avoid logging the password, derived value, salt envelope, reset token, or pepper lookup result.

This code does not provide password policy, rate limiting, account lookup privacy, MFA, recovery, session invalidation, or breach monitoring. That is why using the framework identity stack is normally safer than promoting a helper like this into a custom authentication system.

Password text has compatibility rules

A password is user-chosen opaque text. Do not trim whitespace, lowercase it, or apply a new Unicode normalization rule during an upgrade unless the existing contract already did so. A transformation changes the password and can make old credentials unverifiable or create unexpected equivalences.

Support long passwords and password managers. Set a generous maximum to bound request and hashing cost, but do not silently truncate. Apply the same byte encoding and preprocessing rule during creation and verification and version any deliberate change.

When migrating from a legacy algorithm with a small input limit, preserve its exact verification behavior only for the legacy branch. After a successful login, hash the original submitted password with the current scheme and remove the legacy limitation.

Verification must not become the enumeration endpoint

The public response for an unknown account and an incorrect password should be the same. Timing also needs consideration: immediately returning for unknown usernames can provide a measurable difference from an expensive hash.

One approach is to verify a fixed dummy hash under the current policy for nonexistent accounts. It reduces the obvious timing gap, though no application-level technique guarantees identical network timing under all conditions. Combine it with rate limiting and monitoring instead of claiming perfect resistance.

Rate-limit by more than source IP. Distributed credential stuffing can spread across addresses, while many legitimate users may share one carrier or enterprise NAT. Use layered controls around account, source, device/risk signals, and global verification capacity. Avoid permanent account lockout that lets an attacker deny service to a known user; use progressive controls and a secure recovery path.

Tune for attack cost and service capacity

The strongest parameter set is not the one that makes a single laptop login feel acceptably slow. It is the strongest setting the production service can sustain under peak legitimate demand and controlled attack without exhausting CPU, memory, worker threads, or HSM calls.

For Argon2id, bound concurrent memory as well as time. If one verification uses 64 MiB and 500 are allowed concurrently, the theoretical memory demand is already 32 GiB before application overhead. For PBKDF2, CPU is the primary resource, but an unbounded asynchronous API can still queue more CPU work than the host can complete.

Benchmark on production-class instances with realistic container limits. Measure registration, successful login, incorrect password, unknown account with dummy verification, and rehash. Put a bounded admission controller around verification so overload produces an intentional response rather than process collapse. Password hashing is CPU-bound work; wrapping it in Task.Run does not create CPU capacity.

Monitor verification latency by scheme/version, rehash count, throttle decisions, failures by risk category, reset volume, and host saturation. Never label metrics with usernames or other high-cardinality credential data.

Upgrade only after successful verification

A versioned envelope supports gradual migration:

  1. Parse and validate the stored envelope.
  2. Verify with the stored algorithm and parameters.
  3. If invalid, return the same public authentication failure.
  4. If valid, compare the envelope with the current policy.
  5. Rehash the submitted password under the current policy.
  6. Replace the envelope using optimistic concurrency.

Concurrent successful logins can both attempt an upgrade. Use a row version or conditional update so an older result cannot overwrite a newer hash or password change. Re-read security state before issuing the session.

ASP.NET Core Identity surfaces this distinction through PasswordVerificationResult.SuccessRehashNeeded. If customizing Identity’s hasher, preserve that upgrade path rather than returning only a boolean.

Accounts that never log in do not migrate. Decide how long legacy hashes remain acceptable. A deadline may require password reset on next use, targeted notification, or invalidation after a risk review. Do not weaken the current verifier indefinitely just to retain dormant credentials.

Reset and change flows are part of password storage

A password change requires the authenticated user and usually the current password or a stronger recent-authentication policy. A reset relies on a short-lived, single-use token delivered through a separately protected channel. Both must:

  • write the new current-policy hash;
  • invalidate prior reset tokens;
  • rotate security/session stamps and revoke sessions according to policy;
  • notify the account through a trusted channel;
  • avoid exposing whether an unauthenticated account exists;
  • produce an audit event without credential material.

Recovery can be weaker than login if email, help-desk, or phone procedures are not held to the same threat model. Strong hashing does not repair an account reset that an attacker can socially engineer.

Failure rehearsal

Before rollout, test more than known password vectors:

  • identical passwords produce different stored hashes;
  • malformed Base64, truncated fields, unknown algorithms, and out-of-range costs fail safely;
  • wrong password, missing user, disabled user, and old hash return the intended public response;
  • current and supported legacy envelopes verify against fixed test vectors;
  • successful legacy verification upgrades exactly once under concurrent requests;
  • a password change racing with rehash cannot be overwritten;
  • pepper service outage fails closed and does not log secrets;
  • pepper key rotation accepts the intended versions and migrates forward;
  • peak login and attack traffic respect the verification concurrency budget;
  • rollback can still verify hashes produced by the release being rolled back;
  • backups contain hashes and salts but not the pepper or reset-token signing material.

The rollback case is frequently missed. Deploying code that writes a new envelope version means the previous release must either understand it or the deployment must not roll back after writes begin. Treat hash-format introduction like a database compatibility migration.

The boundary

The algorithm matters, but secure password storage is an operated compatibility boundary. The record needs unique salt and versioned parameters. Optional pepper keys need independent custody and rotation. Verification needs fixed-time comparison, bounded capacity, uniform public failures, and a path to stronger settings. Reset, session, and monitoring behavior must preserve the same threat model.

Calling a password derivation function was one task. Keeping every credential verifiable, upgradeable, and expensive to attack through breach, deployment, and recovery was the authentication design.

Technical references

Keep reading
Browse everything