Вынести первичную проверку JWT на реверс‑прокси — хороший способ разгрузить приложения, унифицировать аутентификацию и упростить аудит. У Nginx есть лёгкий JavaScript‑движок njs, который позволяет реализовать проверку токена прямо в конфигурации. В этой статье мы пройдём путь от устройства JWT и выбора алгоритма (HS256 vs RS256) до рабочего конфига Nginx с njs, разберём валидацию клеймов, ошибки и производительность.
JWT на практике: структура и выбор алгоритма
JWT — это компактный контейнер утверждений (клеймов), подписанный издателем. Он состоит из трёх частей в Base64url: заголовок, полезная нагрузка, подпись. Критические поля для валидации на прокси:
alg— алгоритм подписи (например,HS256илиRS256),iss— издатель (Issuer),aud— аудитория,exp,nbf,iat— сроки действия,sub— субъект (пользователь/сервис).
Два алгоритма, которые чаще всего встречаются на прикладном уровне:
- HS256 — HMAC с общим секретом. Плюсы: простота, производительность. Минусы: общий ключ должен быть известен всем проверяющим компонентам; сложнее ротация без даунтайма.
- RS256 — асимметричная подпись RSA. Плюсы: приватный ключ хранится только у Identity Provider; прокси и сервисы держат только публичный ключ; проще ротация через
kid/JWKS. Минусы: медленнее HMAC, немного сложнее в настройке.
Если у приложения несколько потребителей токенов (прокси, воркеры, бэкенды), RS256 обычно удобнее: приватный ключ остаётся у IdP, а публичные ключи можно кешировать на прокси.
Архитектура решения на Nginx + njs
Мы будем использовать связку auth_request и js_content: основной сервер делает субзапрос в локацию /_auth, где njs‑функция проверяет JWT. Если всё хорошо — возвращаем 200 и пробрасываем часть клеймов в заголовках для апстрима. Если нет — 401/403.
Установка njs
В большинстве дистрибутивов njs ставится пакетом динамического модуля. Примеры для популярных систем (проверьте названия пакетов в вашем репозитории):
# Debian/Ubuntu
apt update
apt install nginx-module-njs
# RHEL/Alma/Rocky (EPEL/NGINX repo)
yum install nginx-module-njs
# Проверить, что модуль доступен
nginx -V | tr ' ' '\n' | grep -i njs
Далее подключим модуль и импортируем наш файл с логикой:
load_module modules/ngx_http_js_module.so;
js_import jwt from /etc/nginx/njs/jwt.js;
Удобнее всё это собирать на собственном VDS: полный контроль над пакетами, модулями, конфигами и безопасным хранением секретов.
Базовый конфиг реверс‑прокси с проверкой JWT
worker_processes auto;
error_log /var/log/nginx/error.log info;
load_module modules/ngx_http_js_module.so;
js_import jwt from /etc/nginx/njs/jwt.js;
# Общие настройки и секреты
# Вариант: прокинуть переменные окружения с секретом HS256
# env JWT_HS256_SECRET;
http {
# Вспомогательные лимиты заголовков, чтобы безопасно принимать Authorization
large_client_header_buffers 4 16k;
# Значения, которые njs может читать через r.variables
map $http_host $jwt_expected_iss { default "example-issuer"; }
map $http_host $jwt_expected_aud { default "my-api"; }
upstream api_backend {
server 127.0.0.1:8080;
keepalive 64;
}
server {
listen 80;
server_name _;
# Точка аутентификации для auth_request
location = /_auth {
internal;
js_content jwt.auth;
}
# Основной трафик защищаем через auth_request
location / {
auth_request /_auth;
# Забираем клеймы из ответных заголовков субзапроса
auth_request_set $auth_sub $upstream_http_x_auth_sub;
auth_request_set $auth_scope $upstream_http_x_auth_scope;
# Пробрасываем в апстрим: без оригинального Authorization
proxy_set_header Authorization "";
proxy_set_header X-Auth-Sub $auth_sub;
proxy_set_header X-Auth-Scope $auth_scope;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://api_backend;
}
}
}
Логика njs: разбор, валидация, подписи HS256 и RS256
Ниже пример файла /etc/nginx/njs/jwt.js. Он:
- читает заголовок
Authorization: Bearer ..., - разбирает JWT, проверяет
exp/nbf/iatс учётом сдвига времени, - сверяет
iss/audс ожидаемыми, - поддерживает подписи
HS256иRS256(через WebCrypto SubtleCrypto), - возвращает
200и помещает выбранные клеймы в заголовки ответа субзапроса.
'use strict';
// Константы валидации. Можно подставлять через r.variables (см. ниже)
const CLOCK_SKEW_SEC = 60; // Допустимая рассинхронизация времени
// Публичный ключ для RS256 (SPKI/PEM). Положите сюда ваш ключ IdP.
// Пример ниже — заглушка, замените на реальный.
const RS256_PUB_PEM = '-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...\n-----END PUBLIC KEY-----\n';
// Base64url helpers
function b64urlToBytes(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
const pad = s.length % 4;
if (pad) s += '='.repeat(4 - pad);
const bin = atob(s);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
function bytesToB64url(buf) {
const bytes = new Uint8Array(buf);
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function utf8Bytes(s) {
// njs поддерживает TextEncoder
return new TextEncoder().encode(s).buffer;
}
function pemToSpkiBytes(pem) {
const body = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '');
return b64urlToBytes(body.replace(/\+/g, '-').replace(/\//g, '_'));
}
function constantTimeEq(aBuf, bBuf) {
const a = new Uint8Array(aBuf);
const b = new Uint8Array(bBuf);
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
async function verifyHS256(signingInput, signatureB64url, secretRaw) {
const key = await crypto.subtle.importKey(
'raw',
utf8Bytes(secretRaw),
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign']
);
const sig = await crypto.subtle.sign({ name: 'HMAC' }, key, utf8Bytes(signingInput));
const sigB64url = bytesToB64url(sig);
// Сравнение в постоянное время
return constantTimeEq(utf8Bytes(sigB64url), utf8Bytes(signatureB64url));
}
async function verifyRS256(signingInput, signatureB64url, pubPem) {
const key = await crypto.subtle.importKey(
'spki',
pemToSpkiBytes(pubPem),
{ name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' } },
false,
['verify']
);
const ok = await crypto.subtle.verify(
{ name: 'RSASSA-PKCS1-v1_5' },
key,
b64urlToBytes(signatureB64url),
utf8Bytes(signingInput)
);
return ok === true;
}
function nowSec() {
return Math.floor(Date.now() / 1000);
}
function parseAuthHeader(r) {
const h = r.headersIn.Authorization || r.headersIn.authorization;
if (!h) return null;
const m = /^Bearer\s+([^\s]+)$/.exec(h);
return m ? m[1] : null;
}
function parseJwt(token) {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Malformed JWT');
const [h, p, s] = parts;
const header = JSON.parse(new TextDecoder().decode(b64urlToBytes(h)));
const payload = JSON.parse(new TextDecoder().decode(b64urlToBytes(p)));
return { header, payload, signature: s, signingInput: h + '.' + p };
}
function validateClaims(r, payload) {
const issExpected = r.variables.jwt_expected_iss || 'example-issuer';
const audExpected = r.variables.jwt_expected_aud || 'my-api';
const now = nowSec();
if (payload.iss !== issExpected) throw new Error('bad iss');
// aud может быть строкой или массивом
if (Array.isArray(payload.aud)) {
if (!payload.aud.includes(audExpected)) throw new Error('bad aud');
} else if (payload.aud !== audExpected) {
throw new Error('bad aud');
}
if (typeof payload.exp === 'number' && now - CLOCK_SKEW_SEC > payload.exp) throw new Error('expired');
if (typeof payload.nbf === 'number' && now + CLOCK_SKEW_SEC < payload.nbf) throw new Error('not yet valid');
if (typeof payload.iat === 'number' && payload.iat > now + CLOCK_SKEW_SEC) throw new Error('bad iat');
}
async function auth(r) {
try {
const token = parseAuthHeader(r);
if (!token) {
r.return(401, 'missing bearer');
return;
}
const { header, payload, signature, signingInput } = parseJwt(token);
if (header.alg !== 'HS256' && header.alg !== 'RS256') {
r.return(400, 'unsupported alg');
return;
}
// Проверка подписи
let ok = false;
if (header.alg === 'HS256') {
const secret = r.variables.jwt_hs256_secret || r.variables.env_JWT_HS256_SECRET || '';
if (!secret) {
r.error('HS256 selected but no secret configured');
r.return(500, 'misconfigured');
return;
}
ok = await verifyHS256(signingInput, signature, secret);
} else if (header.alg === 'RS256') {
// Для продакшна: используйте JWKS и кеш по kid. Здесь — статический PEM для простоты.
ok = await verifyRS256(signingInput, signature, RS256_PUB_PEM);
}
if (!ok) {
r.return(401, 'bad signature');
return;
}
// Клеймы
validateClaims(r, payload);
// Проброс минимально необходимого набора атрибутов
const sub = String(payload.sub || '');
const scope = Array.isArray(payload.scope) ? payload.scope.join(' ') : String(payload.scope || '');
r.headersOut['X-Auth-Sub'] = sub;
r.headersOut['X-Auth-Scope'] = scope;
r.return(200);
} catch (e) {
r.warn('JWT auth error: ' + e.message);
r.return(401, 'unauthorized');
}
}
export default { auth };
Минимальный путь к продакшену: заменить заглушечный RS256_PUB_PEM на реальный публичный ключ IdP (или организовать загрузку JWKS), задать секрет для HS256 через переменную окружения и директиву env в nginx.conf, определить ожидаемые iss/aud.
Проверка и отладка
Включите error_log ... info, чтобы видеть логи njs. Дальше — тесты. Для начала попробуйте запрос без токена и с фиктивным токеном:
# Без токена: ожидаем 401
curl -i http://127.0.0.1/echo
# С токеном с неверной подписью: ожидаем 401
curl -i -H "Authorization: Bearer eyJhbGciOi..." http://127.0.0.1/echo
Когда появится валидный токен, проверьте, что до апстрима доходят заголовки X-Auth-Sub, X-Auth-Scope. Убедитесь, что вы не пробрасываете исходный Authorization дальше — это важно, если приложение не готово доверять принятым извне заголовкам без повторной проверки.

Как жить с RS256 в реальности: JWKS, kid и кеш
В промышленных системах публичные ключи RS256 распределяются через JWKS. В заголовке токена kid указывает, какой ключ использовать. Типовой цикл:
- При первом запросе с новым
kidnjs подтягивает JWKS у IdP черезngx.fetch(или запрашивает внутренний сервис), собирает публичный ключ SPKI/PEM и кладёт в память процесса с TTL. - При последующих запросах берёт ключ из кеша и верифицирует локально.
- По TTL обновляет JWKS или по событию (SIGNAL/таймер/перезагрузка).
Детали реализации зависят от версии njs: наличие ngx.fetch, поддержка crypto.subtle, общих структур для кеша между воркерами. Простой и надёжный вариант — держать небольшой внутренний сервис‑интроспектор, а на njs оставить парсинг и базовые проверки сроков/аудитории, чтобы отсекать явный мусор до похода в интроспекцию.
Не забудьте про ротацию ключей: добавляйте новые ключи, не удаляя старые, пока все выданные токены не истекут. Так вы избежите отказов у клиентов, у которых токен подписан ещё старым ключом.
Политика отказов и коды ответов
Рекомендуемые коды:
401 Unauthorized— нет токена, подпись не сошлась, истёк срок действия;403 Forbidden— токен валиден, но не подходит по политике доступа (например, нет нужногоscopeили роль не разрешена для данного пути).
Если делаете авторизацию на уровне Nginx (RBAC/route‑scopes), применяйте явные карты и списки допуска, а не произвольные выражения в njs. Пример: проверка на уровне локации с помощью map/if над значением $auth_scope из субзапроса.
Безопасность: что обязательно проверить
- Алгоритм: явно разрешайте только нужные (
HS256,RS256). Запретитеnoneи все прочие. - Клеймы:
iss,aud,exp,nbf,iat. Используйте небольшойCLOCK_SKEW(30–120 секунд) на рассинхронизацию часов. - Размеры: ограничьте размер заголовков, не принимайте чрезмерно большие JWT. Проверьте
large_client_header_buffers. - Логи: не логируйте весь токен, чтобы не засорять логи чувствительными данными. Логируйте только
kid,iss,aud,subи причину отказа. - Очистка заголовков: не пропускайте исходный
Authorizationдо бэкенда, если приложение не должно самостоятельно повторно валидировать его. - TLS/HTTPS: включите HTTPS и корректный сертификат на фронте; при необходимости оформите SSL-сертификаты.
- JWT‑подмена в кэше: если используете кэш ответов для API, убедитесь, что ключ кэша учитывает аутентифицированную личность (
$auth_sub/хэш токена).
Производительность и стабильность
HS256 быстрее RS256: для высокочастотных эндпоинтов используйте HMAC, если модель доверия позволяет хранить секрет на прокси. Для RS256 закладывайте небольшую CPU‑дельту. Nginx‑воркеры масштабируются горизонтально — держите количество воркеров пропорциональным ядрам. Следите за GC njs и не аллоцируйте лишние объекты в горячем пути. Если планируете JWKS — обязательно кешируйте ключи в памяти процесса; сетевые походы при каждом запросе недопустимы.

Диагностика и эксплуатация
- Включите
error_log ... infoна начале внедрения, затем снижайте доnotice. - Метрики: ведите счётчики 2xx/401/403 на уровне локации, по необходимости добавьте раздельные локации и используйте парсер логов для построения дашбордов.
- Готовьте план деградации: при недоступности JWKS/интроспекции — что важнее, отказ или временный пропуск (обычно отказ).
Типичные ошибки и анти‑паттерны
- Алгоритм‑конфьюжн: разрешили оба HS256/RS256, но для RS256 используете общий секрет — уязвимость. Разделяйте конфигурации или проверяйте
kid/iss. - Нулевой
exp: принимаете бессрочные токены — риск. Требуйте ограниченный срок жизни. - Отсутствие аудита: нет логов причин отказа — сложно расследовать инциденты.
- JWKS без кеша: сетевой вызов на каждый токен приводит к отказам и задержкам.
- Пропуск оригинального
Authorizationв бэкенд: двойная логика аутентификации и расхождения в политике.
Расширение: авторизация на маршрутах
Проверку наличия конкретных scope или ролей можно оставить приложению, а на прокси лишь передавать их. Если всё же хотите часть правил выполнить в Nginx, используйте значения, полученные из auth_request, и простые мапы:
map $auth_scope $can_write { default 0; ~\bwrite\b 1; }
location /write/ {
auth_request /_auth;
if ($can_write = 0) { return 403; }
proxy_pass http://api_backend;
}
Пошаговый чек‑лист перед выкладкой
- Уточнили версию njs и наличие
crypto.subtleна вашей сборке. - Определили и прописали
iss/aud. - Реализовали проверку
exp/nbf/iatиCLOCK_SKEW. - Ограничили заголовки клиента и не отдаёте
Authorizationв апстрим. - Для RS256 — подготовили доставку публичных ключей (JWKS/статический PEM) и кеш ключей.
- Для HS256 — безопасно завели секрет на прокси (переменные окружения, права на файл).
- Наладили логи и алерты по доле 401/403.
FAQ
Можно ли полностью заменить приложение валидацией на Nginx?
Для аутентификации — да, часто достаточно. Для авторизации по бизнес‑правилам — лучше оставить в приложении, а на прокси ограничиться базовыми проверками и передачей клеймов.
Что если в вашей сборке njs нет crypto.subtle?
Оставьте в njs парсинг токена и валидацию клеймов по времени, а проверку подписи делегируйте на внутренний сервис интроспекции через auth_request или proxy_pass. Либо обновите njs до версии с WebCrypto.
Как безопасно хранить секрет для HS256?
Через переменные окружения процесса Nginx с директивой env и минимальными правами к системному юзеру. Не храните секрет в открытом виде в VCS.
Как реализовать ротацию ключей RS256?
Через kid в заголовке JWT и JWKS на IdP. На прокси держите кеш ключей по kid, поддерживайте одновременное наличие старых и новых ключей до естественного истечения токенов.
Если вы подбираете удобную панель управления для сервера, пригодится обзор: Сравнение панелей управления VDS в 2025.
Итог: njs позволяет поднять надёжную первичную проверку JWT у границы. Для HS256 хватит нескольких строк кода, для RS256 — добавьте доставку публичных ключей. Дальше можно наращивать авторизацию, троттлинг и кэширование, опираясь на аутентифицированную личность на уровне реверс‑прокси.


