Ваше приложение принимает JWT Bearer, впереди стоит Nginx с Basic Auth, и оба хотят использовать один и тот же Authorization заголовок. RFC 7235 говорит, что можно передать только одно значение, сервер молча перезаписывает одно другим - и ваш JWT теряется где-то в пути. Именно это случилось у меня в geo-monitor.
Откуда вырастает проблема
В geo-monitor архитектура выглядела просто: Nginx принимает трафик, проверяет Basic Auth, проксирует на Node.js приложение, которое верифицирует JWT. Клиент отправлял Bearer токен, а до приложения доходило Authorization: Basic dXNlcjpwYXNz.
Происходит это потому, что RFC 7235 отводит под заголовок Authorization единственный слот с единственной схемой. Basic и Bearer - это две разные схемы, их нельзя передать одновременно в одном заголовке. RFC 7230 раздел 3.2.2 требует, чтобы если отправляются несколько заголовков с одинаковым именем, их значение было списком, разделённым запятыми - либо заголовок был явным исключением (как Set-Cookie). Большинство серверов просто перезаписывают первое значение вторым, без ошибки и без предупреждения. Ошибка проявляется уже на слое аутентификации, когда JWT исчез.
Это не теоретическая проблема. В GitHub issues Kong (#12692), Apache APISIX (#10247, #8521) и Envoy встречается один и тот же сценарий: Basic Auth на уровне прокси конфликтует с JWT в приложении. В Envoy некоторое время поведение было ещё интереснее - он конкатенировал два значения через запятую, и JWT парсер ломался на этой запятой (исправлено в PR #32365).
Почему очевидные варианты не работают
Первое, что приходит в голову: отправлять два Authorization заголовка. RFC запрещает. Большинство серверов ведут себя непредсказуемо, положиться на это нельзя.
Второй вариант: переключить JWT на query-параметр, например ?token=eyJ0.... Работает, но любой proxy log, WAF или CDN теперь видит токен в URL. Прямая утечка credentials в логи.
Третий вариант: перенастроить Nginx так, чтобы он не трогал Authorization и делал Basic Auth другим способом. Теоретически возможно, но если Nginx под чужим управлением или его конфигурацию менять нельзя - не подойдёт.
Паттерн X-Authorization
Решение, которое сработало в geo-monitor: JWT передаётся в кастомном заголовке X-Authorization, Basic Auth остаётся в стандартном Authorization. Nginx занимается своим делом с Authorization: Basic, до приложения доходит X-Authorization: Bearer eyJ0..., и конфликта нет.
Со стороны клиента:
curl -H "Authorization: Basic dXNlcjpwYXNz" \
-H "X-Authorization: Bearer eyJ0..." \
http://api/profile
Passport.js делает это удобным через ExtractJwt.fromExtractors() - экстракторы пробуются по очереди, возвращается первый успешный. Кастомный экстрактор для geo-monitor:
const flexibleJwtExtractor = (req) => {
// Сначала смотрим в X-Authorization
if (req.headers['x-authorization']) {
return req.headers['x-authorization'].replace(/^Bearer\s+/, '');
}
// Если не нашли - проверяем стандартный Bearer
if (req.headers.authorization?.startsWith('Bearer ')) {
return req.headers.authorization.substring(7);
}
return null;
};
Регистрируется в конфиге стратегии:
passport.use(new JwtStrategy({
jwtFromRequest: flexibleJwtExtractor,
secretOrKey: process.env.JWT_SECRET
}, verifyCallback));
Пять этапов уточнения
В geo-monitor я решил эту проблему за пять итераций с Claude - не за одну и не за двадцать.
Первый запрос был размытым: “как обойти конфликт между Basic Auth и JWT в Passport.js?”. Получил объяснение механики и вариант с fromExtractors() - я не знал, что эта опция вообще существует.
Второй уточнил контекст: “у меня Nginx делает Basic Auth, покажи конфигурацию для proxy и Node.js”. Получил конкретные примеры с proxy_pass_header и кастомным заголовком.
Третий был про тестирование локально - нужен был способ воспроизвести схему без поднятия реального Nginx. Вышел на эмуляцию заголовков через curl.
Четвёртый - про edge cases: что если клиент отправит оба заголовка, или x-authorization придёт без Bearer префикса? Здесь поправили экстрактор под реальное поведение.
Пятый - про логирование: добавил req.authMethod в middleware. Это оказалось полезным для дебага позже, когда несколько клиентов неправильно собирали заголовки.
Каждый следующий запрос был точнее предыдущего - за счёт накопленного контекста, а не за счёт AI.
Когда это подходит, а когда нет
X-Authorization хорошо работает в конкретных сценариях:
- Перед приложением стоит proxy, который уже занял
Authorizationдля своих нужд - Нужна обратная совместимость с legacy-клиентами на Basic Auth при поддержке JWT
- Хочется явного разделения: Basic Auth на уровне proxy, JWT на уровне приложения
Когда не стоит идти этим путём: новый API без Legacy-ограничений. RFC 6648 (BCP 178) рекомендует отказаться от X- префикса для новых кастомных заголовков - это соглашение считается устаревшим. Для нового API лучше сразу проектировать так, чтобы конфликта не возникало.
| Сценарий | Решение | Главный минус |
|---|---|---|
| Legacy Nginx + JWT приложение | X-Authorization | Кастомный заголовок, требует договорённости |
| Новый API без proxy | Authorization: Bearer | Нужна архитектурная дисциплина |
| Enterprise с несколькими схемами | Spring SecurityFilterChain | Verbose конфигурация |
Как протестировать
Воспроизвести проблему локально проще, чем кажется:
# Имитируем что Nginx перезаписал Authorization
curl -H "Authorization: Basic dXNlcjpwYXNz" http://localhost:3000/api/profile
# → 401, JWT не найден
# Отправляем JWT в X-Authorization
curl -H "Authorization: Basic dXNlcjpwYXNz" \
-H "X-Authorization: Bearer eyJ0..." \
http://localhost:3000/api/profile
# → 200, JWT найден в x-authorization
Полезно добавить логирование в middleware - писать в консоль какой метод аутентификации сработал. В geo-monitor это помогло поймать несколько случаев, когда клиент неправильно собирал заголовки.
Что вынес из geo-monitor
Конфликты авторизационных заголовков - это не экзотика. Kong, APISIX, Envoy все наступили на эти грабли в production. X-Authorization паттерн работает, он простой, его можно внедрить за несколько часов без переписывания архитектуры.
Но это временное решение. Если есть возможность спроектировать систему заново - лучше сразу сделать так, чтобы конфликта не было: отдельный endpoint для Basic Auth, отдельный для JWT, или уйти на один механизм.
Если вы сталкивались с похожей проблемой - пишите в комментарии, интересно сравнить сценарии.