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
The access token might be a JWT:
eyJhbGciOiJIUzI1NiJ9...
The refresh token is usually just an opaque random value:
V4Z8v2mQ4Qm7Qe7z...
The client later sends the refresh token to an endpoint such as:
POST /api/auth/refresh
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
And that leads directly to the database question.
If the database contains:
V4Z8v2mQ4Qm7Qe7z...
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
The client receives:
RANDOM_SECRET
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
When the client later sends the refresh token:
RANDOM_SECRET
the server performs the same transformation:
RANDOM_SECRET
|
v
SHA-256
|
v
HASHED_VALUE
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...
If an attacker reads the database, they have the token.
Hashed storage
Database:
token_hash
----------------------------------------------------------------
9f86d081884c7d659a2feaa0c55ad015...
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
An attacker can make educated guesses.
They can try:
password
password1
password123
qwerty
letmein
summer2026
...
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
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
or:
user-42-refresh-token
or:
a8f3c91
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);
Here we generate:
32 random bytes
That's:
256 bits
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
The slow algorithm makes offline guessing expensive.
Refresh token
256-bit random token
|
v
SHA-256
|
v
database
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
);
}
}
}
A SHA-256 digest contains 256 bits.
When represented as hexadecimal:
256 bits / 4 bits per hex character = 64 characters
So a database column can look like:
@Column(nullable = false, unique = true, length = 64)
private String tokenHash;
The actual refresh token should never be persisted.
Only:
tokenHash
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
For example:
oW8n...random...value
Step 2 — Hash
The server calculates:
SHA-256(refreshToken)
Result:
9f86d081884c7d659a2feaa0c55ad015...
Step 3 — Store
The database receives:
token_hash
expires_at
revoked
user_id
It does not receive the original token.
Step 4 — Return
The raw refresh token is returned to the client:
{
"accessToken": "...",
"refreshToken": "oW8n...random...value"
}
The server no longer needs to persist that raw value.
Step 5 — Refresh
Later, the client sends:
{
"refreshToken": "oW8n...random...value"
}
The server calculates:
SHA-256(receivedToken)
Then searches for that hash:
SELECT *
FROM refresh_tokens
WHERE token_hash = ?
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();
}
Conceptually:
refresh token
|
v
SHA-256
|
v
find token record
|
+------------+------------+
| | |
exists? revoked? expired?
| | |
v v v
yes no no
|
v
accept token
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
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
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
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
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...
An attacker dumps the database.
They now have:
abc123...
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...
The attacker cannot simply send:
9f86d081...
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
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
13. Why Not Encrypt the Refresh Token?
Encryption is another possible approach.
You could store:
encrypted(refreshToken)
instead of:
SHA-256(refreshToken)
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?
A one-way verifier is sufficient.
Encryption also introduces another secret-management problem:
database
+
encryption key
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
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
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
For refresh tokens:
CSPRNG-generated token
|
v
SHA-256
|
v
Database
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);
}
}
}
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
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
Notice what is missing:
token
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
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
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
For refresh tokens:
server-generated
cryptographically random
high entropy
↓
SHA-256 verifier
And most importantly:
Random token
+
hashed database representation
+
expiration
+
revocation
+
rotation
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
While passwords should follow a different path:
human password
↓
Argon2id / BCrypt / PBKDF2
↓
PostgreSQL
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.
Top comments (0)