Refresh Token
2026-21-02
today i finally implemented refresh tokens properly.
i had known the concept for a while — access token expires fast, refresh token lives longer, rotate when needed.
but knowing something conceptually and actually wiring it into a real project are two different things.
i needed this for a project i’m currently building. login was working. jwt was working. protected routes were working.
but everything broke the moment the access token expired.
that’s when things got interesting.
the problem with “just jwt”
initially, my auth flow looked simple:
- - user logs in
- - server issues jwt access token
- - client stores it
- - protected routes verify it
works great — until it expires.
when the token expired:
- - api calls started failing
- - user got logged out abruptly
- - bad experience
i didn’t want users to re-login every 15 minutes.
that’s where refresh tokens come in.
the idea that finally clicked
the clean mental model is this:
access token
- - short lived (e.g. 15 min)
- - used for every request
- - stored in memory (or http-only cookie)
refresh token
- - long lived (e.g. 7 days)
- - used only to get a new access token
- - stored securely (http-only cookie ideally)
- - stored in database
access tokens prove identity.
refresh tokens renew identity.
once that separation made sense, implementation became mechanical.
1. login route
on successful login:
- generate access token
- generate refresh token
- store refresh token in database
- send access token in response
- send refresh token in http-only cookie
const accessToken = jwt.sign(
{ userId: user._id },
process.env.ACCESS_SECRET,
{ expiresIn: "15m" }
);
const refreshToken = jwt.sign(
{ userId: user._id },
process.env.REFRESH_SECRET,
{ expiresIn: "7d" }
);
user.refreshToken = refreshToken;
await user.save();
res
.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
})
.json({ accessToken });the access token is short. the refresh token stays in the cookie.
2. middleware for protected routes
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "unauthorized" });
jwt.verify(token, process.env.ACCESS_SECRET, (err, decoded) => {
if (err) return res.status(403).json({ message: "invalid token" });
req.user = decoded;
next();
});nothing fancy here.
3. refresh route (the important part)
when access token expires, frontend calls:
POST /api/auth/refresh
server logic:
- verify refresh token
- check it against database
- generate new access token
- generate new refresh token
- replace old refresh token in database
- overwrite cookie
const token = req.cookies.refreshToken;
if (!token) return res.status(401).json({ message: "no token" });
const decoded = jwt.verify(token, process.env.REFRESH_SECRET);
const user = await User.findById(decoded.userId);
if (!user || user.refreshToken !== token) {
return res.status(403).json({ message: "invalid refresh token" });
}
// rotate tokens
const newAccessToken = jwt.sign(
{ userId: user._id },
process.env.ACCESS_SECRET,
{ expiresIn: "15m" }
);
const newRefreshToken = jwt.sign(
{ userId: user._id },
process.env.REFRESH_SECRET,
{ expiresIn: "7d" }
);
user.refreshToken = newRefreshToken;
await user.save();
res
.cookie("refreshToken", newRefreshToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
})
.json({ accessToken: newAccessToken });why rotate? because if a refresh token gets stolen, it becomes useless after one use.security is about reducing the blast radius.
4. logout route (invalidate old refresh token)
logout should not just “delete token on frontend”.
it should:
- clear cookie
- remove refresh token from database
const token = req.cookies.refreshToken;
if (token) {
const decoded = jwt.verify(token, process.env.REFRESH_SECRET);
const user = await User.findById(decoded.userId);
if (user) {
user.refreshToken = null;
await user.save();
}
}
res.clearCookie("refreshToken").json({ message: "logged out" });now that session is truly dead.
Frontend
axios interceptor if any request returns 401:
- call refresh endpoint
- update access token
- retry original request
axios.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const res = await axios.post("/api/auth/refresh", {}, {
withCredentials: true
});
const newAccessToken = res.data.accessToken;
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return axios(originalRequest);
}
return Promise.reject(error);
}
);
what changed after implementing rotation
before:
- sessions expired awkwardly
- security was basic
after:
- expired access tokens renew silently
- refresh tokens rotate logout
- truly invalidates session
it feels more like a production system now.