Salted Password Hashes With HASHBYTES and CRYPT_GEN_RANDOM

Salted password hashes make two identical passwords look different in your table, but they do not make guessing slow. One HASHBYTES call is fast, and a fast hash is a gift to anyone trying millions of guesses. Treat this post as a map of a legacy design, not a recipe for a new one.

Two differently textured tenderizer faces making distinct impressions in plain dough

What a salt does for you

Imagine a database leak. If two users share a password and you stored a plain hash, their hashes match. One cracked hash opens every account that uses it. A salt fixes that. It is a random value stored next to the hash and mixed into the input. The same password then produces a different digest for every user.

The salt is not a secret key. It sits in the same row as the digest. Its only job is to make each stored value unique. The passwords in this post are made up, so never put a real password in a script.

Build one salted digest

The first query makes a 16-byte random salt with CRYPT_GEN_RANDOM, then hashes the salt plus the password bytes with SHA2_512. It then checks the right and the wrong password against the stored digest. Keep the byte layout and the order of concatenation identical every time you verify.

DECLARE @Password nvarchar(200) = N'Sample phrase for the demo';
DECLARE @Salt varbinary(16) = CRYPT_GEN_RANDOM(16);
DECLARE @Digest varbinary(64) = HASHBYTES('SHA2_512',
    CONVERT(varbinary(max), @Salt) + CONVERT(varbinary(max), @Password));

SELECT DATALENGTH(@Salt) AS SaltBytes, DATALENGTH(@Digest) AS DigestBytes,
       CASE WHEN @Digest = HASHBYTES('SHA2_512', CONVERT(varbinary(max), @Salt)
            + CONVERT(varbinary(max), @Password)) THEN 1 ELSE 0 END AS CorrectMatches,
       CASE WHEN @Digest = HASHBYTES('SHA2_512', CONVERT(varbinary(max), @Salt)
            + CONVERT(varbinary(max), N'Wrong phrase')) THEN 1 ELSE 0 END AS WrongMatches;

The salt has 16 bytes and the digest has 64. The correct phrase matches with 1, and the wrong one returns 0.

NULL and encoding bite quietly

Two small traps live here. A missing password gives a NULL hash, so reject empty input before you hash. And the same text hashes differently as varchar and as nvarchar, because the bytes differ. Pick one encoding and keep it.

SELECT HASHBYTES('SHA2_512', CAST(NULL AS varbinary(max))) AS MissingInput,
       CASE WHEN HASHBYTES('SHA2_512', CONVERT(varbinary(max), CAST('sample' AS varchar(20))))
               = HASHBYTES('SHA2_512', CONVERT(varbinary(max), CAST(N'sample' AS nvarchar(20))))
            THEN 1 ELSE 0 END AS EncodingsMatch;
Hash results showing salt and digest sizes, match flags, NULL input and different encodings
The salt has 16 bytes and the digest has 64. The right phrase matches, and varchar and nvarchar text do not.

The missing input produces NULL, and EncodingsMatch is 0. For the same reason, do not trim, lowercase or shorten a password before hashing. A friendly cleanup quietly changes what the user typed.

Two users, one password, two digests

Now see the salt do its job. Two users get the same password. The second query counts how many different digests you would store with and without a salt.

DROP TABLE IF EXISTS #Users;
CREATE TABLE #Users
(
    UserName nvarchar(20)  NOT NULL PRIMARY KEY,
    Salt     varbinary(16) NOT NULL,
    Digest   varbinary(64) NOT NULL
);

DECLARE @Password nvarchar(200) = N'Sample phrase for the demo';

INSERT #Users (UserName, Salt, Digest)
SELECT u.UserName, s.Salt, HASHBYTES('SHA2_512', s.Salt + CONVERT(varbinary(max), @Password))
FROM (VALUES (N'user_a'), (N'user_b')) AS u(UserName)
CROSS APPLY (SELECT CRYPT_GEN_RANDOM(16) AS Salt) AS s;

SELECT COUNT(*) AS users,
       COUNT(DISTINCT Digest) AS distinct_digests_with_salt,
       COUNT(DISTINCT HASHBYTES('SHA2_512', CONVERT(varbinary(max), @Password))) AS distinct_digests_without_salt
FROM #Users;

Two users, two different digests with a salt, and only one without. Verification still works, because each row carries its own salt. Rebuild the hash from the salt and the attempt, then compare.

SELECT u.UserName,
       CASE WHEN u.Digest = HASHBYTES('SHA2_512', u.Salt
            + CONVERT(varbinary(max), N'Sample phrase for the demo')) THEN 1 ELSE 0 END AS RightAttempt,
       CASE WHEN u.Digest = HASHBYTES('SHA2_512', u.Salt
            + CONVERT(varbinary(max), N'Wrong phrase')) THEN 1 ELSE 0 END AS WrongAttempt
FROM #Users AS u
ORDER BY u.UserName;
What a salt does and does not do

Why one fast hash is still weak

The salt stops one precomputed table from serving every account. It does not stop someone from guessing against a single account. Watch how fast that goes. Here is an account whose password is Welcome150000, and a script that tries 200,000 guesses.

DECLARE @Salt varbinary(16) = CRYPT_GEN_RANDOM(16);
DECLARE @Digest varbinary(64) = HASHBYTES('SHA2_512', @Salt + CONVERT(varbinary(max), N'Welcome150000'));
DECLARE @Start datetime2 = SYSDATETIME();

SELECT COUNT(*) AS guesses_tried,
       SUM(CASE WHEN HASHBYTES('SHA2_512', @Salt + CONVERT(varbinary(max), CONCAT(N'Welcome', value))) = @Digest
                THEN 1 ELSE 0 END) AS found
FROM GENERATE_SERIES(1, 200000);

SELECT DATEDIFF(MILLISECOND, @Start, SYSDATETIME()) AS elapsed_ms;

It finds the password, found = 1, in under a second on my test server. I saw about 750 ms. Your time will differ, but the lesson will not. The salt did not slow anything down. For a real system, use a password-hashing library in your application, built to be slow on purpose, with a work factor you can raise later.

Plan the migration, not just the hash

Store the algorithm and a version number with every credential, so you can upgrade later. Move users to the new scheme when they log in successfully. Limit who can read the credential table, and return a yes or no, never the salt and digest. Old backups and exports still hold the old values, so include them in your review.

DROP TABLE IF EXISTS #Users;

Keep the password rules in the application, and use this only to understand what you inherited.

A salted hash is not a login system, it is one small part of one.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

SQL Password, SQL Random, SQL Server
Previous Post
SQL SERVER – Fix Error 3271: A nonrecoverable I/O error occurred on file. The remote server returned an error: (404) Not Found
Next Post
SQL SERVER – Database Attach Failure – Msg 2571 – User ‘guest’ Does Not Have Permission to Run DBCC Checkprimaryfile.

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.