Loading…
Both access and refresh tokens are stored in HttpOnly cookies. JavaScript cannot access them, providing full XSS protection.
User submits credentials. Server validates and generates tokens.
Server sends Set-Cookie header with HttpOnly refresh token.
Browser automatically stores the cookie. JavaScript cannot access it.
Browser automatically sends cookie with every request to your API.
HttpOnly cookies cannot be read by JavaScript. Even if an attacker injects malicious scripts, they cannot steal your tokens.
Use SameSite=Strict or Lax to prevent cross-site request forgery attacks.
Cookies are automatically sent with every request. No need to manually add Authorization headers.
Works seamlessly with server-side rendering. Cookies are available on both client and server.
# .env.local
NEXT_PUBLIC_API_URL=http://localhost:4000/**
* Strategy: HttpOnly cookies.
*
* Your backend sets the access and refresh tokens as HttpOnly, Secure,
* SameSite cookies on /auth/login and /auth/refresh. JavaScript never sees
* them (immune to token theft via XSS); the browser sends them with every
* request because requests use `credentials: 'include'`.
*
* Backend checklist: CORS `Access-Control-Allow-Credentials: true` with an
* explicit origin, and cookies scoped to a domain this app can reach.
*/
export const TOKEN_STRATEGY = 'cookie-based';
export const USES_COOKIES = true;
export const getAccessToken = (): string | null => null;
export const getRefreshToken = (): string | null => null;
export const setTokens = (_tokens: { accessToken?: string; refreshToken?: string }): void => {};
export const clearTokens = (): void => {};
/** Nothing to watch — cookies are shared by all tabs automatically. */
export const subscribe = (_listener: () => void): (() => void) => () => {};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;
}// Example: Node.js/Express backend
import express from 'express';
import jwt from 'jsonwebtoken';
import cookieParser from 'cookie-parser';
const app = express();
app.use(cookieParser());
app.use(express.json());
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
const user = await validateUser(email, password);
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
const accessToken = jwt.sign(
{ userId: user.id, email: user.email },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
process.env.REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' }
);
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
return res.json({
user: { id: user.id, email: user.email, name: user.name },
accessToken,
});
});
app.post('/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (!refreshToken) {
return res.status(401).json({ message: 'No refresh token' });
}
try {
const payload = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET);
const newAccessToken = jwt.sign(
{ userId: payload.userId, email: payload.email },
process.env.ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
return res.json({ accessToken: newAccessToken });
} catch (error) {
return res.status(401).json({ message: 'Invalid refresh token' });
}
});
app.post('/auth/logout', (req, res) => {
res.clearCookie('refresh_token', {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
});
return res.json({ success: true });
});