Token Refresh¶
Automatic JWT refresh with request deduplication.
Overview¶
sequenceDiagram
participant C as Component
participant F as Fetch Interceptor
participant A as Auth Store
participant API as Backend
C->>F: API Request
F->>API: Request + Bearer Token
API-->>F: 401 Unauthorized
F->>A: refreshAccessToken()
A->>API: POST /refresh (cookie)
API-->>A: New Access Token
A->>A: Store Token
F->>API: Retry Original Request
API-->>F: Success
F-->>C: Response Token Storage¶
| Token | Storage | Lifetime |
|---|---|---|
| Access Token | localStorage | 15 min |
| Refresh Token | HTTP-only cookie | 1-30 days |
Fetch Interceptor¶
Global fetch override handles 401 responses:
const originalFetch = window.fetch;
window.fetch = async (input, init) => {
const response = await originalFetch(input, init);
if (response.status === 401 && !isAuthEndpoint(input)) {
const newToken = await refreshAccessToken();
if (newToken) {
// Retry with new token
return originalFetch(input, {
...init,
headers: {
...init?.headers,
Authorization: `Bearer ${newToken}`,
},
});
}
}
return response;
};
Refresh Deduplication¶
Prevents multiple concurrent refresh requests:
let refreshPromise: Promise<string | null> | null = null;
export async function refreshAccessToken(): Promise<string | null> {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = doRefresh();
try {
return await refreshPromise;
} finally {
refreshPromise = null;
}
}
async function doRefresh(): Promise<string | null> {
const response = await fetch("/api/v1/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!response.ok) {
useAuth.getState().clearAuthState();
return null;
}
const { accessToken } = await response.json();
useAuth.getState().setAccessToken(accessToken);
return accessToken;
}
Auth Store Integration¶
const useAuth = create<AuthState>((set, get) => ({
isLoggedIn: false,
accessToken: null,
setAccessToken: (token) => {
storage.set(AUTH.ACCESS_TOKEN_KEY, token);
const data = parseTokenData(token);
set({
isLoggedIn: true,
userId: data.userId,
groupId: data.groupId,
role: data.role,
modules: data.modules,
});
},
clearAuthState: () => {
storage.remove(AUTH.ACCESS_TOKEN_KEY);
set({ isLoggedIn: false, userId: null });
},
getAccessToken: () => storage.get(AUTH.ACCESS_TOKEN_KEY),
}));
JWT Parsing¶
export function parseTokenData(token: string): TokenData {
const payload = parseJwt(token);
return {
userId: payload[AUTH.USER_ID_CLAIM],
groupId: payload[AUTH.GROUP_ID_CLAIM],
currency: payload[AUTH.CURRENCY_CLAIM],
role: payload[AUTH.ROLE_CLAIM],
modules: JSON.parse(payload[AUTH.MODULES_CLAIM] || "[]"),
permsVersion: payload[AUTH.PERMS_VERSION_CLAIM],
};
}
function parseJwt(token: string): Record<string, string> {
const base64 = token.split(".")[1];
const json = atob(base64);
return JSON.parse(json);
}
Policy Change Handling¶
When permissions change, backend returns special error:
if (response.status === 403) {
const data = await response.json();
if (data.code === "AUTH.POLICY_CHANGED") {
// Force token refresh
const newToken = await refreshAccessToken();
if (newToken) {
return fetch(input, init); // Retry
}
}
}
Logout Flow¶
async function logOut() {
await fetch("/api/v1/auth/logout", {
method: "POST",
credentials: "include",
});
useAuth.getState().clearAuthState();
queryClient.clear();
Sentry.setUser(null);
}
Related¶
- Authentication — Auth flow
- State Management — Zustand stores
- API Layer — API setup