Як працює управління сесіями в Express.js?
Управління сесіями в Express.js зберігає дані конкретного користувача на сервері між безстановими HTTP-запитами, використовуючи cookie з ідентифікатором сесії для пошуку відповідного серверного запису.
Теорія
TL;DR
- Аналогія: гардероб. Здаєш пальто (дані), отримуєш номерок (cookie), повертаєш пальто, показавши номерок.
- Cookie містить тільки підписаний ID, а не самі дані. Уся інформація залишається на сервері.
connect.sidнесе ID сесії;req.sessionдає доступ до збереженого об'єкта при кожному запиті.- Сесії підходять, коли сервер керує станом (логін, відкликання). JWT краще для stateless API.
- MemoryStore за замовчуванням працює тільки в розробці. Для продакшену потрібен Redis або Postgres.
Швидкий приклад
const express = require('express');
const session = require('express-session');
const app = express();
app.use(session({
secret: process.env.SESSION_SECRET, // Підписує cookie з ID сесії
resave: false, // Не писати в store, якщо нічого не змінилось
saveUninitialized: false, // Не створювати сесію для анонімних запитів
cookie: { maxAge: 60000, httpOnly: true, sameSite: 'lax' }
}));
app.post('/login', (req, res) => {
req.session.userId = '123'; // Дані зберігаються на сервері
res.json({ ok: true }); // Клієнт отримує: connect.sid=s%3Aabc.xyz
});
app.get('/profile', (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'Не авторизовано' });
res.json({ user: req.session.userId }); // Знайдено за ID з cookie
});Middleware прикріплює req.session до кожного запиту. Ти пишеш у нього як у звичайний об'єкт. Express-session сам читає, пише та підписує за лаштунками.
Як session storage працює зсередини
Коли приходить запит, express-session читає cookie connect.sid і перевіряє підпис HMAC-SHA256 відносно твого secret. Якщо підпис валідний, бібліотека отримує об'єкт сесії зі store (за замовчуванням це JS-об'єкт у пам'яті). Наприкінці циклу запиту зміни записуються назад.
Сам cookie містить тільки підписаний ID. Жодних чутливих даних. Уся інформація залишається на сервері. В цьому і є суть патерну.
Завдяки event-driven моделі Node читання й запис у store відбуваються асинхронно і не блокують інші запити. З Redis це працює достатньо швидко навіть при великому навантаженні.
Головна відмінність від клієнтського зберігання
localStorage і клієнтські токени відкриті для JavaScript і вразливі до XSS. Cookie з httpOnly: true скрипти взагалі не можуть прочитати. Крім того, сервер контролює закінчення терміну дії та відкликання, просто видаляючи запис сесії. Cookie залишається маленьким (тільки ID), тому розмір запитів не зростає.
Коли використовувати сесії
- Автентифікація: зберігай стан входу та ролі на сервері. Сервер може відкликати доступ миттєво.
- Кошик товарів: зберігай товари до оформлення замовлення без витоку даних на клієнт.
- A/B тестування: відстежуй групи користувачів на сервері, щоб клієнт не міг підробити своє призначення.
- Не підходить для публічних API, що споживаються SPA або мобільними застосунками. Там краще JWT, без зберігання на сервері.
- Для кластерних деплойментів або стабільно великого трафіку (більше ~10 req/s) використовуй Redis. MemoryStore не витримає.
Session stores
MemoryStore за замовчуванням витікає в пам'яті в продакшені та ламається в кластерних конфігураціях. Кожен Node-процес має власну пам'ять, тому логін на worker 1 не спрацює на worker 2. Це найпоширеніша проблема з сесіями на Stack Overflow.
| Store | Пакет | Коли використовувати |
|---|---|---|
| MemoryStore | Вбудований | Тільки для розробки |
| Redis | connect-redis | Продакшен будь-якого масштабу |
| PostgreSQL | connect-pg-simple | Якщо вже використовуєш Postgres |
| MongoDB | connect-mongo | Якщо вже використовуєш MongoDB |
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, sameSite: 'lax', httpOnly: true }
}));Сесії vs JWT
| Сесії | JWT | |
|---|---|---|
| Зберігання | На сервері | На клієнті |
| Масштабування | Потрібне спільне сховище | Stateless |
| Відкликання | Просто (видалити запис) | Складно (потрібен blacklist) |
| Розмір cookie | Малий (тільки ID) | Більший токен |
| Найкраще для | Server-rendered додатки, адмінки | API, SPA, мікросервіси |
Коротко: якщо треба відкликати доступ миттєво (примусовий logout, бан), сесії виграють. Якщо будуєш розподілену систему або мікросервіси, де спільний store незручний, JWT простіший.
Типові помилки
Захардкоджений secret у коді
// Неправильно - один Git-коміт це розкриє
secret: 'keyboard cat'
// Правильно
secret: process.env.SESSION_SECRETЯкщо це потрапить у публічний репозиторій, зловмисник може перерахувати HMAC-підпис і підробити будь-який session cookie.
saveUninitialized: true
Старий дефолт. Такий режим створює запис сесії для кожного анонімного відвідувача і бота. MemoryStore швидко переповнюється, після чого настає OOM crash. Встанови false, і сесії будуть створюватись тільки після запису в req.session.
Немає спільного store у кластері
// Ламається в pm2 cluster mode або Docker з кількома репліками
app.use(session({ /* без опції store */ }));Кожен процес тримає сесії у власній пам'яті. Користувач логіниться на worker 1, наступний запит іде на worker 2 - сесія зникла. Перейди на connect-redis або connect-pg-simple.
secure: true у розробці
Cookie з secure: true надсилаються тільки через HTTPS. На localhost вони взагалі не встановлюються. Виправлення:
cookie: { secure: process.env.NODE_ENV === 'production' }Пропущений req.session.regenerate() після логіну
Session fixation (фіксація сесії): зловмисник заздалегідь встановлює відомий connect.sid, користувач логіниться, і тепер вони ділять ту саму сесію. Потрібно викликати req.session.regenerate() одразу після успішної автентифікації - це видає новий ID і копіює дані.
app.post('/login', async (req, res) => {
const user = await authenticate(req.body);
if (!user) return res.status(401).json({ error: 'Невірні дані' });
req.session.regenerate(err => { // Новий ID, захист від fixation
if (err) return res.status(500).json({ error: 'Помилка сесії' });
req.session.userId = user.id;
req.session.roles = user.roles;
res.json({ ok: true });
});
});Де зустрічається в реальних проектах
- Passport.js: зберігає тільки ID користувача в
req.session.passportчерезserializeUser, потім завантажує повний об'єкт черезdeserializeUser. - Socket.io: session middleware передається напряму в
io.use()для спільного використання сесії з Express. - Strapi / KeystoneJS: Redis-backed сесії для автентифікації в адмін-панелях.
- Next.js custom server: session middleware запускається перед
next()для захисту SSR-маршрутів.
Follow-up питання
Q: Що таке session fixation атака?
A: Зловмисник встановлює відомий connect.sid до того, як жертва логіниться. Після входу вони ділять ту саму сесію. Захист: req.session.regenerate() при кожній успішній автентифікації, це видає новий ID і копіює дані.
Q: Коли переходити з MemoryStore на Redis?
A: Щойно виходиш за межі розробки або одного процесу. У кластері MemoryStore ізолює кожен worker. На одному процесі при реальному трафіку він витікає в пам'яті. Redis вирішує обидві проблеми однією зміною.
Q: Як resave: false впливає на продуктивність?
A: Пропускає запис у store, якщо дані сесії не змінились за час запиту. У read-heavy застосунках це скорочує кількість записів у Redis або БД на 50-70%.
Q: Як відкликати сесії на всіх пристроях?
A: Зберігай Set з ID сесій у Redis для кожного userId. При логіні додавай новий session ID до Set. При logout-all видаляй усі записи та самі сесії. При кожному запиті перевіряй членство.
Q: sameSite: 'strict' чи 'lax' для захисту від CSRF?
A: 'strict' блокує cookie при будь-якому cross-site запиті, включно з переходами за посиланнями. 'lax' дозволяє при safe top-level навігації, але блокує cross-site POST. Більшість застосунків використовують 'lax' разом з CSRF-токенами для мутуючих ендпоінтів.
Приклади
Базовий потік: логін, захищений маршрут і вихід
const express = require('express');
const session = require('express-session');
const app = express();
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000 // 24 години
}
}));
app.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findOne({ email });
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return res.status(401).json({ error: 'Невірні дані' });
}
req.session.regenerate(err => {
if (err) return res.status(500).json({ error: 'Помилка сесії' });
req.session.userId = user.id;
req.session.roles = user.roles;
res.json({ ok: true });
});
});
app.get('/me', (req, res) => {
if (!req.session.userId) return res.status(401).json({ error: 'Не авторизовано' });
res.json({ userId: req.session.userId, roles: req.session.roles });
});
app.post('/logout', (req, res) => {
req.session.destroy(err => {
if (err) return res.status(500).json({ error: 'Помилка виходу' });
res.clearCookie('connect.sid');
res.json({ ok: true });
});
});Ключова деталь: req.session.regenerate() перед записом даних користувача. Якщо пропустити цей крок, session fixation атака спрацює.
Продакшен налаштування з Redis та auth middleware
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 86400000 }
}));
function requireAuth(req, res, next) {
if (!req.session.userId) return res.status(401).json({ error: 'Не авторизовано' });
next();
}
function requireRole(role) {
return (req, res, next) => {
if (!req.session.roles?.includes(role)) {
return res.status(403).json({ error: 'Доступ заборонено' });
}
next();
};
}
app.get('/admin', requireAuth, requireRole('admin'), (req, res) => {
res.json({ message: 'Адмін-панель' });
});Я бачив команди, які вписують if (!req.session.userId) в кожен маршрут замість окремого middleware. Це спрацьовує до моменту, коли логіку авторизації треба змінити в 40 місцях одночасно.
Відкликання сесій на всіх пристроях
// При логіні: реєструємо session ID в Redis Set для конкретного юзера
app.post('/login', async (req, res) => {
// ... автентифікація ...
req.session.regenerate(async err => {
if (err) return res.status(500).json({ error: 'Помилка' });
req.session.userId = user.id;
await redisClient.sAdd(`user:${user.id}:sessions`, req.session.id);
res.json({ ok: true });
});
});
// Вихід з усіх пристроїв: видаляємо всі сесії юзера
app.post('/logout-all', requireAuth, async (req, res) => {
const sessionIds = await redisClient.sMembers(`user:${req.session.userId}:sessions`);
for (const id of sessionIds) {
await redisClient.del(`sess:${id}`); // префікс ключа connect-redis
}
await redisClient.del(`user:${req.session.userId}:sessions`);
res.json({ ok: true });
});Цей патерн відповідає на класичне senior-питання з інтерв'ю напряму. Cookie засновані на ID, тому очищення cookie на одному пристрої прибирає тільки його ID. Для відкликання на всіх пристроях потрібен серверний трекінг.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.