X-Authorization. Когда Authorization заголовок уже занят

Ваше приложение принимает 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 без proxyAuthorization: BearerНужна архитектурная дисциплина
Enterprise с несколькими схемамиSpring SecurityFilterChainVerbose конфигурация

Как протестировать

Воспроизвести проблему локально проще, чем кажется:

# Имитируем что 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, или уйти на один механизм.

Если вы сталкивались с похожей проблемой - пишите в комментарии, интересно сравнить сценарии.