Why Two Tokens?
The two-token system balances security and usability:
- A single long-lived token would be convenient, but if it is stolen, the attacker has access for a long time
- A single short-lived token is secure, but the user would be logged out every 15 minutes and have to re-enter their password
Solution: Use a short-lived access token (15 min – 1 hour) for API requests, and a long-lived refresh token (7 – 30 days) stored securely for generating new access tokens silently.
---
Token Lifecycle
Login → Access Token (15m) + Refresh Token (7d) issued
↓
API requests with Access Token (until it expires)
↓
Access Token expires → Send Refresh Token → Get new Access Token
↓
Refresh Token expires → User must log in again
---
Login Controller — Step by Step
- Find user by email OR username in database
- Check if password is correct (
isPasswordCorrectinstance method) - Generate new access token and refresh token
- Save the refresh token to the user document in MongoDB
- Set both tokens as httpOnly cookies
- Return sanitised user data (no password, no refreshToken)
const loginUser = asyncHandler(async (req, res) => {
const { email, username, password } = req.body;
if (!email && !username) throw new ApiError(400, 'Email or username is required');
if (!password) throw new ApiError(400, 'Password is required');
// Find user — select password field explicitly (it has select: false)
const user = await User.findOne({
$or: [{ email: email?.toLowerCase() }, { username: username?.toLowerCase() }],
}).select('+password');
if (!user) throw new ApiError(404, 'User not found');
const isPasswordValid = await user.isPasswordCorrect(password);
if (!isPasswordValid) throw new ApiError(401, 'Invalid credentials');
// Generate tokens
const accessToken = user.generateAccessToken();
const refreshToken = user.generateRefreshToken();
// Save refreshToken to DB for revocation capability
user.refreshToken = refreshToken;
await user.save({ validateBeforeSave: false });
// Get clean user without sensitive fields
const loggedInUser = await User.findById(user._id).select('-password -refreshToken');
// Set cookies
res
.status(200)
.cookie('accessToken', accessToken, { ...cookieOptions, maxAge: 15 * 60 * 1000 })
.cookie('refreshToken', refreshToken, { ...cookieOptions, maxAge: 7 * 24 * 60 * 60 * 1000 })
.json(new ApiResponse(200, { user: loggedInUser, accessToken }, 'Login successful'));
});
---
Refresh Access Token Controller
Called by the frontend when the access token expires:
- Read refresh token from cookie (or request body as fallback)
- Verify the refresh token with
JWT_REFRESH_SECRET - Find the user by the decoded
_id(select refreshToken field) - Compare the incoming refresh token with the one stored in DB (prevents reuse of revoked tokens)
- Generate new access token (and optionally a new refresh token — token rotation)
- Return new tokens
const refreshAccessToken = asyncHandler(async (req, res) => {
const incomingRefreshToken = req.cookies?.refreshToken || req.body.refreshToken;
if (!incomingRefreshToken) throw new ApiError(401, 'Refresh token not found');
const decoded = jwt.verify(incomingRefreshToken, process.env.JWT_REFRESH_SECRET);
const user = await User.findById(decoded._id).select('+refreshToken');
if (!user) throw new ApiError(401, 'Invalid refresh token');
// Security check: token must match what is stored in DB
if (incomingRefreshToken !== user.refreshToken) {
throw new ApiError(401, 'Refresh token is expired or has been used');
}
const accessToken = user.generateAccessToken();
const newRefreshToken = user.generateRefreshToken();
// Token rotation: update stored refresh token
user.refreshToken = newRefreshToken;
await user.save({ validateBeforeSave: false });
res
.status(200)
.cookie('accessToken', accessToken, { ...cookieOptions, maxAge: 15 * 60 * 1000 })
.cookie('refreshToken', newRefreshToken, { ...cookieOptions, maxAge: 7 * 24 * 60 * 60 * 1000 })
.json(new ApiResponse(200, { accessToken }, 'Access token refreshed'));
});
---
Logout Controller
Clears cookies and removes refresh token from database:
const logoutUser = asyncHandler(async (req, res) => {
// Remove refresh token from DB (revoke the token)
await User.findByIdAndUpdate(
req.user._id,
{ $unset: { refreshToken: 1 } }, // $unset removes the field
{ new: true }
);
// Clear both cookies
res
.status(200)
.clearCookie('accessToken', cookieOptions)
.clearCookie('refreshToken', cookieOptions)
.json(new ApiResponse(200, {}, 'Logged out successfully'));
});
---
Token Storage Security
| Storage Location | XSS Attack | CSRF Attack | Recommended |
|---|---|---|---|
| httpOnly Cookie | ✅ Safe | ❌ Vulnerable | ✅ With SameSite=strict |
| localStorage | ❌ Vulnerable | ✅ Safe | ❌ Never for auth tokens |
| sessionStorage | ❌ Vulnerable | ✅ Safe | ❌ Never for auth tokens |
| In-memory (JS variable) | ✅ Safe | ✅ Safe | ✅ For access token only |
Best practice: Store access token in memory (JavaScript variable, Redux state) and refresh token in an httpOnly cookie.
---
validateBeforeSave: false
When updating only the refreshToken field on a user document, pass { validateBeforeSave: false } to user.save() to skip full validation (which would require all required fields to be present again). This is safe when you are only updating one field and not changing the user's core data.