Chapters of From Frontend to Full-Stack Backend

Week 1 - API FoundationChapter 6 of 283 min read

Auth part 2 - Refresh token rotation, httpOnly cookies and role-based access

The access + refresh token pair, what rotation means, why cookies are safer, admin vs user roles

In the last chapter we kept the access token's lifetime short for security. But then the user would have to log in every 15 minutes - that cannot work. The solution is a pair of two tokens: a short-lived access token for everyday work, and a long-lived refresh token used only to get a new access token.

How refresh token rotation works

The flow goes like this: when the access token expires, the frontend asks for a new access token with the refresh token. The server does not just give a new access token, it gives a new refresh token too - and cancels the old refresh token. That is rotation. The benefit is that even if a refresh token is stolen, the theft can be detected as soon as the thief uses it once - because when the real user later arrives with the old token, the server understands something is wrong, and then all tokens can be cancelled. A hash of the refresh token is kept in the database, so cancelling it is possible.

// Endpoint for giving a new access token
app.post("/auth/refresh", async (req, res) => {
  const token = req.cookies.refreshToken;
  const saved = await db.refreshToken.findFirst({ where: { tokenHash: hash(token) } });
  if (!saved) return res.status(401).json({ success: false, message: "Session expired" });

  await db.refreshToken.delete({ where: { id: saved.id } }); // cancel the old one
  // Here, build and send a new access + new refresh token
});

Why httpOnly cookies

The refresh token is kept in an httpOnly cookie. HttpOnly means JavaScript cannot read this cookie - so even if the site has an XSS bug, an attacker cannot steal the refresh token. Compared to the access token, the refresh token is far more powerful (it can fetch new access tokens again and again), so its protection is stricter too. Also use the secure and sameSite flags.

Role-based access - not everyone can do everything

Having a login does not mean permission to do everything. A normal user cannot enter the admin panel's routes. That also happens through middleware - one more step after the last chapter's requireAuth:

function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ success: false, message: "Forbidden" });
    }
    next();
  };
}

router.delete("/users/:id", requireAuth, requireRole("admin"), deleteUser);

Note the difference between 401 and 403 - 401 means 'I do not know who you are' (no login), 403 means 'I know who you are, but you do not have permission for this'. At logout, delete the refresh token from the database and clear the cookie - only then does the session truly end.

The mistake everyone makes

Two mistakes show up a lot: keeping the refresh token in localStorage (it gets stolen the moment XSS happens), and doing role checks only on the frontend (hiding a button is not security - sending a request directly to the API still gets the job done). The real permission check is always on the server.

Today's practice

Add a refresh token to your auth - send the refresh token in an httpOnly cookie at login, build the /auth/refresh endpoint with rotation, and cancel the token at logout. Then make an admin-only route and try entering it as a normal user to check that a 403 comes back.

How to say it in an interview

I use a short-lived access token and a refresh token with rotation. The refresh token stays in an httpOnly cookie so it cannot be stolen through XSS, and I keep its hash in the database so it can be cancelled on logout or suspicious use. I enforce role-based access with middleware - I do not rely on the frontend.

All chapters of From Frontend to Full-Stack Backend