OSEN-НИЙ SAAALEСкидка 50% на виртуальный хостинг и VDS
до 30.11.2025 Подробнее
Выберите продукт

JWT в Nginx с njs: проверка HS256 и RS256 на реверс‑прокси

Практическое руководство для админов и DevOps: как валидировать JWT прямо на уровне Nginx с помощью njs. Разберём HS256 и RS256, сигнатуру и сроки, проверку iss/aud, интеграцию через auth_request, безопасную передачу клеймов в бэкенд, кеш JWKS и типичные ошибки.
JWT в Nginx с njs: проверка HS256 и RS256 на реверс‑прокси

Вынести первичную проверку 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 дальше — это важно, если приложение не готово доверять принятым извне заголовкам без повторной проверки.

Диаграмма: клиент — Nginx (njs) — API, проверка JWT и проброс клеймов

Как жить с RS256 в реальности: JWKS, kid и кеш

В промышленных системах публичные ключи RS256 распределяются через JWKS. В заголовке токена kid указывает, какой ключ использовать. Типовой цикл:

  1. При первом запросе с новым kid njs подтягивает JWKS у IdP через ngx.fetch (или запрашивает внутренний сервис), собирает публичный ключ SPKI/PEM и кладёт в память процесса с TTL.
  2. При последующих запросах берёт ключ из кеша и верифицирует локально.
  3. По TTL обновляет JWKS или по событию (SIGNAL/таймер/перезагрузка).

Детали реализации зависят от версии njs: наличие ngx.fetch, поддержка crypto.subtle, общих структур для кеша между воркерами. Простой и надёжный вариант — держать небольшой внутренний сервис‑интроспектор, а на njs оставить парсинг и базовые проверки сроков/аудитории, чтобы отсекать явный мусор до похода в интроспекцию.

Не забудьте про ротацию ключей: добавляйте новые ключи, не удаляя старые, пока все выданные токены не истекут. Так вы избежите отказов у клиентов, у которых токен подписан ещё старым ключом.

Виртуальный хостинг FastFox
Виртуальный хостинг для сайтов
Универсальное решение для создания и размещения сайтов любой сложности в Интернете от 95₽ / мес

Политика отказов и коды ответов

Рекомендуемые коды:

  • 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 — обязательно кешируйте ключи в памяти процесса; сетевые походы при каждом запросе недопустимы.

Схема JWKS: выбор ключа по kid и кеш публичных ключей в Nginx

Диагностика и эксплуатация

  • Включите error_log ... info на начале внедрения, затем снижайте до notice.
  • Метрики: ведите счётчики 2xx/401/403 на уровне локации, по необходимости добавьте раздельные локации и используйте парсер логов для построения дашбордов.
  • Готовьте план деградации: при недоступности JWKS/интроспекции — что важнее, отказ или временный пропуск (обычно отказ).
FastFox SSL
Надежные SSL-сертификаты
Мы предлагаем широкий спектр SSL-сертификатов от GlobalSign по самым низким ценам. Поможем с покупкой и установкой SSL бесплатно!

Типичные ошибки и анти‑паттерны

  • Алгоритм‑конфьюжн: разрешили оба 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 — добавьте доставку публичных ключей. Дальше можно наращивать авторизацию, троттлинг и кэширование, опираясь на аутентифицированную личность на уровне реверс‑прокси.

Поделиться статьей

Вам будет интересно

MetalLB и K3s на VDS: L2 vs BGP, healthchecks и устойчивые Service IP OpenAI Статья написана AI (GPT 5)

MetalLB и K3s на VDS: L2 vs BGP, healthchecks и устойчивые Service IP

Как получить стабильные публичные Service IP в K3s на VDS? Разбираем MetalLB в режимах layer2 и BGP: когда что выбирать, ограничен ...
ACME tls-alpn-01 и порт 80: как правильно выпустить SSL за обратным прокси OpenAI Статья написана AI (GPT 5)

ACME tls-alpn-01 и порт 80: как правильно выпустить SSL за обратным прокси

Многие путают tls-alpn-01 с http-01 и запускают проверку на 80-м — валидация падает. Разбираем, почему челлендж обязателен на 443 ...
TimescaleDB на VDS: hypertable, compression и retention для быстрых time series OpenAI Статья написана AI (GPT 5)

TimescaleDB на VDS: hypertable, compression и retention для быстрых time series

Поднимем TimescaleDB на VDS и выжмем максимум из time series. Создадим hypertable, подберём интервал чанка, включим compression и ...