DEV Community

Pratik Kumar Ghosh
Pratik Kumar Ghosh

Posted on

Why Hashing Refresh Tokens Broke My Auth Flow — and How I Fixed It

When I first decided to hash refresh tokens before storing them in the database, it felt like a straightforward security improvement.

But the moment I did that, my refresh-token flow broke.

Not because hashing itself was wrong. The real issue was that hashing changed how I had to design the lookup and verification flow.

This post is about that problem, the first working fix I used, and the more optimal version I reached after understanding the flow properly.


What I was trying to do

My auth setup was simple in principle:

  • access tokens for short-lived authentication
  • refresh tokens for issuing new access tokens
  • sessions stored in the database
  • refresh token rotation for better security

The part I wanted to improve was storage.

Instead of saving the refresh token directly in the session collection, I wanted to hash it with Argon2 before saving it, just like we hash passwords.

That part was easy enough.

A simplified schema method looked like this:

sessionSchema.methods.verifyRefreshToken = async function (token) {
  if (!this.refreshToken) return false;
  return argon2.verify(this.refreshToken, token);
};
Enter fullscreen mode Exit fullscreen mode

And the pre-save hashing idea was also straightforward:

sessionSchema.pre("save", async function () {
  if (!this.isModified("refreshToken")) return;
  if (!this.refreshToken) return;

  this.refreshToken = await argon2.hash(this.refreshToken);
});
Enter fullscreen mode Exit fullscreen mode

So far, so good.

Then the refresh flow broke.


Why hashing refresh tokens broke the flow

Before hashing, finding the correct session was easy.

I could do something like this:

const existingSession = await session.findOne({
  refreshToken,
  valid: true,
});
Enter fullscreen mode Exit fullscreen mode

That works only when the database stores the raw refresh token.

Once I started hashing the refresh token before saving it, this approach stopped working.

Why?

Because a hashed value is not something I can directly query with the raw token anymore.

The important lesson here is this:

hash verification and database lookup are two different problems.

Hashing solved storage security, but it removed my ability to locate the session by the raw token itself.


Why verification alone was not enough

At first, this felt confusing because argon2.verify() still exists.

So I thought:

No problem. I will just verify the token against the hash.

But that was only half the story.

argon2.verify() needs two things:

  1. the raw token coming from the cookie
  2. the stored hash from the correct session document

And that second part is where the real design issue appeared.

I could verify the hash only after I had already found the right session document.

But if I could no longer find the session by refresh token, then what exactly was I verifying against?

That was the missing piece.


My first working fix

My first working solution was simple:

Create the session first, then generate the refresh token

The flow looked like this:

  1. Create a session document first
  2. Let MongoDB generate the session _id
  3. Use userId and sessionId inside the refresh token payload
  4. Save the real refresh token back into that session
  5. Later, during refresh, verify the JWT, extract sessionId, find the session by that id, and only then verify the hashed token

Login flow

This was the idea in simplified form:

const sessionDoc = await session.create({
  userId: user._id,
  refreshToken: "demo",
  valid: true,
  ip: req.ip,
  userAgent: req.get("User-Agent"),
});

const refreshToken = jwt.sign(
  {
    userId: user._id,
    sessionId: sessionDoc._id.toString(),
  },
  process.env.JWT_REFRESH_SECRET,
  { expiresIn: "15d" }
);

sessionDoc.refreshToken = refreshToken;
await sessionDoc.save();
Enter fullscreen mode Exit fullscreen mode

The reason this worked was simple:

I stopped trying to locate the session by raw refresh token.

Instead, I used a stable identifier: sessionId.

Later, in the refresh flow, I could do this:

const decoded = jwt.verify(refreshTokenCookie, process.env.JWT_REFRESH_SECRET);

const existingSession = await session
  .findOne({
    _id: decoded.sessionId,
    userId: decoded.userId,
    valid: true,
  })
  .select("+refreshToken");

const isValid = await existingSession.verifyRefreshToken(refreshTokenCookie);

if (!isValid) {
  return res.status(401).json({ error: "Invalid refresh token" });
}
Enter fullscreen mode Exit fullscreen mode

This was the moment the whole design started making sense.


Why that first fix was not optimal

It worked, but it had two problems.

1. It needed two database writes

First I created the session. Then I updated it with the real refresh token.

That is acceptable, but not ideal.

2. I used a placeholder value first

Because refreshToken was required in the model, I temporarily stored an Empty String "" and then replaced it with the real token.

That was not elegant.

It worked as a practical fix, but it clearly felt like an implementation workaround rather than a clean design.


The better fix

After that, I moved to a cleaner approach.

Generate the Mongo ObjectId first, then create everything in one go

Instead of letting Mongo create the session id during insert, I generated the ObjectId beforehand.

That allowed me to:

  1. generate the session id first
  2. sign the refresh token using userId and sessionId
  3. create the session once with the real refresh token
  4. let the schema middleware hash it before saving

Here is the cleaner version:

const sessionId = new mongoose.Types.ObjectId();

const refreshToken = jwt.sign(
  {
    userId: user._id,
    sessionId: sessionId.toString(),
  },
  process.env.JWT_REFRESH_SECRET,
  { expiresIn: "15d" }
);

await session.create({
  _id: sessionId,
  userId: user._id,
  refreshToken,
  valid: true,
  ip: req.ip,
  userAgent: req.get("User-Agent"),
});
Enter fullscreen mode Exit fullscreen mode

This solved both earlier drawbacks:

  • no placeholder token
  • only one database write

It also kept the refresh flow clean, because the token still carried the sessionId.


Comparing both approaches

Approach 1: create session first, then update

Pros

  • easy to understand
  • easy to implement
  • works reliably

Cons

  • needs two DB operations
  • temporary placeholder value feels awkward

Approach 2: generate ObjectId first, then create once

Pros

  • cleaner design
  • one DB write
  • no placeholder token
  • easier to reason about long-term

Cons

  • slightly more thought required upfront

For me, the second version was the better engineering decision.


The final refresh flow

Once I moved to the sessionId-based design, the full flow became much clearer.

During login

  • create an access token
  • generate a session id
  • create a refresh token containing userId and sessionId
  • save the session with the refresh token
  • let Mongoose hash the token before saving
  • send both tokens in cookies

A simplified login controller looked like this:

export const login = async (req, res) => {
  try {
    const { identifier, password } = req.body || {};

    if (!identifier || !password) {
      return res.status(400).json({ error: "All fields are required" });
    }

    const existingUser = await user.findOne({
      $or: [{ email: identifier }, { userName: identifier }],
    }).select("+password");

    if (!existingUser) {
      return res.status(401).json({ error: "Invalid credentials" });
    }

    const isPasswordValid = await existingUser.verifyPassword(password);

    if (!isPasswordValid) {
      return res.status(401).json({ error: "Invalid credentials" });
    }

    const accessToken = jwt.sign(
      { userId: existingUser._id },
      process.env.JWT_ACCESS_SECRET,
      { expiresIn: "15m" }
    );

    const sessionId = new mongoose.Types.ObjectId();

    const refreshToken = jwt.sign(
      {
        userId: existingUser._id,
        sessionId: sessionId.toString(),
      },
      process.env.JWT_REFRESH_SECRET,
      { expiresIn: "15d" }
    );

    await session.create({
      _id: sessionId,
      userId: existingUser._id,
      refreshToken,
      valid: true,
      ip: req.ip,
      userAgent: req.get("User-Agent"),
    });

    res.cookie("accessToken", accessToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      maxAge: 15 * 60 * 1000,
    });

    res.cookie("refreshToken", refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production",
      sameSite: "strict",
      maxAge: 15 * 24 * 60 * 60 * 1000,
    });

    return res.status(200).json({
      message: "Login successful",
      user: existingUser,
    });
  } catch (error) {
    return res.status(500).json({ error: "Internal server error" });
  }
};
Enter fullscreen mode Exit fullscreen mode

During refresh

  • read refresh token from cookie
  • verify JWT
  • extract sessionId
  • find the session by _id
  • verify raw token against stored hash
  • rotate tokens if valid

A simplified refresh flow looked like this:

export const refreshToken = async (req, res) => {
  try {
    const refreshTokenCookie = req.cookies?.refreshToken;

    if (!refreshTokenCookie) {
      return res.status(401).json({ error: "Missing refresh token" });
    }

    const decoded = jwt.verify(
      refreshTokenCookie,
      process.env.JWT_REFRESH_SECRET
    );

    const existingSession = await session
      .findOne({
        _id: decoded.sessionId,
        userId: decoded.userId,
        valid: true,
      })
      .select("+refreshToken");

    if (!existingSession) {
      return res.status(401).json({ error: "Invalid refresh token" });
    }

    const isValid = await existingSession.verifyRefreshToken(
      refreshTokenCookie
    );

    if (!isValid) {
      return res.status(401).json({ error: "Invalid refresh token" });
    }

    return res.status(200).json({ message: "Token verified successfully" });
  } catch (error) {
    return res.status(500).json({ error: "Internal server error" });
  }
};
Enter fullscreen mode Exit fullscreen mode

What this taught me

This problem taught me something important:

security improvements often change architecture, not just storage.

At first, I treated hashed refresh token storage as a simple replacement:

  • store token
  • hash token
  • done

But hashing a refresh token changes the lookup strategy completely.

Once the token is hashed, it should no longer be treated as the thing used to find the session document.

Instead:

  • use a stable identifier like sessionId to find the session
  • use hash verification only to validate the token

That separation made the whole flow much cleaner.


Key takeaways

  • Hashing refresh tokens is safer than storing them in plain text
  • But once you hash them, direct database lookup by raw token no longer works
  • argon2.verify() helps with validation, not identification
  • You still need a stable way to find the correct session first
  • Including sessionId inside the refresh token is a clean solution
  • Generating the Mongo ObjectId beforehand is a better version because it removes the extra DB update

Closing thought

This was a small bug, but it changed the way I think about authentication design.

I started with a storage problem and ended up learning a flow-design problem.

And that was the real lesson.

Sometimes the hard part in auth is not generating or verifying the token itself.

It is designing the path that lets you verify the right thing, in the right place, for the right reason.

Top comments (0)