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)