Passwords Done Properly

Salted slow hashing, constant-time comparison, never storing or logging the plain password, and when to let Identity do it.

SyntaxRfc2898DeriveBytes.Pbkdf2(string password, byte[] salt, int iterations, HashAlgorithmName hashAlgorithm, int outputLength)

Why a password needs a slow hash

A hash function turns text into a fixed-length value and cannot be run backwards. That is not enough on its own. If a stolen database holds fast hashes, an attacker can try billions of guesses a second until one matches.

So password hashing uses a deliberately slow algorithm. PBKDF2 repeats a hash many thousands of times. One sign-in pays that cost once; someone guessing pays it on every guess. Each password also gets a salt: random bytes stored next to the hash. Two people who choose the same password then get different stored values, so cracking one reveals nothing about the other.

Comparing without leaking

Comparing byte arrays with == compares references, not contents. Comparing them byte by byte stops at the first difference, and how long that takes is measurable, which tells an attacker how much of the guess was right. CryptographicOperations.FixedTimeEquals always reads every byte.

Let the library do it

Writing this by hand is worth understanding once, then stopping. PasswordHasher from ASP.NET Core Identity chooses the algorithm, the salt and the iteration count, and returns one string holding all of them plus the hash. Its VerifyHashedPassword can also report SuccessRehashNeeded, meaning the password was right but the stored hash used older settings, so you can rewrite it during the sign-in.

Never keep the plain password

Hash it and let the variable go. A password must never reach a log line, an exception message, a URL or an analytics event. Accept it in a request body only, never as a route or query value, because those are written to access logs by default.

Example

Example · csharp
using System.Security.Cryptography;

// A salt is random bytes mixed into the hash. Two people with the same
// password then get different stored values, so one cracked hash does
// not reveal the other. The salt is stored, not secret.
byte[] salt = RandomNumberGenerator.GetBytes(16);
byte[] stored = Hash("correct horse battery staple", salt);

bool rightPassword = Verify("correct horse battery staple", salt, stored);
bool wrongCase = Verify("Correct horse battery staple", salt, stored);

Console.WriteLine($"right password: {rightPassword}");
Console.WriteLine($"wrong case:     {wrongCase}");
Console.WriteLine($"stored bytes:   {stored.Length}");

// => expr is the expression-bodied shorthand for { return expr; }.
// PBKDF2 repeats the hash on purpose: cheap once, ruinous a billion times.
static byte[] Hash(string password, byte[] salt) =>
    Rfc2898DeriveBytes.Pbkdf2(
        password: password,
        salt: salt,
        iterations: 210_000,
        hashAlgorithm: HashAlgorithmName.SHA512,
        outputLength: 32);

static bool Verify(string candidate, byte[] salt, byte[] stored)
{
    byte[] attempt = Hash(candidate, salt);

    // Reads every byte, so the time taken does not reveal how many
    // bytes matched before the first difference.
    return CryptographicOperations.FixedTimeEquals(attempt, stored);
}

// Output:
// right password: True
// wrong case:     False
// stored bytes:   32

When to use it

  • Store a new user's password so a database leak does not hand over their account.
  • Upgrade an existing table of weak SHA-256 password hashes to a slow salted algorithm.
  • Check a webhook signature or API key without leaking information through timing.
  • Review a sign-in endpoint for password values reaching logs or query strings.

More examples

Using the Identity hasher instead

What to reach for in an application rather than hand-rolled PBKDF2: the library owns the algorithm choice and the iteration count, so raising the cost later is a configuration change plus the rehash branch above. Ignoring SuccessRehashNeeded still signs the user in but leaves them on the old, weaker settings forever.

Example · csharp
using Microsoft.AspNetCore.Identity;

namespace Accounts;

public sealed class AppUser
{
    public required string Email { get; init; }
    public string PasswordHash { get; set; } = "";
}

// AddIdentityCore<AppUser>() registers IPasswordHasher<AppUser>.
// The parameter list on the class is a primary constructor; the longhand
// is a private field plus a constructor that assigns it.
public sealed class AccountService(IPasswordHasher<AppUser> hasher)
{
    public void SetPassword(AppUser user, string password)
    {
        // One string holds the format marker, the salt, the iteration
        // count and the hash. Store it in a single text column.
        user.PasswordHash = hasher.HashPassword(user, password);
    }

    public bool CheckPassword(AppUser user, string password)
    {
        PasswordVerificationResult result =
            hasher.VerifyHashedPassword(user, user.PasswordHash, password);

        if (result == PasswordVerificationResult.SuccessRehashNeeded)
        {
            // Correct password, outdated settings. This is the only moment
            // the plain password is available, so rewrite the hash now.
            SetPassword(user, password);
            return true;
        }

        return result == PasswordVerificationResult.Success;
    }
}

A sign-in endpoint that gives nothing away

The shape of the endpoint matters as much as the hashing. Two distinguishable failure replies turn the sign-in form into an account-enumeration tool, and one careless log statement puts live passwords in plain text where anyone with log access can read them.

Example · csharp
// Program.cs (excerpt)
app.MapPost("/login", async (LoginRequest request, AccountService accounts,
    ILogger<Program> logger) =>
{
    AppUser? user = await accounts.FindByEmailAsync(request.Email);

    // One message for both failures. A separate "no such account" reply
    // tells an attacker which email addresses are registered.
    if (user is null || !accounts.CheckPassword(user, request.Password))
    {
        // Log the email. Never log the password, not even on failure.
        logger.LogWarning("Failed sign-in for {Email}", request.Email);
        return Results.Problem("Email or password is incorrect.", statusCode: 401);
    }

    return Results.Ok();
});

// The password arrives in the request body only. A route value or a query
// string value would be written into server and proxy access logs.
public record LoginRequest(string Email, string Password);

The same comparison rule for API keys

Reach for this when checking a webhook signature, a shared API key or a password-reset token. These are compared on every request, which is exactly the repetition an attacker needs to measure timing differences from a plain string comparison.

Example · csharp
using System.Security.Cryptography;
using System.Text;

namespace Security;

public static class ApiKeys
{
    // Any secret comparison should be constant time, not just passwords.
    public static bool Matches(string presented, string expected)
    {
        byte[] a = Encoding.UTF8.GetBytes(presented);
        byte[] b = Encoding.UTF8.GetBytes(expected);

        // Note: when the lengths differ this returns false straight away,
        // so it hides the contents but not the length. Fixed-length keys
        // make that irrelevant.
        return CryptographicOperations.FixedTimeEquals(a, b);
    }
}

Discussion

  • Be the first to comment on this lesson.