A lightweight, type-safe token refresh library for JavaScript/TypeScript applications. Zero dependencies, works with any frontend framework.
fetch APInpm install ts-retoken
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();
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(),
});
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'),
});
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
});
createRetoken<TResponse>(config)Creates a retoken instance with the provided configuration.
| 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 |
| 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 |
| Option | Type | Default | Description |
|---|---|---|---|
delays |
number[] |
[3000, 6000, 12000] |
Delays between retries (ms) |
skipOnClientError |
boolean |
true |
Skip retry on 4xx errors |
| Option | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
false |
Enable cross-tab sync |
channelName |
string |
'ts-retoken-auth' |
BroadcastChannel name |
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 |
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 |
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:
skipProactiveRefresh is true)skipRetry is true)FetchError for unexpected HTTP status codesnull for 204 No Content responsesBasic 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 |
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);
}
}
// 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());
// 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 };
}
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')}`,
},
});
}
const retoken = createRetoken({
// ...
retry: {
delays: [1000, 2000, 4000, 8000], // 4 retries
skipOnClientError: true,
},
expirationLeeway: 30, // Refresh 30s before expiration
});
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],
});
Proactive Refresh: Before each request, checks if the access token expires within expirationLeeway seconds. If so, refreshes the token first.
Fallback Refresh: If the request returns a status in retryStatuses (default: 401), attempts to refresh the token and retries the request.
Request Deduplication: If multiple requests trigger a refresh simultaneously, only one refresh request is made. All pending requests wait for the same refresh promise.
Retry with Backoff: Failed refresh requests are retried with exponential backoff (default: 3s, 6s, 12s). Client errors (4xx) are not retried.
Auth Failure: When the refresh request returns a status in refreshFailureStatuses, onAuthFailure is called and no more retries are attempted.
MIT