Суть проблемы
Бэкенд на Express с JWT-авторизацией. Фронтенд стабильно жаловался: «то 401, то 403, то 500, то 404» — без какой-либо системы. Логи Sentry выглядели как белый шум: половина 500-х не имела тела ответа, часть 401 приходила на запросы с валидным токеном, а существующие роуты периодически отдавали 404. Ниже — конкретные симптомы, которые воспроизвелись на проде.
- !401 Unauthorized на запросах с валидным токеномТокен в localStorage есть, но часть запросов отклоняется. Причем один и тот же эндпоинт то работает, то нет — зависело от того, через какой baseURL шёл запрос.
- !403 Forbidden для залогиненного пользователяПользователь авторизован, токен принят, но действие всё равно запрещено. CORS-preflight на DELETE-запросах тоже падал с 403.
- !500 Internal Server Error без телаСлучайные падения на эндпоинтах с async-обработчиками. Ответ приходил пустым, в логах — unhandledRejection. Понять причину по ответу было невозможно.
- !404 Not Found на существующих роутахРоут объявлен в роутере, но клиент стабильно получал 404. На часть методов работало, на часть — нет.
Мешанина кодов: 401 на валидных токенах, 403 без объяснения, 500 без тела, 404 на живых роутах. Фронтенд не мог отличить «протух токен» от «нет прав» от «сервер упал».
Каждый статус несёт точную семантику: 401 — проблема аутентификации с понятным сообщением, 403 — недостаток прав, 500 — логируемая ошибка с correlation id, 404 — только на несуществующих ресурсах.
Как диагностировал
Разбирал каждый код отдельно — причины у них разные, и лечение симптомов единым «улучшшим middleware» только маскировало бы корневые проблемы. Ниже сводная таблица, затем пошаговая логика диагноза.
jwt.verify, единый формат заголовкаcredentials: trueasyncHandler-обёртка + глобальный error-handler middlewareПошаговый диагноз
- Снял логи и сгруппировал по статусамПодключился к прод-логам через
kubectl logs, отфильтровал поres.statusCode. Разделил поток на четыре корзины — каждая корзина имела свой «отпечаток»: 401 концентрировался на запросах с одним из двух baseURL, 500 — на async-эндпоинтах, 404 — на методах DELETE и PUT. - Вскрыл auth middlewareВ
auth.middleware.jsвызовjwt.verifyстоял без try/catch. Если токен протух — выбрасывалсяTokenExpiredError, который улетал выше и превращался в 500. Отдельно: парсинг заголовка делался черезsplit(' ')[1], который падал на лишнем пробеле"Bearer eyJ..."и тоже давал 500. Различения «нет токена вообще» и «токен невалиден» не было. - Проверил отправку токена на фронтеОказалось, что один из baseURL (
/api/v1) шёл через axios-инстанс с интерцептором, добавляющимAuthorization, а второй (/api/v2) — через fetch без заголовка. Поэтому часть запросов получала 401 по законной причине, но это маскировало остальные баги. - Посмотрел проверку правПроверки ownership не было вовсе:
DELETE /api/posts/:idудалял любой авторизованный пользователь любой пост. Роли проверялись черезreq.user.role === 'admin', но в токене поле называлосьrolesи было массивом — сравнение всегда давало false, и админ получал 403 на админских эндпоинтах. - Проанализировал CORS-preflight
app.use(cors())вызывался без опций. На запросах сcredentials: 'include'браузер слал preflightOPTIONS, ноAccess-Control-Allow-Credentialsне выставлялся — браузер блокировал ответ, а в логах это выглядело как 403 от сервера. - Нашёл источник 500 без телаAsync-обработчики вида
async (req, res) => { ... }бросали reject, который Express 4.x не ловит автоматически. Глобального error-handler middleware не было вовсе. В одном месте обращались кreq.user.idв маршруте без auth middleware —Cannot read property 'id' of undefinedуходил в 500-пустышку. - Разобрал порядок роутовВ
app.jscatch-allapp.use('*', notFoundHandler)стоял до подключения бизнес-роутеров черезapp.use('/api', routes). Любой запрос, не совпавший с ранее зарегистрированными, уходил в 404, даже если роут был объявлен — просто ниже по файлу. Плюс часть роутов регистрировалась черезrouter.get, а клиент слалPOST.
Что исправил
Каждый код лечился отдельным коммитом, чтобы можно было откатить точечно. Ниже — пары «было/стало» по всем четырём классам ошибок.
401 — корректный auth middleware
Главная проблема: jwt.verify не ловился, протухший токен нельзя было отличить от отсутствующего, а парсинг падал на лишних пробелах. Переписал middleware с явной обработкой трёх случаев.
// протухший токен выбрасывает TokenExpiredError
// и улетает в 500 без try/catch
function auth(req, res, next) {
const token = req.headers.authorization.split(' ')[1]; // падает на "Bearer eyJ..."
const payload = jwt.verify(token, SECRET); // выброс при expiry
req.user = payload;
next();
}
// единый формат ошибки отсутствовал
function notFound(req, res) {
res.status(404).send('Not found');
}
function auth(req, res, next) {
const header = req.headers.authorization || '';
// 1. Токена нет совсем — 401 с понятным сообщением
if (!header.startsWith('Bearer ')) {
return res.status(401).json({
error: 'unauthorized',
message: 'Отсутствует заголовок Authorization: Bearer <token>',
});
}
// trim защищает от "Bearer eyJ..." (двойной пробел)
const token = header.slice(7).trim();
try {
req.user = jwt.verify(token, SECRET);
next();
} catch (err) {
// 2. Различаем протухший и просто невалидный
const code = err.name === 'TokenExpiredError'
? 'token_expired'
: 'token_invalid';
return res.status(401).json({
error: 'unauthorized',
code,
message: err.message,
});
}
}
На фронте выровнял оба baseURL на единый axios-инстанс с интерцептором, гарантированно добавляющим заголовок. Это убрало «законные» 401 и обнажило реальные баги прав и роутов.
403 — проверка прав и CORS с credentials
Удалять чужой ресурс мог любой залогиненный. Проверка ролей сравнивала строку с массивом. CORS не отдавал Allow-Credentials. Починил всё разом.
// ownership НЕ проверяется — любой юзер удаляет любой пост
router.delete('/:id', auth, async (req, res) => {
await Post.destroy({ where: { id: req.params.id } });
res.status(204).end();
});
// роли: сравниваем строку с массивом — всегда false
function adminOnly(req, res, next) {
if (req.user.role === 'admin') next(); // req.user.roles === ['admin']
else res.status(403).send('Forbidden');
}
// проверяем владельца ресурса — 403 если не хозяин и не админ
router.delete('/:id', auth, async (req, res, next) => {
const post = await Post.findByPk(req.params.id);
if (!post) return res.status(404).json({ error: 'not_found' });
const isOwner = post.userId === req.user.id;
const isAdmin = req.user.roles?.includes('admin');
if (!isOwner && !isAdmin) {
return res.status(403).json({
error: 'forbidden',
message: 'Недостаточно прав для удаления этого ресурса',
});
}
await post.destroy();
res.status(204).end();
});
// роли: проверяем по массиву roles[]
function requireRole(...roles) {
return (req, res, next) => {
const has = roles.some(r => req.user.roles?.includes(r));
if (!has) {
return res.status(403).json({
error: 'forbidden',
message: `Требуется одна из ролей: ${roles.join(', ')}`,
});
}
next();
};
}
// cors без опций — credentials не выставляется
// браузер блокирует preflight → выглядит как 403
app.use(cors());
app.use(express.json());
app.use(cors({
origin: [process.env.FRONT_URL, process.env.ADMIN_URL],
credentials: true, // Allow-Credentials для cookie/Authorization
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}));
app.use(express.json());
500 — asyncHandler и глобальный error-handler
Корневая причина — Express 4.x не ловит rejected promises из async-обработчиков автоматически. Введена обёртка asyncHandler и единый error-handler, который логирует ошибку с correlation id и отдаёт структурированный ответ.
// reject улетает в unhandledRejection → 500 без тела
router.get('/profile', auth, async (req, res) => {
const profile = await Profile.findOne({ where: { userId: req.user.id } });
// если profile === null:
res.json(profile.settings.theme); // Cannot read property 'settings' of null
});
// глобального error-handler нет вовсе
// обёртка: пробрасывает async-ошибки в error-handler
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
// глобальный error-handler — регистрируется ПОСЛЕ всех роутов
function errorHandler(err, req, res, next) {
const status = err.status || 500;
const correlationId = req.id || crypto.randomUuid();
logger.error({
correlationId,
status,
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
return res.status(status).json({
error: status >= 500 ? 'internal_error' : err.code || 'error',
message: status >= 500
? 'Внутренняя ошибка сервера. Уже чиним.'
: err.message,
correlationId, // клиент шлёт его в поддержку — ищем в логах за секунды
});
}
module.exports = errorHandler;
Обработчики переписаны через asyncHandler, а на потенциально-null поля добавлены проверки, возвращающие осмысленный 404 вместо 500.
404 — порядок роутов и явные методы
Catch-all стоял выше бизнес-роутера. Часть методов была зарегистрирована как GET, а клиент слал POST. Выстроил порядок и привёл методы в соответствие контракту.
// catch-all стоит ДО бизнес-роутов → всё уходит в 404
app.use('*', (req, res) => {
res.status(404).send('Not found');
});
app.use('/api', routes); // никогда не срабатывает
// в роутере метод не тот
router.get('/login', loginHandler); // клиент шлёт POST
// 1. Бизнес-роуты — первыми
app.use('/api', routes);
// 2. CORS-preflight для неизвестных путей
app.options('*', cors());
// 3. Catch-all — последним, отдаёт структурированный 404
app.use((req, res) => {
res.status(404).json({
error: 'not_found',
message: `Маршрут ${req.method} ${req.path} не найден`,
});
});
// 4. Глобальный error-handler — самым последним
app.use(errorHandler);
// в роутере методы приведены в соответствие контракту
router.post('/login', asyncHandler(loginHandler));
Результат
После выкатки поочерёдно (auth → права → error-handler → роуты) каждый класс ошибок сошёл к нулю. Метрики снял за неделю до и неделю после релиза.
Каждый статус теперь несёт точную семантику: 401 — проблема аутентификации с кодом token_expired или token_invalid, 403 — недостаток прав с указанием требуемых ролей, 500 — логируемая ошибка с correlation id, 404 — только на несуществующих ресурсах. Фронтенд различает сценарии и корректно предлагает перевыпуск токена при 401, а не молча разлогинивает.