Refresh tokens in NestJS are your defense against stolen access tokens—they let you issue short-lived credentials while keeping sessions alive without forcing users to log in every few minutes. In this guide, we'll build a token rotation system that detects when a token has been compromised and terminates the entire session family. By the end, you'll have a production-ready refresh flow that balances security, user experience, and backend simplicity.
The refresh flow
Let's walk through what happens in a real-world scenario. A user logs in at 9:00 AM. They get an access token (valid for 15 minutes) and a refresh token (valid for 7 days, stored in a secure httpOnly cookie). At 9:14 AM, they make a request—still fine. At 9:16 AM, the access token expires.
Their client receives a 401 response. Instead of bouncing them to login, the browser automatically calls POST /auth/refresh with the refresh token cookie attached. The server validates it, issues a new access token, and optionally rotates the refresh token. The client retries the original request with the fresh access token. The user never notices a thing.
Here's the basic flow in pseudocode:
// 1. User logs in
POST /auth/login
→ Issue accessToken (15m), refreshToken (7d)
// 2. Access token expires, client gets 401
GET /protected-route
→ 401 Unauthorized
// 3. Client intercepts, calls refresh
POST /auth/refresh (with refreshToken cookie)
→ Validate refreshToken
→ Rotate: Issue new refreshToken, invalidate old one
→ Return new accessToken
// 4. Client retries original request
GET /protected-route (with new accessToken)
→ 200 OK
The key insight: short-lived access tokens mean even if one is stolen, the damage window is narrow. Refresh tokens live longer but stay in secure cookies, making them harder to exfiltrate via XSS.
Store hashed
Never store the raw refresh token in your database. If an attacker breaches your DB, they gain instant access to every user's session. Instead, hash the token the same way you'd hash a password.
Here's why this matters in practice: imagine your database gets leaked. An attacker sees thousands of refresh tokens sitting there. If you stored them raw, they can impersonate any user immediately. If you stored hashes, they can't reverse-engineer the original tokens—they're just random strings.
Set up a hashing utility:
import * as crypto from 'crypto';
export class HashUtil {
static hashToken(token: string): string {
return crypto
.createHash('sha256')
.update(token)
.digest('hex');
}
static verifyToken(token: string, hash: string): boolean {
return this.hashToken(token) === hash;
}
}
Now, when issuing a refresh token, you generate a random string and save its hash:
import { randomBytes } from 'crypto';
async issueRefreshToken(userId: string): Promise<string> {
const refreshToken = randomBytes(32).toString('hex');
const hashedToken = HashUtil.hashToken(refreshToken);
await this.tokenRepository.save({
userId,
hashedToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
isRevoked: false,
});
return refreshToken; // Return raw token to client only
}
When the client sends the refresh token back, you hash it again and compare:
async validateRefreshToken(
token: string,
userId: string,
): Promise<boolean> {
const hashedToken = HashUtil.hashToken(token);
const record = await this.tokenRepository.findOne({
where: {
userId,
hashedToken,
isRevoked: false,
expiresAt: MoreThan(new Date()),
},
});
return !!record;
}
This way, even if your database is compromised, attackers only see hashes, not usable tokens.
Rotate every time
Token rotation means: every time a client exchanges a refresh token for a new access token, you also issue a new refresh token and invalidate the old one. This is your main line of defense against token theft.
Here's the scenario: an attacker steals a refresh token at 9:00 AM. The legitimate user is still using the app. At 9:05 AM, the legitimate user's access token expires and they call /auth/refresh. You issue them a new refresh token (and invalidate the old one). At 9:10 AM, the attacker tries to use their stolen token—but it's already been revoked. You catch them.
In your refresh endpoint:
@Post('refresh')
@UseGuards(JwtRefreshGuard)
async refresh(@Req() req, @Res() res) {
const userId = req.user.sub;
const oldRefreshToken = req.cookies['refreshToken'];
// Step 1: Validate the old token
const isValid = await this.authService.validateRefreshToken(
oldRefreshToken,
userId,
);
if (!isValid) {
throw new UnauthorizedException('Refresh token invalid or expired');
}
// Step 2: Revoke the old token
await this.authService.revokeRefreshToken(userId, oldRefreshToken);
// Step 3: Issue new tokens
const newAccessToken = this.authService.issueAccessToken(userId);
const newRefreshToken = await this.authService.issueRefreshToken(userId);
// Step 4: Set the new refresh token in a secure cookie
res.cookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return res.json({
accessToken: newAccessToken,
});
}
The critical part: you revoke the old token before returning the new one. If the same old token shows up again later, you know it's either a replay attack or a stolen token being used by an attacker.
Detect reuse
If you see the same refresh token used twice, that's a red flag. It means either:
- The legitimate user lost network connectivity mid-request and retried (rare).
- An attacker is using a stolen token (likely).
The safest approach: revoke the entire session family. A "family" is all refresh tokens ever issued to that user in the current login session. If reuse is detected, burn them all and force a re-login.
Track the family by adding a tokenFamily field to your token records:
// In your token entity
@Entity()
export class RefreshToken {
@PrimaryGeneratedColumn()
id: string;
@Column()
userId: string;
@Column()
hashedToken: string;
@Column()
tokenFamily: string; // UUID linking related tokens
@Column()
expiresAt: Date;
@Column({ default: false })
isRevoked: boolean;
}
When issuing the initial refresh token (at login), create a new family:
async login(email: string, password: string) {
const user = await this.validateUser(email, password);
const tokenFamily = randomUUID(); // New family for this session
const accessToken = this.issueAccessToken(user.id);
const refreshToken = await this.issueRefreshToken(user.id, tokenFamily);
return { accessToken, refreshToken };
}
When rotating, keep the same family:
async refresh(oldRefreshToken: string, userId: string) {
// Fetch the old token record to get its family
const oldRecord = await this.tokenRepository.findOne({
where: {
hashedToken: HashUtil.hashToken(oldRefreshToken),
userId,
isRevoked: false,
},
});
if (!oldRecord) {
// Token not found or already revoked—potential reuse
await this.detectReuse(userId);
throw new UnauthorizedException('Token reuse detected');
}
const tokenFamily = oldRecord.tokenFamily;
// Revoke the old token
oldRecord.isRevoked = true;
await this.tokenRepository.save(oldRecord);
// Issue new token in the same family
const accessToken = this.issueAccessToken(userId);
const newRefreshToken = await this.issueRefreshToken(
userId,
tokenFamily,
);
return { accessToken, newRefreshToken };
}
private async detectReuse(userId: string) {
// Revoke all tokens for this user
await this.tokenRepository.update(
{ userId },
{ isRevoked: true },
);
// Optional: log security event, notify user
console.warn(`Token reuse detected for user ${userId}`);
}
Now, if a stolen token is reused, you catch it immediately and lock down the account.
Common mistakes
- Storing refresh tokens in localStorage: This opens you to XSS attacks. Use httpOnly cookies instead. If your SPA is on the same origin as your API, cookies work perfectly.
- Long-lived access tokens: If your access token lives for hours, a stolen token is dangerous for that entire duration. Keep access tokens short (15–30 minutes). That's what refresh tokens are for.
- Forgetting CSRF protection: Refresh endpoints should be protected with CSRF tokens if cookies are involved. Use libraries like `@nestjs/csrf` or let your frontend include a token in the body.
- Not handling clock skew: Servers' clocks drift. When validating expiry, add a small buffer (like 30 seconds) to avoid false rejections.
- Rotating too aggressively: Every single request causing a rotation creates database churn. Refresh tokens on-demand only. Alternatively, rotate on a sliding window (e.g., if less than 1 day remains, rotate).
- Logging raw tokens: Never log or print raw refresh tokens. Log the hashed version or just the user ID. Logs are often less protected than databases.
Key takeaways
- Use short-lived access tokens (15–30 minutes) and longer-lived refresh tokens (7 days) to balance security and UX.
- Always hash refresh tokens before storing them in your database. Treat them like passwords.
- Rotate the refresh token on every use: invalidate the old one, issue a new one, and tie them to a token family.
- Detect token reuse by checking if a refresh token has already been consumed. If so, revoke the entire family immediately.
- Store refresh tokens in secure, httpOnly cookies to protect against XSS. Set
secure: trueandsameSite: 'strict'in production. - Add CSRF protection to refresh endpoints and always validate token expiry with a small clock-skew buffer.






Comments
Loading comments…