Customize password hashing

Replace the default PBKDF2 hasher with your own — use a different KDF, migrate from bcrypt/argon2, or opportunistically rehash legacy rows on login.


SmartData.Server registers IPasswordHasher with TryAddSingleton, so you override it by registering your own implementation before calling AddSmartData() (or any time before the container is built). The default is Pbkdf2PasswordHasher (PBKDF2-HMAC-SHA256, 100,000 iterations, 16-byte salt, 32-byte hash; stored as {base64-salt}.{base64-hash}).

The interface

public interface IPasswordHasher
{
    string Hash(string password);
    bool Verify(string password, string storedHash);
}

A single implementation owns both directions: it produces the stored form via Hash, and accepts any stored form it recognizes via Verify. That second part is what makes aggregate hashers possible.

Swap in a custom KDF

public class BcryptPasswordHasher : IPasswordHasher
{
    public string Hash(string password) => BCrypt.Net.BCrypt.HashPassword(password);
    public bool Verify(string password, string storedHash) => BCrypt.Net.BCrypt.Verify(password, storedHash);
}

builder.Services.AddSingleton<IPasswordHasher, BcryptPasswordHasher>();
builder.Services.AddSmartData(/* ... */);

AddSingleton (not TryAddSingleton) wins over the framework's default registration regardless of order, but registering before AddSmartData() makes the intent obvious in code review.

Migrate from a legacy format with opportunistic rehash

When importing users from an existing system, you don't need a flag day. Write an aggregate hasher that recognizes both the legacy format and your preferred format, and rehashes legacy rows when their owners next log in.

public class BcryptToPbkdf2Hasher : IPasswordHasher
{
    private readonly Pbkdf2PasswordHasher _preferred = new();
    private readonly IDatabaseProvider _db;

    public BcryptToPbkdf2Hasher(IDatabaseProvider db) => _db = db;

    public string Hash(string password) => _preferred.Hash(password);

    public bool Verify(string password, string storedHash)
    {
        if (LooksLikeBcrypt(storedHash) && BCrypt.Net.BCrypt.Verify(password, storedHash))
        {
            // Upgrade in place. The stored hash is unique enough to key the update.
            var newHash = _preferred.Hash(password);
            using var conn = _db.OpenConnection("master");
            conn.GetTable<SysUser>()
                .Where(u => u.PasswordHash == storedHash)
                .Set(u => u.PasswordHash, newHash)
                .Update();
            return true;
        }

        return _preferred.Verify(password, storedHash);
    }

    private static bool LooksLikeBcrypt(string hash) =>
        hash.StartsWith("$2a$") || hash.StartsWith("$2b$") || hash.StartsWith("$2y$");
}

Pair this with the PasswordHash parameter on sp_user_create (see Manage users from code → Import a user with a pre-computed hash) to seed the legacy rows verbatim. Each user's row gets rewritten to the preferred format the next time they log in successfully.

Notes

  • KDF parameters in Pbkdf2PasswordHasher (iteration count, hash size, etc.) are an implementation detail and may change between SmartData versions. If you need to lock a specific algorithm, register your own IPasswordHasher.
  • The hasher is invoked from SessionManager.Login, SpUserCreate, SpUserUpdate, and the master-database admin seeder. A custom hasher needs to handle every credential path you care about.
  • The opportunistic-rehash pattern issues one UPDATE per legacy login — fine for typical workloads. If you'd rather not write on the read path, drop the rehash branch and run a one-off migration job instead.