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.api';
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 });
// ⚠️ No token handling needed!
// Server sets HttpOnly cookie via Set-Cookie header
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());
// Login endpoint
app.post('/auth/login', async (req, res) => {
const { email, password } = req.body;
// Validate credentials (your logic here)
const user = await validateUser(email, password);
if (!user) {
return res.status(401).json({ message: 'Invalid credentials' });
}
// Generate tokens
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' }
);
// ⚠️ Set refresh token as HttpOnly cookie
res.cookie('refresh_token', refreshToken, {
httpOnly: true, // JS cannot access
secure: true, // HTTPS only in production
sameSite: 'strict', // CSRF protection
path: '/', // Available to all routes
maxAge: 60 * 60 * 24 * 7, // 7 days
});
// Return user + access token in body
// (client stores access token if using memory strategy,
// but for cookie-based, client doesn't need it)
return res.json({
user: { id: user.id, email: user.email, name: user.name },
accessToken, // Optional for cookie-based (not used by client)
});
});
// Refresh endpoint
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' });
}
});
// Logout endpoint
app.post('/auth/logout', (req, res) => {
// Clear the refresh token cookie
res.clearCookie('refresh_token', {
httpOnly: true,
secure: true,
sameSite: 'strict',
path: '/',
});
return res.json({ success: true });
});withCredentials: true for Axios