Loading…
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:3001
ACCESS_TOKEN_SECRET=your-access-token-secret-min-32-chars
REFRESH_TOKEN_SECRET=your-refresh-token-secret-min-32-chars
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d// src/lib/api-client.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001',
headers: { 'Content-Type': 'application/json' },
// ⚠️ Required: Send cookies with requests
withCredentials: true,
});
// Response interceptor: Auto-refresh on 401
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: any) => void;
}> = [];
const processQueue = (error: any, token: string | null) => {
failedQueue.forEach((prom) => {
error ? prom.reject(error) : prom.resolve(token!);
});
failedQueue = [];
};
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(() => apiClient(originalRequest));
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Cookie is auto-sent by browser
const res = await axios.post(
process.env.NEXT_PUBLIC_API_URL + '/auth/refresh',
{},
{ withCredentials: true }
);
processQueue(null, res.data.accessToken);
return apiClient(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
window.location.href = '/auth/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);// src/api/auth/auth.api.ts
import { apiClient } from '@/lib/api-client';
import type { LoginRequest, LoginResponse, RegisterRequest, RegisterResponse, User } from './auth.types';
export const authApi = {
register: async (data: RegisterRequest): Promise<RegisterResponse> => {
const res = await apiClient.post('/auth/register', data);
return res.data;
},
login: async (data: LoginRequest): Promise<LoginResponse> => {
const res = await apiClient.post('/auth/login', data);
return res.data;
},
logout: async (): Promise<void> => {
await apiClient.post('/auth/logout');
},
getMe: async (): Promise<User> => {
const res = await apiClient.get('/auth/me');
return res.data;
},
forgotPassword: async (email: string): Promise<void> => {
await apiClient.post('/auth/forgot-password', { email });
},
resetPassword: async (token: string, password: string): Promise<void> => {
await apiClient.post('/auth/reset-password', { token, password });
},
changePassword: async (currentPassword: string, newPassword: string): Promise<void> => {
await apiClient.post('/auth/change-password', { currentPassword, newPassword });
},
};// src/store/auth.store.ts (Zustand)
import { create } from 'zustand';
import { authApi } from '@/api/auth/auth';
import type { User, LoginRequest, RegisterRequest } from '@/api/auth/auth.types';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (data: LoginRequest) => Promise<void>;
register: (data: RegisterRequest) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
isAuthenticated: false,
isLoading: true,
error: null,
login: async (data) => {
try {
set({ isLoading: true, error: null });
const response = await authApi.login(data);
set({ user: response.user, isAuthenticated: true, isLoading: false });
} catch (error: any) {
set({ error: error.response?.data?.message || 'Login failed', isLoading: false });
throw error;
}
},
register: async (data) => {
try {
set({ isLoading: true, error: null });
const response = await authApi.register(data);
set({ user: response.user, isAuthenticated: true, isLoading: false });
} catch (error: any) {
set({ error: error.response?.data?.message || 'Registration failed', isLoading: false });
throw error;
}
},
logout: async () => {
try {
await authApi.logout();
} finally {
set({ user: null, isAuthenticated: false });
}
},
refreshUser: async () => {
try {
set({ isLoading: true });
const user = await authApi.getMe();
set({ user, isAuthenticated: true, isLoading: false });
} catch {
set({ user: null, isAuthenticated: false, isLoading: false });
}
},
clearError: () => set({ error: null }),
}));// 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 });
});