npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

ts-retoken

v0.2.0

Published

Lightweight TypeScript library for automatic JWT token refresh with cross-tab synchronization

Readme

ts-retoken

A lightweight, type-safe token refresh library for JavaScript/TypeScript applications. Zero dependencies, works with any frontend framework.

Features

  • Type-safe: Full TypeScript support with generics for API response types
  • Two storage modes: localStorage or HTTP-only cookie
  • Configurable: Custom status codes, retry delays, and response parsing
  • Proactive refresh: Refreshes tokens before they expire
  • Request deduplication: Only one refresh request at a time
  • Retry with backoff: Exponential backoff for failed refresh requests
  • Cross-tab sync: Optional logout synchronization across browser tabs
  • Zero dependencies: Uses native fetch API

Installation

npm install ts-retoken

Quick Start

import { createRetoken } from 'ts-retoken';

const retoken = createRetoken({
  refreshEndpoint: {
    url: 'https://api.example.com/auth/refresh',
    parseResponse: (data) => ({
      accessToken: data.access_token,
      refreshToken: data.refresh_token,
    }),
  },
  getAccessToken: () => localStorage.getItem('access_token'),
  getRefreshToken: () => localStorage.getItem('refresh_token'),
  setTokens: (tokens) => {
    localStorage.setItem('access_token', tokens.accessToken);
    localStorage.setItem('refresh_token', tokens.refreshToken);
  },
  clearTokens: () => localStorage.clear(),
  onAuthFailure: () => {
    window.location.href = '/login';
  },
});

// Use the fetch wrapper - handles token refresh automatically
const response = await retoken.fetch('/api/users/me');
const user = await response.json();

Storage Modes

Mode 1: localStorage (or any storage)

Provide getRefreshToken to use localStorage, sessionStorage, or any custom storage:

const retoken = createRetoken({
  refreshEndpoint: {
    url: '/api/auth/refresh',
    buildBody: (token) => JSON.stringify({ refresh_token: token }),
    parseResponse: (data) => ({
      accessToken: data.access_token,
      refreshToken: data.refresh_token,
    }),
  },
  getAccessToken: () => localStorage.getItem('access_token'),
  getRefreshToken: () => localStorage.getItem('refresh_token'),
  setTokens: (tokens) => {
    localStorage.setItem('access_token', tokens.accessToken);
    localStorage.setItem('refresh_token', tokens.refreshToken);
  },
  clearTokens: () => localStorage.clear(),
});

Mode 2: HTTP-only Cookie

Omit getRefreshToken for HTTP-only cookie mode. The refresh token is sent automatically via cookies:

const retoken = createRetoken({
  refreshEndpoint: {
    url: '/api/auth/refresh',
    credentials: 'include', // Send cookies with request
    parseResponse: (data) => ({
      accessToken: data.access_token,
      refreshToken: '', // Not needed in cookie mode
    }),
  },
  getAccessToken: () => localStorage.getItem('access_token'),
  // getRefreshToken OMITTED = cookie mode
  setTokens: (tokens) => {
    localStorage.setItem('access_token', tokens.accessToken);
  },
  clearTokens: () => localStorage.removeItem('access_token'),
});

Type-Safe API Responses

Use generics to get full TypeScript inference for your API response:

// Define your API response type
interface RefreshResponse {
  data: {
    access_token: string;
    refresh_token: string;
    expires_in: number;
  };
}

// Pass it as a generic parameter
const retoken = createRetoken<RefreshResponse>({
  refreshEndpoint: {
    url: '/api/auth/refresh',
    parseResponse: (data) => ({
      // 'data' is typed as RefreshResponse
      // TypeScript will autocomplete: data.data.access_token
      accessToken: data.data.access_token,
      refreshToken: data.data.refresh_token,
    }),
  },
  // ... other config
});

API Reference

createRetoken<TResponse>(config)

Creates a retoken instance with the provided configuration.

Config Options

| Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | refreshEndpoint | RefreshEndpointConfig | Yes | - | Refresh endpoint configuration | | getAccessToken | () => string \| null | Yes | - | Function to get current access token | | getRefreshToken | () => string \| null | No | - | Function to get refresh token (omit for cookie mode) | | setTokens | (tokens: TokenPair) => void | Yes | - | Function to store new tokens | | clearTokens | () => void | Yes | - | Function to clear tokens on auth failure | | expirationLeeway | number | No | 60 | Seconds before expiration to refresh proactively | | retryStatuses | number[] | No | [401] | Status codes that trigger refresh + retry | | refreshFailureStatuses | number[] | No | [401, 403] | Refresh status codes that mean auth failed | | retry | RetryConfig | No | See below | Retry configuration | | crossTab | CrossTabConfig | No | { enabled: false } | Cross-tab sync configuration | | onAuthFailure | () => void | No | - | Callback when auth fails completely | | onTokenRefresh | (tokens: TokenPair) => void | No | - | Callback when tokens are refreshed |

RefreshEndpointConfig

| Option | Type | Required | Default | Description | |--------|------|----------|---------|-------------| | url | string | Yes | - | Full URL to refresh endpoint | | method | 'POST' \| 'PUT' | No | 'POST' | HTTP method | | credentials | RequestCredentials | No | 'same-origin' | Fetch credentials mode | | headers | Record<string, string> | No | - | Additional headers | | buildBody | (token: string) => BodyInit | No | JSON with refresh_token | Build request body | | parseResponse | (response: TResponse) => TokenPair | Yes | - | Parse response to TokenPair |

RetryConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | delays | number[] | [3000, 6000, 12000] | Delays between retries (ms) | | skipOnClientError | boolean | true | Skip retry on 4xx errors |

CrossTabConfig

| Option | Type | Default | Description | |--------|------|---------|-------------| | enabled | boolean | false | Enable cross-tab sync | | channelName | string | 'ts-retoken-auth' | BroadcastChannel name |

RetokenInstance

The object returned by createRetoken():

| Method | Type | Description | |--------|------|-------------| | fetch | (url: string, options?: RetokenFetchOptions) => Promise<Response> | Fetch wrapper with auto-refresh | | fetchJson | <T>(url: string, options?: RetokenFetchJsonOptions) => Promise<T> | Type-safe fetch that returns parsed JSON | | refreshToken | () => Promise<TokenPair> | Manually trigger token refresh | | isTokenExpiringSoon | () => boolean | Check if access token expires soon | | parseTokenExpiration | (token: string) => number \| null | Parse JWT expiration (ms) | | broadcastLogout | () => void | Broadcast logout to other tabs | | destroy | () => void | Cleanup resources |

RetokenFetchOptions

Options for the fetch wrapper (extends RequestInit):

| Option | Type | Default | Description | |--------|------|---------|-------------| | headers | Record<string, string> | - | Request headers | | skipProactiveRefresh | boolean | false | Skip proactive token refresh | | skipRetry | boolean | false | Skip retry on retryStatuses |

RetokenFetchJsonOptions

Options for the fetchJson wrapper (extends RetokenFetchOptions):

| Option | Type | Default | Description | |--------|------|---------|-------------| | expectedStatuses | number[] | [200, 201] | HTTP status codes that indicate success |

fetchJson<T>(url, options)

Type-safe fetch wrapper that returns parsed JSON with automatic token management.

Features:

  • Proactively refreshes token if expiring soon (unless skipProactiveRefresh is true)
  • Retries with a new token on 401 responses (unless skipRetry is true)
  • Parses response body as JSON with full type safety
  • Throws FetchError for unexpected HTTP status codes
  • Returns null for 204 No Content responses

Basic Usage:

interface User {
  id: string;
  name: string;
  email: string;
}

// GET request - response is typed as User
const user = await retoken.fetchJson<User>('/api/users/me');
console.log(user.name); // Fully typed

POST Request with Custom Status Codes:

interface CreateUserResponse {
  id: string;
  createdAt: string;
}

const newUser = await retoken.fetchJson<CreateUserResponse>('/api/users', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'John', email: '[email protected]' }),
  expectedStatuses: [201], // Only 201 is considered success
});

Error Handling:

import { FetchError } from 'ts-retoken';

try {
  const data = await retoken.fetchJson<SomeType>('/api/resource');
} catch (error) {
  if (error instanceof FetchError) {
    console.log(error.message); // "Request failed with status 404"
    console.log(error.status);  // 404
    console.log(error.body);    // Parsed error response body (if JSON)
  }
}

FetchError Properties:

| Property | Type | Description | |----------|------|-------------| | message | string | Error message including status code | | status | number | HTTP status code | | body | unknown | Parsed response body (if JSON) or null |

Standalone Utilities

import { isTokenExpiringSoon, parseTokenExpiration, RefreshError } from 'ts-retoken';

// Check if token expires within 60 seconds
const expiring = isTokenExpiringSoon(token, 60);

// Parse expiration timestamp from JWT
const expiresAt = parseTokenExpiration(token); // milliseconds or null

// RefreshError has a status property
try {
  await retoken.refreshToken();
} catch (error) {
  if (error instanceof RefreshError) {
    console.log('Refresh failed with status:', error.status);
  }
}

Advanced Examples

With React

// lib/auth.ts
import { createRetoken, TokenPair } from 'ts-retoken';

// Mutable callback holder for React hooks integration
export const authCallbacks = {
  onAuthFailure: () => {},
  onTokenRefresh: (_tokens: TokenPair) => {},
};

export const retoken = createRetoken({
  refreshEndpoint: {
    url: `${import.meta.env.VITE_API_URL}/auth/refresh`,
    parseResponse: (data) => ({
      accessToken: data.access_token,
      refreshToken: data.refresh_token,
    }),
  },
  getAccessToken: () => localStorage.getItem('access_token'),
  getRefreshToken: () => localStorage.getItem('refresh_token'),
  setTokens: (tokens) => {
    localStorage.setItem('access_token', tokens.accessToken);
    localStorage.setItem('refresh_token', tokens.refreshToken);
  },
  clearTokens: () => localStorage.clear(),
  crossTab: { enabled: true },
  // Delegate to mutable callbacks
  onAuthFailure: () => authCallbacks.onAuthFailure(),
  onTokenRefresh: (tokens) => authCallbacks.onTokenRefresh(tokens),
});
// AuthProvider.tsx - Set callbacks with React hooks
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { authCallbacks } from './auth';

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const navigate = useNavigate();

  useEffect(() => {
    authCallbacks.onAuthFailure = () => {
      navigate('/login');
    };

    return () => {
      authCallbacks.onAuthFailure = () => {};
    };
  }, [navigate]);

  return <>{children}</>;
}
// Use in components
const users = await retoken.fetch('/api/users').then(r => r.json());

With Vue

// composables/useAuth.ts
import { createRetoken } from 'ts-retoken';
import { ref, onUnmounted } from 'vue';

const accessToken = ref<string | null>(localStorage.getItem('access_token'));

export const retoken = createRetoken({
  refreshEndpoint: {
    url: '/api/auth/refresh',
    parseResponse: (data) => ({
      accessToken: data.access_token,
      refreshToken: data.refresh_token,
    }),
  },
  getAccessToken: () => accessToken.value,
  getRefreshToken: () => localStorage.getItem('refresh_token'),
  setTokens: (tokens) => {
    accessToken.value = tokens.accessToken;
    localStorage.setItem('access_token', tokens.accessToken);
    localStorage.setItem('refresh_token', tokens.refreshToken);
  },
  clearTokens: () => {
    accessToken.value = null;
    localStorage.clear();
  },
});

export function useAuth() {
  onUnmounted(() => retoken.destroy());
  return { fetch: retoken.fetch, isTokenExpiringSoon: retoken.isTokenExpiringSoon };
}

Manual Token Refresh

Use with your own HTTP client (axios, ky, etc.):

import { createRetoken } from 'ts-retoken';
import axios from 'axios';

const retoken = createRetoken({ /* config */ });

// Ensure valid token before axios request
async function apiRequest(url: string) {
  if (retoken.isTokenExpiringSoon()) {
    await retoken.refreshToken();
  }

  return axios.get(url, {
    headers: {
      Authorization: `Bearer ${localStorage.getItem('access_token')}`,
    },
  });
}

Custom Retry Configuration

const retoken = createRetoken({
  // ...
  retry: {
    delays: [1000, 2000, 4000, 8000], // 4 retries
    skipOnClientError: true,
  },
  expirationLeeway: 30, // Refresh 30s before expiration
});

Custom Status Codes

const retoken = createRetoken({
  // ...

  // Original request: which statuses trigger refresh + retry
  retryStatuses: [401, 403],

  // Refresh request: which statuses mean "auth failed completely"
  refreshFailureStatuses: [401, 403, 422],
});

How It Works

  1. Proactive Refresh: Before each request, checks if the access token expires within expirationLeeway seconds. If so, refreshes the token first.

  2. Fallback Refresh: If the request returns a status in retryStatuses (default: 401), attempts to refresh the token and retries the request.

  3. Request Deduplication: If multiple requests trigger a refresh simultaneously, only one refresh request is made. All pending requests wait for the same refresh promise.

  4. Retry with Backoff: Failed refresh requests are retried with exponential backoff (default: 3s, 6s, 12s). Client errors (4xx) are not retried.

  5. Auth Failure: When the refresh request returns a status in refreshFailureStatuses, onAuthFailure is called and no more retries are attempted.

License

MIT