DEV Community

Cover image for The Bcrypt Trap: How Hashing JWTs Silently Breaks Refresh Token Rotation
Wilfrid Okorie
Wilfrid Okorie

Posted on

The Bcrypt Trap: How Hashing JWTs Silently Breaks Refresh Token Rotation

This article talks about an easy-to-miss danger of using JWTs for authentication tokens. It is a danger that is so often not tested negatively, often slips into production codebases, yet is bad enough to bring the entire systems down.

JSON Web Tokens

JSON Web Tokens (JWTs) are an open industry standard used to securely transmit information between parties as a JSON object. This information is verifiable and trusted because it is digitally signed.
JWTs are stateless, and the server does not need to store the session information. Instead, the server issues a token to the client, and the client sends that token back with every subsequent request. The server can verify the token's authenticity independently without hitting a database.

Structure of a JSON Web Token

A JWT is composed of three parts, separated by dots (.): header.payload.signature

Header

The header consists of the type of token which is usually JWT, and the signing algorithm used, such as RSA or HMAC SHA256. It is encoded in base64URL.
The decoded header looks something like:

{
   "alg": "HS256",
   "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

Payload

The payload consists of metadata, sometimes known as Claims. They are statements about an entity (in authentication, the user) and additional data. They could be registered claims such as issuance time (iat), issuer (iss), expiration time (exp), or subject (sub). They could be public claims defined publicly in IANA JSON Web Token Registry. They could also (and most often used in authentication) be private claims, which is where user metadata falls, such as user_id, role, etc.
The payload is also encoded in base64URL.

{
   "sub": "117272829822882828",
   "name": "John Doe",
   "admin": "true"
}
Enter fullscreen mode Exit fullscreen mode

Signature

This is created by using a secret key, with the algorithm specified in the header to sign encoded payload + header.
It looks something like:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)
The signature is used to verify that the sender of the JWT is who they say they are, and ensure the message wasn't changed along the way.

A JWT is a compact, URL-safe means of representing claims to be transferred between two parties. A sample string looks something like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Modern-day Authentication

Modern-day authentication uses JWTs, with the server issuing two tokens to the client; a short-lived (typically 3 to 15 minutes) access token sent on every request and used to authenticate the user, and a long-lived (sometimes up to 7 days) refresh token, which is used to generate a new set of tokens (access and refresh) according to their use. They can live up to 7 days, so that a user can obtain tokens and continue their session even when they have been away for that long, without having to login over again.

  • Login: User sends credentials to the auth server
  • Creation: Auth server authenticates the user and generates JWT(s) using the secret key(s).
  • Transmission: The server sends the tokens back to the client
  • Storage: The client stores the token(s) wherever, which could be in cookies, localStorage, sessionStorage, or whatever resourceful solution the client comes up with.
  • Request: For every protected request, the client sends the JWT in the Authorization header: Bearer <token> (or it is sent automatically as with httpOnly cookies).
  • Verification: The API server receives the token, decodes the header to see the algorithm, and uses its copy of the secret to verify the signature. If valid, it trusts the claim in the payload.
  • Refresh: The access token is short-lived, so when it expires naturally, it cannot be used to authenticate requests.

During initial login, the generated refresh token is hashed, and the hash is persisted. When the client wants to refresh, the client sends the refresh token, and the hash is compared against what is stored in the database, and if it matches, tokens are signed again and sent to the user in the response. The new refresh token that is generated is hashed, and the new token hash replaces the old one in the database. This way, if a refresh token has been used to refresh once, it cannot be used again.

The Easy-to-miss Danger

Everything below applies when JWTs are used as refresh tokens, not access tokens. The access token should contain user metadata, since it is used to authenticate the user on every request.
Different hashing algorithms are used to hash the generated refresh token before storing the hash in the database. Many systems use SHA256, but one of the most frequently used hashing methods is bcrypt hashing. Bcrypt (a hashing function built on the Blowfish cipher) differs from SHA256 in the sense that it is intentionally slow, slowing down brute-force attacks, has a salt built-in so that hash('123') != hash('123') twice to stop rainbow attacks.
However, Bcrypt has a 72-byte limit, and silently truncates anything greater than 72 bytes. JWT strings are very long, typically > 200 bytes in length, so using Bcrypt to hash JWTs hashes just the first 72 bytes.
Only the signature part of a JWT is cryptographically secure. The rest of it is encoded, not encrypted.
Let's take a practical look at this.
For the next section, we use this jwt encoder and this random key generator to generate random UUIDs so that we use different JTIs
Let's construct a JWT. The header would look something like:

Our secret key: a-string-secret-at-least-256-bits-long
{
  "alg": "HS256",
  "typ": "JWT"
}
Enter fullscreen mode Exit fullscreen mode

We will consistently use this secret key to sign different payloads with the jwt encoder.

Payload: 
{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "jti": "f27ca436-01b6-42fb-854b-f2de6e29c337"
}

Resulting token: 
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImp0aSI6ImYyN2NhNDM2LTAxYjYtNDJmYi04NTRiLWYyZGU2ZTI5YzMzNyJ9.C9yFWhlCNLvPnEGo37fYX93C2hgyQMBjvOm91tPw2oY

Payload:
{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "jti": "02657189-789c-4ef8-9412-d5f3ad48f260"
}

Resulting token: 
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImp0aSI6IjAyNjU3MTg5LTc4OWMtNGVmOC05NDEyLWQ1ZjNhZDQ4ZjI2MCJ9.2Jc6qLmbKyWP0Y1g4xCZPVVv3KJQBuf89cNoiqfOu78

Payload:
{
  "sub": "1234567890",
  "name": "John Doe",
  "jti": "02657189-789c-4ef8-9412-d5f3ad48f260",
  "admin": true
}

Resulting token: 
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwianRpIjoiMDI2NTcxODktNzg5Yy00ZWY4LTk0MTItZDVmM2FkNDhmMjYwIiwiYWRtaW4iOnRydWV9.t1SoE0cz40FpRNGy9e9mj4OGAmFWzJnx-3_OIAg8n9A
Enter fullscreen mode Exit fullscreen mode

From the results above, using the header.payload.signature structure, the header is exactly the same in all cases - it is mere encoding. You can decode the header with this base64URL decoder.

JWT header decryption from base64URL

This is because the JSON serializer serializes the fields in a consistent order. This can also be seen when you see that in the first two payloads, the payload section of the JWT string is almost the same as well, and the only difference is towards the end, because JTIs differ. To buttress that further, when the jti and admin fields are exchanged in position, the divergence point shifts earlier in the string.

However, the fact remains that in all of the tokens, the first 72 bytes: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI is the same in all of the resulting tokens.

This means that for two different refresh tokens, the hash matches when checked with bcrypt's compare method.

While this would not directly cause refresh tokens not to work, it renders the invalidating process of refresh tokens (replacing old hashes with new hashes) pointless, because no matter how many times the refresh token is generated, the same bytes are in the first 72 bytes of the resulting JWT string, and these 72 bytes are hashed. The implication is that old refresh tokens still work.

This means that if an attacker gets hold of a refresh token issued by a server in which this error is made on the backend, the attacker can 100% extend their session as long as they want, because even when the real owner refreshes, the old refresh token remains valid, and hence the attacker who gets an access token in refreshing as well, now has the keys needed to make requests to every protected route.

Mitigation:

Since we now know that using bcrypt to sign JWT refresh tokens is a disaster, we have three logical options, and here is what each of the options entails:
Option 1: Keep using JWT, use SHA256. Since the issue is bcrypt's 72-byte limit, we could use SHA-256. There is no byte length limit for this, so token rotation works fine. However, using a JWT as a refresh token carries redundant data (the payload which is already in the DB). This would work, and then the refresh token hash can actually be used for DB lookup, since with SHA256, the hash is always the same down to the byte. When you hash the provided token and you don't find it in the database, it can be used as an unauthorized flag. There is no real disadvantage here, but there is also no benefit.

Option 2: Use cryptographically-secure random bytes, with bcrypt. This is completely opaque. 32 random bytes results in 64 hex characters, is completely opaque, high entropy, and does not leak session information. There is no bcrypt truncation issue here, since the random bytes can be generated to be less than 72 bytes in length. Bcrypt works exactly as intended, but your refresh token is not a JWT anymore, so it does not look pretty. No real issue here. However, for a randomly generated token, bcrypt is not exactly the right pick. The features of bcrypt mentioned at the top of the article aree specifically built to resist brute-forcing low-entropy secrets like passwords. Random bytes like this has at least 256 bits of entropy, so the chances of brute force attacks are negligible. The slow hashing and built-in salt are just extra work on the CPU. Also, bcrypt's built-in salt makes it unsuitable for hash-based lookups at all, meaning the entire entity would have to be fetched before checking presented hash against stored hash using bcrypt's compare method. This does not scale before for multiple active user sessions, forces you to know user id before verifying token, and adds more calls to what would have been solved by a simple hash lookup.

Option 3: Use cryptographically-secure random bytes + SHA256. This also works fine, and SHA256 is faster and still secure for high-entropy inputs like random bytes. It is slightly less collision-resistant than bcrypt in a brute-force scenario. For 32 or 64 (or less than 72) random bytes, the practical difference is negligible. Furthermore, SHA256 is deterministic, making hash-based lookups possible, and mitigating the issues mentioned in option 2 with Bcrypt.

I would go for option 3. Instead of signing a JWT at all for refresh token generation, cryptographically-secure random bytes is the solution, because it already comes with the entropy it needs.

It is surprising how often this combination is misused, in production codebases. How often have you seen this mistake made?
What are some other oversights in terms of security often made in authentication?

Top comments (0)