Careeroza
Refresh Tokens
Access + Refresh Token Flow
login -> accessToken (15 min) + refreshToken (7 days, stored hashed in DB)
API calls -> Authorization: Bearer <accessToken>
access expires -> 401 -> app calls POST /auth/refresh with refreshToken
/auth/refresh -> verify refresh JWT + compare with stored hash
-> issue NEW access + NEW refresh token (rotation), replace stored hash
logout -> delete stored hash: refresh token is now useless
stolen old token -> hash does not match -> 401, and revoke all sessions (reuse detection)Implementation
src/auth/auth.service.ts (refresh part)
@Injectable()
export class AuthService {
constructor(
private readonly users: UsersService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
async issueTokens(user: { id: number; email: string; roles: string[] }) {
const payload = { sub: user.id, email: user.email, roles: user.roles };
const [accessToken, refreshToken] = await Promise.all([
this.jwt.signAsync(payload), // default secret, 15m
this.jwt.signAsync({ sub: user.id }, {
secret: this.config.getOrThrow('JWT_REFRESH_SECRET'),
expiresIn: '7d',
}),
]);
await this.users.setRefreshTokenHash(user.id, await bcrypt.hash(refreshToken, 10));
return { accessToken, refreshToken };
}
async refresh(refreshToken: string) {
let sub: number;
try {
({ sub } = await this.jwt.verifyAsync<{ sub: number }>(refreshToken, {
secret: this.config.getOrThrow('JWT_REFRESH_SECRET'),
}));
} catch {
throw new UnauthorizedException('Invalid refresh token');
}
const user = await this.users.findById(sub);
if (!user?.refreshTokenHash || !(await bcrypt.compare(refreshToken, user.refreshTokenHash))) {
if (user) await this.users.setRefreshTokenHash(user.id, null); // reuse detected: log out everywhere
throw new UnauthorizedException('Refresh token revoked');
}
return this.issueTokens(user); // rotation
}
logout(userId: number) {
return this.users.setRefreshTokenHash(userId, null);
}
}Tip: For browsers, send the refresh token in an httpOnly, secure, sameSite=strict cookie (with @Res({ passthrough: true }) and res.cookie(...)) so JavaScript on the page cannot read it.
Rotation and Reuse Detection, Runnable
Example
A small model of rotation: every refresh replaces the stored token, and presenting an old one revokes the session.
import { createHash } from 'node:crypto';
const sha = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 12);
const store = new Map<number, string | null>(); // userId -> hash of current refresh token
let counter = 0;
function issue(userId: number) {
const refresh = 'rt-' + userId + '-' + ++counter;
store.set(userId, sha(refresh));
return refresh;
}
function refresh(userId: number, token: string): string {
const current = store.get(userId);
if (!current || current !== sha(token)) {
store.set(userId, null); // reuse detected: revoke
throw new Error('401 Refresh token revoked');
}
return issue(userId);
}
const t1 = issue(12);
const t2 = refresh(12, t1);
console.log('rotated', t1, '->', t2);
const t3 = refresh(12, t2);
console.log('rotated', t2, '->', t3);
try { refresh(12, t1); } catch (e) { console.log('attacker replays', t1 + ':', (e as Error).message); }
try { refresh(12, t3); } catch (e) { console.log('real user with', t3 + ':', (e as Error).message, '(must log in again)'); }Output
rotated rt-12-1 -> rt-12-2 rotated rt-12-2 -> rt-12-3 attacker replays rt-12-1: 401 Refresh token revoked real user with rt-12-3: 401 Refresh token revoked (must log in again)