Skip to content
NestJS ns auth 5 min read

Refresh Tokens

Short-lived access tokens keep your API stateless and limit damage if a token leaks, but forcing users to log in every fifteen minutes is a poor experience. The standard solution is a pair: a short-lived access token that authorizes requests, and a long-lived refresh token that mints new access tokens when the old one expires. To stay secure you rotate the refresh token on every use, store only a hash of it server-side, and revoke it on logout or theft. This page builds that flow in modern NestJS with @nestjs/jwt.

How the two-token pattern works

The access token (5-15 minutes) is sent on every request and verified statelessly. The refresh token (days to weeks) is sent only to a single /auth/refresh endpoint. When the access token expires, the client posts its refresh token, the server validates it against stored state, and returns a brand-new pair.

Rotation means each refresh issues a new refresh token and invalidates the old one. If an attacker steals a refresh token and uses it, the legitimate client’s next refresh will fail — surfacing the breach so you can revoke the whole session family.

PropertyAccess tokenRefresh token
Lifetime5-15 minutes7-30 days
Sent onEvery API request/auth/refresh only
Stored server-sideNo (stateless)Yes (hashed)
SecretJWT_ACCESS_SECRETJWT_REFRESH_SECRET
RotatedNoYes, on every use

Issuing a token pair

Use a single JwtService with per-call overrides so access and refresh tokens are signed with different secrets and expiries. After signing the refresh token, store a hash of it on the user record — never the raw token. If your database leaks, hashed tokens are useless to an attacker.

// auth/auth.service.ts
import { Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { UsersService } from '../users/users.service';
import * as argon2 from 'argon2';

@Injectable()
export class AuthService {
  constructor(
    private readonly users: UsersService,
    private readonly jwt: JwtService,
    private readonly config: ConfigService,
  ) {}

  private async signTokens(userId: number, email: string) {
    const payload = { sub: userId, email };
    const [accessToken, refreshToken] = await Promise.all([
      this.jwt.signAsync(payload, {
        secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
        expiresIn: '15m',
      }),
      this.jwt.signAsync(payload, {
        secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
        expiresIn: '7d',
      }),
    ]);
    return { accessToken, refreshToken };
  }

  private async storeRefreshHash(userId: number, refreshToken: string) {
    const hash = await argon2.hash(refreshToken);
    await this.users.setRefreshHash(userId, hash);
  }

  async login(email: string, password: string) {
    const user = await this.users.findByEmail(email);
    if (!user || !(await argon2.verify(user.passwordHash, password))) {
      throw new UnauthorizedException('Invalid credentials');
    }
    const tokens = await this.signTokens(user.id, user.email);
    await this.storeRefreshHash(user.id, tokens.refreshToken);
    return tokens;
  }
}

Tip: Hashing the refresh token, not just comparing it literally, is what makes a database leak survivable. Argon2 (or bcrypt) is intentionally slow, so brute-forcing the stored hash back into a usable token is infeasible.

Rotating on refresh

The refresh endpoint validates the incoming token’s signature, confirms its hash matches what you stored, then issues a fresh pair and overwrites the stored hash. Comparing against the stored hash is the revocation gate: if setRefreshHash was cleared (logout) or replaced (rotation), an old token no longer matches.

// auth/auth.service.ts (continued)
async refresh(userId: number, refreshToken: string) {
  const user = await this.users.findById(userId);
  if (!user || !user.refreshHash) {
    throw new ForbiddenException('Access denied');
  }

  const matches = await argon2.verify(user.refreshHash, refreshToken);
  if (!matches) {
    // Token reuse or revoked session — kill the whole family.
    await this.users.setRefreshHash(userId, null);
    throw new ForbiddenException('Access denied');
  }

  const tokens = await this.signTokens(user.id, user.email);
  await this.storeRefreshHash(user.id, tokens.refreshToken);
  return tokens;
}

async logout(userId: number) {
  await this.users.setRefreshHash(userId, null);
}

Validating refresh tokens with a Passport strategy

A dedicated jwt-refresh strategy verifies the refresh token’s signature and forwards the raw token to the controller so the service can hash-compare it. Read the token from an httpOnly cookie (recommended) or the Authorization header.

// auth/strategies/refresh.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';

@Injectable()
export class RefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor(config: ConfigService) {
    super({
      jwtFromRequest: (req: Request) => req?.cookies?.refresh_token ?? null,
      secretOrKey: config.getOrThrow<string>('JWT_REFRESH_SECRET'),
      passReqToCallback: true,
    });
  }

  validate(req: Request, payload: { sub: number; email: string }) {
    const refreshToken = req?.cookies?.refresh_token;
    return { ...payload, refreshToken };
  }
}

Delivering tokens over httpOnly cookies

Storing tokens in localStorage exposes them to XSS. An httpOnly, secure, sameSite cookie cannot be read by JavaScript and is sent automatically. Scope the refresh cookie to the refresh path so it is never leaked on ordinary API calls.

// auth/auth.controller.ts
import { Controller, Post, Body, Res, UseGuards, Req } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Response, Request } from 'express';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private readonly auth: AuthService) {}

  private setRefreshCookie(res: Response, token: string) {
    res.cookie('refresh_token', token, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      path: '/auth/refresh',
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    });
  }

  @Post('login')
  async login(@Body() dto: { email: string; password: string }, @Res({ passthrough: true }) res: Response) {
    const { accessToken, refreshToken } = await this.auth.login(dto.email, dto.password);
    this.setRefreshCookie(res, refreshToken);
    return { accessToken };
  }

  @UseGuards(AuthGuard('jwt-refresh'))
  @Post('refresh')
  async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
    const { sub, refreshToken } = req.user as { sub: number; refreshToken: string };
    const tokens = await this.auth.refresh(sub, refreshToken);
    this.setRefreshCookie(res, tokens.refreshToken);
    return { accessToken: tokens.accessToken };
  }
}

Output:

$ curl -i -X POST localhost:3000/auth/refresh \
    --cookie 'refresh_token=eyJhbGciOiJIUzI1NiIs...'

HTTP/1.1 201 Created
Set-Cookie: refresh_token=eyJhbGciOiJIUzI1NiIs...new; Path=/auth/refresh; HttpOnly; Secure; SameSite=Strict
Content-Type: application/json

{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

Enable cookie parsing in main.ts with app.use(cookieParser()) so the strategy can read req.cookies.

Best Practices

  • Keep access tokens short (5-15 minutes) and refresh tokens longer, with separate signing secrets for each.
  • Store only a hash of the refresh token (Argon2 or bcrypt) — never the raw value.
  • Rotate the refresh token on every use and detect reuse to catch stolen tokens early.
  • On detected reuse, revoke the entire token family by clearing the stored hash, forcing re-login.
  • Deliver refresh tokens via httpOnly, secure, sameSite cookies scoped to the refresh path, not localStorage.
  • Clear the stored hash on logout so the refresh token is immediately and permanently invalidated.
Last updated June 14, 2026
Was this helpful?