hashjwtbcryptbcryptjs

Multiple JWT's match hashed JWT


I'm using bcryptjs to hash a user's refresh_token before storing it in my database.

It seems that the following always evaluates to true when comparing a hashed string with a JWT, I've also gotten the same behavior on https://bcrypt-generator.com/

for example the hash $2a$10$z4rwnyg.cVtP2SHt3lYj7.aGeAzonmmzbxqCzi2UW3SQj6famGaqW is a match with the following two JWTs

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NTZlODdkNi1jMmVkLTRmN2ItOTU2Zi00NDFhMWU1NjA2MmQiLCJpYXQiOjE2Mzk1OTg2MDIsImV4cCI6MTY0MjE5MDYwMn0.aJlzFHhBMGO4J7vlOudqOrOFnL1P-yEGrREgdaCXlxU

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NTZlODdkNi1jMmVkLTRmN2ItOTU2Zi00NDFhMWU1NjA2MmQiLCJpYXQiOjE2Mzk2MDY4ODgsImV4cCI6MTY0MjE5ODg4OH0.vo4HKLXuQbT0Yb0j21M4xl-rakxyE5wINjuGdkPuSJY

You can verify these on the site as well that they both result in a 'match'

  1. Go to https://bcrypt-generator.com/ and open your browser console.

  2. Enter these lines into the console:

    > var jwt1 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NTZlODdkNi1jMmVkLTRmN2ItOTU2Zi00NDFhMWU1NjA2MmQiLCJpYXQiOjE2Mzk1OTg2MDIsImV4cCI6MTY0MjE5MDYwMn0.aJlzFHhBMGO4J7vlOudqOrOFnL1P-yEGrREgdaCXlxU"
    < undefined
    
    > var jwt2 = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NTZlODdkNi1jMmVkLTRmN2ItOTU2Zi00NDFhMWU1NjA2MmQiLCJpYXQiOjE2Mzk2MDY4ODgsImV4cCI6MTY0MjE5ODg4OH0.vo4HKLXuQbT0Yb0j21M4xl-rakxyE5wINjuGdkPuSJY"
    < undefined
    
    > var h = "$2a$10$z4rwnyg.cVtP2SHt3lYj7.aGeAzonmmzbxqCzi2UW3SQj6famGaqW"
    < undefined
    
  3. Then enter these lines into the console, observe how they return true:

    > bcrypt.compareSync(jwt1, h)
    < true
    > bcrypt.compareSync(jwt2, h)
    < true
    

This is my own JS code that also reproduces the hash match:

// Login Logic

const refresh_token: string = jwt.sign({ userId }, authSecrets.refresh_secret, { expiresIn: '30d' });

const hash_refresh = bcrypt.hashSync(refresh_token);

await UserModel.update({
    id: user.id,
    refresh_token: hash_refresh,
});


// Refresh logic
// 'value' is the payload after using joi to validate it 

const claims: any = jwt.verify(value.refresh_token, authSecrets.refresh_secret);

user = await UserModel.get(claims.userId);

if (!bcrypt.compareSync(value.refresh_token, user.refresh_token)) {
    // This never happens with any JWT!
    return response(401, 'Refresh Token is incorrect');
}

Why is this happening? the strings are clearly different (although not by a lot).


Solution

  • The hash collisions are because bcrypt only hashes the first 72 bytes of input (in most implementations).

    This is documented in the README for both the bcryptjs and bcrypt npm packages:

    bcryptjs:

    The maximum input length is 72 bytes (note that UTF8 encoded characters use up to 4 bytes) and the length of generated hashes is 60 characters.

    bcrypt:

    Per bcrypt implementation, only the first 72 bytes of a string are used. Any extra bytes are ignored when matching passwords. Note that this is not the first 72 characters. It is possible for a string to contain less than 72 characters, while taking up more than 72 bytes (e.g. a UTF-8 encoded string containing emojis).

    (That's an objectively terrible design considering this is for user-security... The bcryptjs library really should always throw an exception if the input exceeds 72 bytes IMO)

    I note that bcrypt is design for human-supplied (i.e. non-random) passwords, not as a general-purpose message-digest algorithm. Given you don't need to add a salt to randomly-generated passwords (like your refresh_token value) you probably should use something like a SHA-2 family algorithm (e.g. SHA-256, but not SHA-1) for this.