Back to all posts
December 5, 2025Charlie BrownSecurity

Implementing JWT Refresh Tokens: Secure Token Management

Learn how to implement secure JWT refresh tokens for long-lived sessions, including token rotation, revocation, and best security practices.

Implementing JWT Refresh Tokens: Secure Token Management

Implementing JWT Refresh Tokens: Secure Token Management

JWT (JSON Web Tokens) are widely used for authentication, but managing token expiration and security requires careful implementation. Refresh tokens provide a secure way to maintain user sessions while keeping access tokens short-lived. In this article, we'll explore how to implement a robust refresh token system.

Why Refresh Tokens?

Security Benefits

  • Short-lived Access Tokens: Access tokens expire quickly (15 minutes), limiting exposure if compromised
  • Long-lived Sessions: Refresh tokens allow users to stay logged in without frequent re-authentication
  • Token Rotation: Refresh tokens can be rotated on each use, invalidating old tokens
  • Revocation: Refresh tokens can be revoked independently of access tokens

Token Lifecycle

  1. User logs in → Receives access token (15 min) + refresh token (7 days)
  2. Access token expires → Use refresh token to get new access token
  3. Refresh token expires → User must log in again

Implementation

1. Token Generation

Generate both access and refresh tokens:

typescript
// apps/api/src/auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class AuthService {
  constructor(
    private jwtService: JwtService,
    private configService: ConfigService,
  ) {}

  async generateTokens(user: User): Promise<{
    access_token: string;
    refresh_token: string;
  }> {
    const payload = {
      sub: user.id,
      email: user.email,
      role: user.role,
    };

    // Short-lived access token
    const accessToken = this.jwtService.sign(payload, {
      secret: this.configService.get<string>('JWT_SECRET'),
      expiresIn: this.configService.get<string>('JWT_EXPIRES_IN', '15m'),
    });

    // Long-lived refresh token
    const refreshToken = this.jwtService.sign(payload, {
      secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
      expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '7d'),
    });

    return {
      access_token: accessToken,
      refresh_token: refreshToken,
    };
  }
}

2. Refresh Token Storage

Store refresh tokens in database for revocation:

typescript
// apps/api/src/users/entities/refresh-token.entity.ts
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn } from 'typeorm';
import { User } from './user.entity';

@Entity('refresh_tokens')
export class RefreshToken {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @ManyToOne(() => User)
  @JoinColumn({ name: 'user_id' })
  user: User;

  @Column({ type: 'varchar', length: 500, unique: true })
  token: string;

  @Column({ type: 'timestamp', name: 'expires_at' })
  expiresAt: Date;

  @Column({ type: 'boolean', default: false })
  revoked: boolean;

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

3. Token Refresh Endpoint

Implement refresh endpoint with token rotation:

typescript
// apps/api/src/auth/auth.service.ts
async refresh(refreshToken: string): Promise<{ access_token: string; refresh_token?: string }> {
  try {
    // Verify refresh token
    const payload = this.jwtService.verify(refreshToken, {
      secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
    });

    // Check if token exists in database and is not revoked
    const storedToken = await this.refreshTokenRepository.findOne({
      where: { token: refreshToken },
      relations: ['user'],
    });

    if (!storedToken || storedToken.revoked || storedToken.expiresAt < new Date()) {
      throw new UnauthorizedException('Invalid or expired refresh token');
    }

    const user = storedToken.user;

    if (!user.isActive) {
      throw new UnauthorizedException('User account is inactive');
    }

    // Revoke old refresh token
    storedToken.revoked = true;
    await this.refreshTokenRepository.save(storedToken);

    // Generate new tokens
    const newPayload = { sub: user.id, email: user.email, role: user.role };
    const newAccessToken = this.jwtService.sign(newPayload);
    
    // Optionally rotate refresh token (recommended for security)
    const newRefreshToken = this.jwtService.sign(newPayload, {
      secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
      expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN', '7d'),
    });

    // Store new refresh token
    const newStoredToken = this.refreshTokenRepository.create({
      user,
      token: newRefreshToken,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
    });
    await this.refreshTokenRepository.save(newStoredToken);

    return {
      access_token: newAccessToken,
      refresh_token: newRefreshToken, // Return new refresh token
    };
  } catch (error) {
    throw new UnauthorizedException('Invalid refresh token');
  }
}

4. Token Revocation

Implement token revocation for logout:

typescript
async logout(refreshToken: string): Promise<void> {
  const storedToken = await this.refreshTokenRepository.findOne({
    where: { token: refreshToken },
  });

  if (storedToken) {
    storedToken.revoked = true;
    await this.refreshTokenRepository.save(storedToken);
  }

  // Optionally revoke all tokens for the user
  // await this.refreshTokenRepository.update(
  //   { user: { id: userId } },
  //   { revoked: true }
  // );
}

5. Controller Endpoints

Expose refresh and logout endpoints:

typescript
// apps/api/src/auth/auth.controller.ts
import { Controller, Post, Body, HttpCode } from '@nestjs/common';
import { AuthService } from './auth.service';

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

  @Post('refresh')
  @HttpCode(200)
  async refresh(@Body() body: { refresh_token: string }) {
    return this.authService.refresh(body.refresh_token);
  }

  @Post('logout')
  @HttpCode(200)
  async logout(@Body() body: { refresh_token: string }) {
    await this.authService.logout(body.refresh_token);
    return { message: 'Logged out successfully' };
  }
}

6. Frontend: Token Management

Store tokens securely and handle refresh automatically:

typescript
// apps/portal/src/lib/auth/token-manager.ts
class TokenManager {
  private accessToken: string | null = null;
  private refreshToken: string | null = null;

  setTokens(accessToken: string, refreshToken: string) {
    this.accessToken = accessToken;
    this.refreshToken = refreshToken;
    
    // Store in httpOnly cookies (more secure) or localStorage
    localStorage.setItem('access_token', accessToken);
    localStorage.setItem('refresh_token', refreshToken);
  }

  getAccessToken(): string | null {
    return this.accessToken || localStorage.getItem('access_token');
  }

  getRefreshToken(): string | null {
    return this.refreshToken || localStorage.getItem('refresh_token');
  }

  async refreshAccessToken(): Promise<string | null> {
    const refreshToken = this.getRefreshToken();
    if (!refreshToken) {
      return null;
    }

    try {
      const response = await fetch('/api/auth/refresh', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ refresh_token: refreshToken }),
      });

      if (!response.ok) {
        this.clearTokens();
        return null;
      }

      const { access_token, refresh_token } = await response.json();
      this.setTokens(access_token, refresh_token || refreshToken);
      return access_token;
    } catch (error) {
      this.clearTokens();
      return null;
    }
  }

  clearTokens() {
    this.accessToken = null;
    this.refreshToken = null;
    localStorage.removeItem('access_token');
    localStorage.removeItem('refresh_token');
  }
}

export const tokenManager = new TokenManager();

7. Frontend: Axios Interceptor

Automatically refresh tokens on 401 errors:

typescript
// apps/portal/src/lib/api/client.ts
import axios from 'axios';
import { tokenManager } from '../auth/token-manager';

const apiClient = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
});

// Add access token to requests
apiClient.interceptors.request.use((config) => {
  const token = tokenManager.getAccessToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Handle token refresh on 401
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // If 401 and haven't tried to refresh yet
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      const newAccessToken = await tokenManager.refreshAccessToken();

      if (newAccessToken) {
        // Retry original request with new token
        originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
        return apiClient(originalRequest);
      } else {
        // Refresh failed, redirect to login
        window.location.href = '/login';
        return Promise.reject(error);
      }
    }

    return Promise.reject(error);
  },
);

Security Best Practices

1. Token Storage

Backend: Store refresh tokens in database for revocation Frontend: Use httpOnly cookies when possible (prevents XSS attacks)

typescript
// Set httpOnly cookie (server-side)
res.cookie('refresh_token', refreshToken, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'strict',
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});

2. Token Rotation

Rotate refresh tokens on each use to prevent token reuse:

typescript
// Issue new refresh token on each refresh
const newRefreshToken = this.jwtService.sign(newPayload, {
  secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
  expiresIn: '7d',
});

3. Token Expiration

Use appropriate expiration times:

  • Access Token: 15 minutes (short-lived)
  • Refresh Token: 7 days (long-lived but revocable)

4. Revocation Strategy

Implement token revocation:

  • Single Token: Revoke specific token on logout
  • User Tokens: Revoke all tokens for a user (on password change, etc.)
  • Device Tokens: Track device and revoke by device

5. Rate Limiting

Protect refresh endpoint from abuse:

typescript
@Post('refresh')
@UseGuards(ThrottlerGuard)
@Throttle(5, 60) // 5 requests per minute
async refresh(@Body() body: { refresh_token: string }) {
  return this.authService.refresh(body.refresh_token);
}

Token Cleanup

Clean up expired tokens periodically:

typescript
// Scheduled task to clean expired tokens
@Cron(CronExpression.EVERY_DAY_AT_2AM)
async cleanupExpiredTokens() {
  await this.refreshTokenRepository.delete({
    expiresAt: LessThan(new Date()),
  });
}

Conclusion

JWT refresh tokens provide a secure way to manage user sessions while keeping access tokens short-lived. By implementing token rotation, revocation, and proper storage, you create a robust authentication system that balances security and user experience. Remember to use httpOnly cookies when possible, implement rate limiting, and clean up expired tokens regularly.

References

Want more insights?

Subscribe to our newsletter or follow us for more updates on software development and team scaling.

Contact Us