DEV Community

TeKa IT
TeKa IT

Posted on

Why You Should Hash Refresh Tokens with SHA-256

Refresh tokens are often treated as an implementation detail.

Generate a random string, store it in the database, return it to the client, and use it later to issue a new access token.

It works.

But there is an important security question hiding in that design:

What happens if someone gets read access to your refresh-token database?

If the database contains the actual refresh tokens, the answer is uncomfortable.

An attacker who can read the database may be able to use those tokens immediately.

That is why refresh tokens should generally be treated like other authentication secrets: the server should be able to verify them without storing the original value in plaintext.

In this article, we'll build that idea from first principles and implement it with SHA-256.

We'll look at:

  • why storing refresh tokens in plaintext is risky
  • why hashing is useful for refresh tokens
  • why SHA-256 is appropriate for high-entropy tokens
  • why BCrypt is the wrong tool for this particular job
  • how to generate cryptographically random refresh tokens
  • how to hash and validate them in Spring Boot
  • how hashing fits together with refresh-token rotation
  • what hashing does — and does not — protect you from

The important distinction throughout this article is this:

Passwords and refresh tokens are both secrets, but they have very different security properties.


1. A Refresh Token Is a Bearer Secret

Consider a typical authentication flow.

A user logs in and receives:

access token
refresh token
Enter fullscreen mode Exit fullscreen mode

The access token might be a JWT:

eyJhbGciOiJIUzI1NiJ9...
Enter fullscreen mode Exit fullscreen mode

The refresh token is usually just an opaque random value:

V4Z8v2mQ4Qm7Qe7z...
Enter fullscreen mode Exit fullscreen mode

The client later sends the refresh token to an endpoint such as:

POST /api/auth/refresh
Enter fullscreen mode Exit fullscreen mode

The server validates it and issues a new access token.

The important property is that the refresh token is a bearer credential.

Whoever possesses a valid refresh token can potentially use it.

There is no username and password challenge attached to the token.

There is no proof that the person presenting the token is the original user.

Possession is the credential.

That means this:

refresh token = authentication secret
Enter fullscreen mode Exit fullscreen mode

And that leads directly to the database question.

If the database contains:

V4Z8v2mQ4Qm7Qe7z...
Enter fullscreen mode Exit fullscreen mode

then anyone who obtains that value may have obtained an authentication credential.

A database dump therefore becomes more than a data confidentiality problem.

It can become a session hijacking problem.


2. Why Plaintext Storage Is Unnecessary

Suppose the server generates this refresh token:

RANDOM_SECRET
Enter fullscreen mode Exit fullscreen mode

The client receives:

RANDOM_SECRET
Enter fullscreen mode Exit fullscreen mode

But the database does not actually need to know the original value.

The server only needs to answer one question later:

"Does the refresh token presented by the client correspond to a valid token stored in the database?"

That means we can store a one-way representation instead:

RANDOM_SECRET
      |
      v
   SHA-256
      |
      v
HASHED_VALUE
Enter fullscreen mode Exit fullscreen mode

When the client later sends the refresh token:

RANDOM_SECRET
Enter fullscreen mode Exit fullscreen mode

the server performs the same transformation:

RANDOM_SECRET
      |
      v
   SHA-256
      |
      v
HASHED_VALUE
Enter fullscreen mode Exit fullscreen mode

Then it compares the resulting hash with the value stored in the database.

The original refresh token never needs to be stored.

This changes the security properties of a database compromise.

Plaintext storage

Database:

refresh_token
--------------------------------
V4Z8v2mQ4Qm7Qe7z...
Enter fullscreen mode Exit fullscreen mode

If an attacker reads the database, they have the token.

Hashed storage

Database:

token_hash
----------------------------------------------------------------
9f86d081884c7d659a2feaa0c55ad015...
Enter fullscreen mode Exit fullscreen mode

The database contains a verifier rather than the credential itself.

That distinction matters.

OWASP's session-management guidance explicitly describes storing a one-way verifier for random session tokens when read-only disclosure of the session store is part of the threat model. It also notes that fast hashing is sufficient for these random verifiers.


3. But Why SHA-256?

This is where refresh tokens differ from passwords.

A common reaction is:

"We're storing something sensitive. Shouldn't we use BCrypt?"

Not necessarily.

In fact, using BCrypt for refresh tokens is usually solving the wrong problem.

To understand why, we need to look at the difference between a password and a randomly generated token.


4. Passwords Are Low-Entropy Secrets

Passwords are chosen by humans.

That creates a problem.

Consider:

password123
Enter fullscreen mode Exit fullscreen mode

An attacker can make educated guesses.

They can try:

password
password1
password123
qwerty
letmein
summer2026
...
Enter fullscreen mode Exit fullscreen mode

Even a reasonably long human-generated password may have much less entropy than its length suggests.

That's why password hashing algorithms deliberately make every guess expensive.

BCrypt, Argon2 and PBKDF2 are designed to make password guessing computationally expensive.

For example:

password
    |
    v
   BCrypt
    |
    v
stored password hash
Enter fullscreen mode Exit fullscreen mode

The cost of BCrypt is a feature.

You want attackers to pay a significant computational price for every password guess.

Spring Security's BCryptPasswordEncoder is specifically designed around this model and deliberately uses a work factor to make password hashing expensive.


5. Refresh Tokens Should Have High Entropy

A properly generated refresh token has a very different property.

It should not be:

refresh-token-123
Enter fullscreen mode Exit fullscreen mode

or:

user-42-refresh-token
Enter fullscreen mode Exit fullscreen mode

or:

a8f3c91
Enter fullscreen mode Exit fullscreen mode

Instead, generate it using a cryptographically secure random number generator.

For example:

SecureRandom secureRandom = new SecureRandom();

byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);

String refreshToken = Base64.getUrlEncoder()
        .withoutPadding()
        .encodeToString(bytes);
Enter fullscreen mode Exit fullscreen mode

Here we generate:

32 random bytes
Enter fullscreen mode Exit fullscreen mode

That's:

256 bits
Enter fullscreen mode Exit fullscreen mode

of random input.

The important property isn't the string length.

It's the entropy.

If the token is generated correctly, an attacker cannot realistically predict the next valid token.

OWASP's session-management guidance similarly emphasizes that reference tokens should be generated using a cryptographically secure random number generator and have sufficient entropy.

So the security problem is no longer:

"How do we make every hash calculation expensive?"

The problem becomes:

"How do we prevent someone who can read our database from immediately obtaining the original bearer secret?"

That's exactly where SHA-256 fits.


6. SHA-256 Is Fast — and That's Fine Here

SHA-256 is designed to be fast.

For passwords, that is a problem.

For a high-entropy random token, it is not.

Consider the two scenarios.

Password

human password
      |
      v
slow password hash
      |
      v
database
Enter fullscreen mode Exit fullscreen mode

The slow algorithm makes offline guessing expensive.

Refresh token

256-bit random token
      |
      v
SHA-256
      |
      v
database
Enter fullscreen mode Exit fullscreen mode

The token itself is already designed to make guessing infeasible.

SHA-256 does not need to make guesses expensive because there should be no realistic sequence of guesses to make.

This is an important security principle:

The correct hashing algorithm depends on the properties of the secret being protected.

Don't blindly apply password-storage rules to every secret.


7. SHA-256 vs BCrypt

The difference becomes clearer when we compare them directly.

Property Password Refresh Token
Usually created by Human Server
Predictability Potentially high Should be extremely low
Entropy Variable High
Brute-force resistance Needs slow hashing Primarily comes from randomness
Appropriate hashing strategy Argon2id / BCrypt / PBKDF2 Fast cryptographic hash such as SHA-256
Main goal Make guessing expensive Store a verifier without storing the secret

BCrypt is not "more secure" simply because it is slower.

It is optimized for a different problem.

Using BCrypt for a 256-bit random refresh token can add substantial computation without meaningfully improving the protection provided by the token's already-high entropy.


8. Hash the Token Before Storing It

The implementation can be very small.

A dedicated service keeps the hashing logic out of your controllers and authentication flow.

For example:

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;

public final class RefreshTokenHasher {

    private RefreshTokenHasher() {
    }

    public static String hash(String token) {
        try {
            MessageDigest digest = MessageDigest.getInstance("SHA-256");

            byte[] hash = digest.digest(
                    token.getBytes(StandardCharsets.UTF_8)
            );

            return HexFormat.of().formatHex(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(
                    "SHA-256 is not available",
                    e
            );
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

A SHA-256 digest contains 256 bits.

When represented as hexadecimal:

256 bits / 4 bits per hex character = 64 characters
Enter fullscreen mode Exit fullscreen mode

So a database column can look like:

@Column(nullable = false, unique = true, length = 64)
private String tokenHash;
Enter fullscreen mode Exit fullscreen mode

The actual refresh token should never be persisted.

Only:

tokenHash
Enter fullscreen mode Exit fullscreen mode

is stored.


9. The Complete Token Lifecycle

Let's put the pieces together.

Step 1 — Generate

At login:

SecureRandom
    |
    v
256-bit random refresh token
Enter fullscreen mode Exit fullscreen mode

For example:

oW8n...random...value
Enter fullscreen mode Exit fullscreen mode

Step 2 — Hash

The server calculates:

SHA-256(refreshToken)
Enter fullscreen mode Exit fullscreen mode

Result:

9f86d081884c7d659a2feaa0c55ad015...
Enter fullscreen mode Exit fullscreen mode

Step 3 — Store

The database receives:

token_hash
expires_at
revoked
user_id
Enter fullscreen mode Exit fullscreen mode

It does not receive the original token.

Step 4 — Return

The raw refresh token is returned to the client:

{
  "accessToken": "...",
  "refreshToken": "oW8n...random...value"
}
Enter fullscreen mode Exit fullscreen mode

The server no longer needs to persist that raw value.

Step 5 — Refresh

Later, the client sends:

{
  "refreshToken": "oW8n...random...value"
}
Enter fullscreen mode Exit fullscreen mode

The server calculates:

SHA-256(receivedToken)
Enter fullscreen mode Exit fullscreen mode

Then searches for that hash:

SELECT *
FROM refresh_tokens
WHERE token_hash = ?
Enter fullscreen mode Exit fullscreen mode

If the record exists and is valid, the refresh operation can continue.


10. Validation Is More Than Checking the Hash

A matching hash does not automatically mean that the refresh token should be accepted.

A production implementation should also validate the token's state.

For example:

String tokenHash = refreshTokenHasher.hash(rawToken);

RefreshToken refreshToken = repository
        .findByTokenHash(tokenHash)
        .orElseThrow(() -> new InvalidRefreshTokenException());

if (refreshToken.isRevoked()) {
    throw new InvalidRefreshTokenException();
}

if (refreshToken.getExpiresAt().isBefore(Instant.now())) {
    throw new InvalidRefreshTokenException();
}
Enter fullscreen mode Exit fullscreen mode

Conceptually:

                 refresh token
                       |
                       v
                  SHA-256
                       |
                       v
              find token record
                       |
          +------------+------------+
          |            |            |
       exists?       revoked?    expired?
          |            |            |
          v            v            v
         yes           no           no
                       |
                       v
                  accept token
Enter fullscreen mode Exit fullscreen mode

This is one reason refresh tokens are often stored server-side even when access tokens are JWTs.

The database record provides server-controlled state.


11. Hashing and Rotation Solve Different Problems

Hashing is only one part of a secure refresh-token design.

Consider this attack.

An attacker somehow steals a valid refresh token from a browser:

ATTACKER
   |
   | stolen refresh token
   v
POST /api/auth/refresh
Enter fullscreen mode Exit fullscreen mode

Hashing does not prevent that.

The attacker already has the original secret.

Hashing protects against a different threat:

ATTACKER
   |
   | database read access
   v
token_hash
Enter fullscreen mode Exit fullscreen mode

The attacker sees the verifier, not the original token.

Refresh-token rotation addresses another problem: reuse of a token after it has already been consumed.

A simplified rotation flow looks like this:

Old refresh token
       |
       v
validate
       |
       v
revoke old token
       |
       v
generate new refresh token
       |
       v
hash new token
       |
       v
store new hash
       |
       v
return new token pair
Enter fullscreen mode Exit fullscreen mode

The public demo for the starter follows this model: the raw refresh token is returned to the client, only its SHA-256 hash is stored, and refresh operations rotate the token.

OAuth 2.0 Security Best Current Practice also describes refresh-token rotation as a mechanism for detecting refresh-token replay: a previously invalidated token being presented again can indicate that the token was compromised.

So think of the protections as separate layers:

Random token generation
        +
SHA-256 storage
        +
Expiration
        +
Revocation
        +
Refresh-token rotation
Enter fullscreen mode Exit fullscreen mode

Each addresses a different part of the problem.


12. What Happens If the Database Is Leaked?

This is where the design becomes interesting.

Plaintext storage

Suppose the database contains:

refresh_tokens

id | user_id | token
----------------------------------------
1  | 42      | abc123...
Enter fullscreen mode Exit fullscreen mode

An attacker dumps the database.

They now have:

abc123...
Enter fullscreen mode Exit fullscreen mode

If the token is still valid, they may be able to present it directly to the refresh endpoint.

Hashed storage

Now suppose the database contains:

refresh_tokens

id | user_id | token_hash
----------------------------------------
1  | 42      | 9f86d081...
Enter fullscreen mode Exit fullscreen mode

The attacker cannot simply send:

9f86d081...
Enter fullscreen mode Exit fullscreen mode

as the refresh token.

That's only the SHA-256 representation.

They would need to find an input whose SHA-256 digest matches the stored value.

With a properly generated high-entropy refresh token, that is a fundamentally different problem from guessing a human password.

This is why token randomness and token hashing must be considered together.

Hashing a weak token does not magically make it strong.

For example:

"password123"
       |
       v
SHA-256
       |
       v
hash
Enter fullscreen mode Exit fullscreen mode

does not make the original secret cryptographically strong.

An attacker can simply hash common guesses offline.

The correct design is:

cryptographically random token
             +
        SHA-256 hash
Enter fullscreen mode Exit fullscreen mode

13. Why Not Encrypt the Refresh Token?

Encryption is another possible approach.

You could store:

encrypted(refreshToken)
Enter fullscreen mode Exit fullscreen mode

instead of:

SHA-256(refreshToken)
Enter fullscreen mode Exit fullscreen mode

But encryption gives you something you don't actually need: the ability to recover the original token.

For validation, the server only needs to answer:

Does this presented token match a valid stored token?
Enter fullscreen mode Exit fullscreen mode

A one-way verifier is sufficient.

Encryption also introduces another secret-management problem:

database
   +
encryption key
Enter fullscreen mode Exit fullscreen mode

If the application needs the encryption key to decrypt the stored tokens, compromise of both the database and the key may expose the original credentials.

With one-way hashing:

database
   |
   v
token hash
Enter fullscreen mode Exit fullscreen mode

there is no decryption operation.

This follows the principle of storing the minimum information necessary to perform the operation.


14. Don't Use the Same Storage Strategy for Passwords

One of the easiest mistakes is to create a generic:

HashingService
Enter fullscreen mode Exit fullscreen mode

and use SHA-256 for everything.

Don't.

Passwords and refresh tokens have different threat models.

For passwords:

Password
   |
   v
Argon2id / BCrypt / PBKDF2
   |
   v
Database
Enter fullscreen mode Exit fullscreen mode

For refresh tokens:

CSPRNG-generated token
   |
   v
SHA-256
   |
   v
Database
Enter fullscreen mode Exit fullscreen mode

The distinction matters because a password can be guessed from a relatively small search space.

A properly generated 256-bit random token has an enormously larger search space.

The hash algorithm is therefore only one part of the security equation.


15. A Small Spring Boot Service

A practical implementation can hide the details behind a service.

For example:

@Service
public class RefreshTokenService {

    private final RefreshTokenRepository repository;

    public RefreshTokenService(RefreshTokenRepository repository) {
        this.repository = repository;
    }

    public String createToken(User user) {
        String rawToken = generateToken();

        RefreshToken entity = new RefreshToken();
        entity.setUser(user);
        entity.setTokenHash(hash(rawToken));
        entity.setExpiresAt(Instant.now().plus(30, ChronoUnit.DAYS));
        entity.setRevoked(false);

        repository.save(entity);

        return rawToken;
    }

    private String generateToken() {
        byte[] bytes = new byte[32];
        new SecureRandom().nextBytes(bytes);

        return Base64.getUrlEncoder()
                .withoutPadding()
                .encodeToString(bytes);
    }

    private String hash(String token) {
        try {
            MessageDigest digest =
                    MessageDigest.getInstance("SHA-256");

            return HexFormat.of().formatHex(
                    digest.digest(
                            token.getBytes(StandardCharsets.UTF_8)
                    )
            );
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

In a real application, the SecureRandom instance should normally be reused rather than instantiated for every token generation, and the hashing logic is better extracted into its own component.

The important architecture is:

Controller
    |
    v
Authentication Service
    |
    v
Refresh Token Service
    |
    +---- generate raw token
    |
    +---- hash token
    |
    +---- persist hash
    |
    +---- return raw token
Enter fullscreen mode Exit fullscreen mode

The controller never needs to know how the token is generated or stored.


16. The Database Should Store State, Not Secrets

A refresh-token table might contain:

refresh_tokens

id
user_id
token_hash
expires_at
revoked
created_at
Enter fullscreen mode Exit fullscreen mode

Notice what is missing:

token
Enter fullscreen mode Exit fullscreen mode

That's intentional.

The database needs to know:

  • which user owns the token
  • whether it has expired
  • whether it has been revoked
  • when it was created
  • which hash corresponds to the token

It does not need the original bearer credential.

This is a useful general security principle:

If your database only needs to verify a secret, don't automatically store the secret itself.


17. One Important Caveat: Rotation Needs Concurrency Protection

There is one subtle problem worth calling out.

Imagine two requests arrive at almost exactly the same time with the same refresh token:

Request A ────────> refresh endpoint
Request B ────────> refresh endpoint
Enter fullscreen mode Exit fullscreen mode

If both requests validate the old token before either transaction revokes it, both may succeed.

Hashing does not solve this.

Rotation alone does not necessarily solve it either.

A production-grade implementation may need stronger concurrency control, depending on the application's requirements:

  • transactional boundaries
  • row-level locking
  • optimistic locking
  • atomic state transitions
  • refresh-token families
  • replay detection

This is an important distinction because "hashed refresh tokens" does not mean "complete refresh-token security."

It solves one specific problem extremely well:

protecting the stored representation of the token if the database is exposed.


18. The Security Model

The final design can be summarized like this:

                  LOGIN
                    |
                    v
          Generate random token
                    |
                    v
             Raw refresh token
                /         \
               /           \
              v             v
         Client          SHA-256
                           |
                           v
                       Database
                       token_hash


                 REFRESH
                    |
                    v
             Client sends raw token
                    |
                    v
                 SHA-256
                    |
                    v
             Database lookup
                    |
          +---------+---------+
          |                   |
       invalid              valid
          |                   |
          v                   v
        reject          revoke old token
                              |
                              v
                       generate new token
                              |
                              v
                         store hash
                              |
                              v
                      return new pair
Enter fullscreen mode Exit fullscreen mode

Each layer has a specific responsibility.

Cryptographic randomness

Prevents attackers from predicting tokens.

SHA-256

Prevents the database from containing reusable plaintext refresh credentials.

Expiration

Limits the lifetime of a token.

Revocation

Allows the server to invalidate a token before its natural expiration.

Rotation

Limits token reuse and provides a mechanism for detecting replay.

None of these replaces the others.


19. The Practical Rule

If you remember only one thing from this article, make it this:

Don't choose a hashing algorithm based on the fact that the value is called a "secret." Choose it based on the properties of the secret.

For passwords:

human-generated
potentially guessable
        ↓
slow password hashing
Enter fullscreen mode Exit fullscreen mode

For refresh tokens:

server-generated
cryptographically random
high entropy
        ↓
SHA-256 verifier
Enter fullscreen mode Exit fullscreen mode

And most importantly:

Random token
      +
hashed database representation
      +
expiration
      +
revocation
      +
rotation
Enter fullscreen mode Exit fullscreen mode

is much stronger than simply generating a random token and putting the raw value into PostgreSQL.


Conclusion

JWT authentication is often presented as a token-generation problem.

It isn't.

The difficult part is designing what happens around the token.

Refresh tokens are a good example.

They are long-lived bearer credentials, so storing their plaintext values in a database creates an unnecessary risk. The server doesn't need to recover the original token. It only needs to verify that the presented token corresponds to a valid server-side record.

That makes a one-way hash a natural fit.

The important distinction is that refresh tokens should be random enough that guessing is already impractical. SHA-256 then gives the database a verifier without requiring the database to store the original bearer secret.

And that is why the following design makes sense:

32-byte cryptographically random token
                    ↓
                 SHA-256
                    ↓
             PostgreSQL
Enter fullscreen mode Exit fullscreen mode

While passwords should follow a different path:

human password
       ↓
Argon2id / BCrypt / PBKDF2
       ↓
   PostgreSQL
Enter fullscreen mode Exit fullscreen mode

Security isn't about using the strongest-looking algorithm everywhere.

It's about matching the mechanism to the threat model.


AI disclosure

This article was created with the assistance of AI and reviewed for technical accuracy by the author. The implementation details and security decisions should be verified against the requirements of the application before production use.

ABotWroteThis

Top comments (0)