В этом уроке мы разберём, как на Lua делается то, ради чего люди и берут OpenResty вместо ванильного nginx: динамическая маршрутизация по данным из Redis прямо в balancer_by_lua, авторизация по JWT на access-фазе (с обязательным аудитом безопасности - тут легко прострелить себе ногу), многоуровневый кэш JWKS и решений, и аккуратная работа с Redis через пул соединений. Это и есть тот самый openresty nginx lua api gateway, который держит десятки тысяч rps на одной ноде.
Авторизация на access-фазе: где и как валидировать JWT
Первое правило: токен проверяется на фазе access, ДО того как запрос ушёл в proxy_pass. Это естественная точка отсечения - если токен невалиден, мы возвращаем 401 и не тратим бэкенд. Фаза access вызывается после rewrite и до content, и в ней доступны cosockets, то есть можно ходить в сеть.
Самый частый паттерн openresty авторизация - директива access_by_lua_block в нужной локации. Достаём токен, парсим, проверяем подпись и claims:
Код: Выделить всё
location /api/ {
access_by_lua_block {
local auth = ngx.var.http_authorization
if not auth then
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
local _, _, token = string.find(auth, "Bearer%s+(.+)")
if not token then
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
-- дальше проверка токена (см. ниже)
ngx.ctx.token = token
}
proxy_pass http://backend;
}
Теперь самое важное - КАК проверять подпись, потому что именно здесь в 2026 чаще всего и пробивают шлюзы.

openresty jwt без дыр: аудит alg-confusion и почему голый lua-resty-jwt опасен
Классическая библиотека для openresty jwt - lua-resty-jwt. Она работает, но имеет историю уязвимостей, и пользоваться ей наивно нельзя. Две беды.
Беда первая - алгоритм none. Спецификация JWT разрешает alg: "none" для неподписанных токенов. Если библиотека не отвергает его явно, атакующий снимает подпись, ставит в заголовок alg:none и получает доверенный токен с любыми claims. Хуже того, наивный фильтр строки "none" обходится регистром: nOnE, NONE, None пройдут, если ты не приводишь к нижнему регистру.
Беда вторая, опаснее - alg-confusion (key confusion). Сервер ждёт RS256 (асимметрика, проверка ПУБЛИЧНЫМ ключом). Атакующий берёт твой публичный RSA-ключ (он публичный, его легко достать) и подписывает токен алгоритмом HS256, используя текст этого публичного ключа как HMAC-секрет. Если код просто говорит "проверь этим ключом", не фиксируя алгоритм, библиотека увидит alg:HS256, возьмёт публичный ключ как секрет HMAC - и подпись сойдётся. У lua-resty-jwt по умолчанию alg_whitelist равен nil, то есть НИКАКОЙ фильтрации алгоритма нет. Это включённый по умолчанию режим выстрела в ногу.
Правило железное: НИКОГДА не доверяй полю alg из заголовка токена. Алгоритм задаёшь ТЫ на стороне сервера, исходя из типа ключа, и любой токен с другим alg отвергаешь. На lua-resty-jwt это выглядит так:
Код: Выделить всё
local jwt = require "resty.jwt"
local jwt_validators = require "resty.jwt-validators"
-- фиксируем разрешённый алгоритм заранее, не из токена
local claim_spec = {
alg = jwt_validators.equals("RS256"),
exp = jwt_validators.is_not_expired(),
iss = jwt_validators.equals("https://auth.cyberlake.ru/"),
aud = jwt_validators.equals("api.cyberlake.ru"),
}
local public_key = ngx.shared.jwks_cache:get("rsa_pub")
local verified = jwt:verify(public_key, token, claim_spec)
if not verified.verified then
ngx.log(ngx.WARN, "jwt rejected: ", verified.reason)
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
ngx.ctx.claims = verified.payload
Но вот честный совет на 2026: если у тебя полноценный OAuth2/OIDC (Keycloak, Authentik, Auth0, Zitadel), не собирай велосипед на голом lua-resty-jwt. Де-факто стандарт - lua-resty-openidc. Это реализация OIDC Relying Party и OAuth2 Resource Server: он сам тянет и кэширует JWKS по jwks_uri из discovery-документа, проверяет подпись правильным ключом по kid, умеет token introspection для непрозрачных токенов, ведёт сессии. Тебе не надо вручную рулить ротацией ключей.
Код: Выделить всё
location /api/ {
access_by_lua_block {
local openidc = require "resty.openidc"
local opts = {
discovery = "https://auth.cyberlake.ru/.well-known/openid-configuration",
-- для Resource Server: проверяем access-токен, без редиректов
accept_none_alg = false,
accept_unsupported_alg = false,
}
local res, err = openidc.bearer_jwt_verify(opts)
if err or not res then
ngx.log(ngx.WARN, "oidc verify failed: ", err)
return ngx.exit(ngx.HTTP_UNAUTHORIZED)
end
ngx.req.set_header("X-User-Sub", res.sub)
ngx.ctx.claims = res
}
proxy_pass http://backend;
}
nginx lua redis и пул соединений: динамическая маршрутизация без reload
Теперь nginx lua redis. Зачем Redis в шлюзе: фиче-флаги, карта маршрутов (какой клиент на какой апстрим), блок-листы, счётчики. Главное правило cosocket - соединение ОБЯЗАТЕЛЬНО возвращать в пул через set_keepalive, иначе на каждый запрос будет новый TCP-хендшейк и Redis захлебнётся в коннектах. И помни: пул per-worker, соединение нельзя сохранять в переменную уровня модуля между запросами - бери из пула в начале и возвращай в конце.
Код: Выделить всё
local function get_route(client_id)
local redis = require "resty.redis"
local red = redis:new()
red:set_timeouts(200, 200, 200) -- connect, send, read - в мс
local ok, err = red:connect("127.0.0.1", 6379)
if not ok then
ngx.log(ngx.ERR, "redis connect: ", err)
return nil
end
local upstream, rerr = red:get("route:" .. client_id)
if not upstream then
ngx.log(ngx.ERR, "redis get: ", rerr)
-- ВАЖНО: при ошибке НЕ зови set_keepalive, закрой соединение
red:close()
return nil
end
-- вернуть в пул: idle 10s, размер пула 100
red:set_keepalive(10000, 100)
if upstream == ngx.null then return nil end
return upstream
end
А теперь динамическая маршрутизация. Выбор апстрима из Redis делается в balancer_by_lua - это специальная фаза, где Lua сама назначает пир. Апстрим объявляется пустым с директивой balancer, реальный адрес ставит ngx.balancer.set_current_peer:
Код: Выделить всё
upstream dynamic_backend {
server 0.0.0.1; -- плейсхолдер, реальный адрес задаёт Lua
balancer_by_lua_block {
local balancer = require "ngx.balancer"
local addr = ngx.ctx.target_addr
local host, port = addr:match("([^:]+):(%d+)")
local ok, err = balancer.set_current_peer(host, tonumber(port))
if not ok then
ngx.log(ngx.ERR, "set_current_peer: ", err)
return ngx.exit(502)
end
}
}
openresty rate limit, A/B и трансформация: гибкость Lua поверх limit_req
Базовый limit_req из прошлых уроков прекрасен, но жёсткий: ключ и скорость зашиты в конфиг. Когда нужно openresty rate limit с лимитом, зависящим от тарифа клиента из JWT, или с разными политиками на разные ручки - берут lua-resty-limit-traffic. Важное предупреждение на 2026: эта библиотека официально помечена как highly experimental, её API может меняться между версиями. В прод бери осознанно и пинуй версию.
Код: Выделить всё
local limit_req = require "resty.limit.req"
-- rate=200 r/s, burst=100; счётчик в shared dict
local lim = limit_req.new("my_limit_zone", 200, 100)
local key = ngx.ctx.claims and ngx.ctx.claims.sub or ngx.var.binary_remote_addr
local delay, err = lim:incoming(key, true)
if not delay then
if err == "rejected" then
return ngx.exit(429)
end
ngx.log(ngx.ERR, "limit err: ", err)
else
if delay > 0 then ngx.sleep(delay) end -- сглаживаем всплеск
end
Трансформация и A/B на Lua тоже тривиальны. Хочешь канареечный роутинг 10% трафика на новую версию:
Код: Выделить всё
access_by_lua_block {
local bucket = ngx.crc32_short(ngx.var.binary_remote_addr) % 100
if bucket < 10 then
ngx.ctx.target_addr = "10.0.0.50:8080" -- canary
ngx.req.set_header("X-Variant", "canary")
else
ngx.ctx.target_addr = "10.0.0.10:8080" -- stable
ngx.req.set_header("X-Variant", "stable")
end
}
Многоуровневый кэш lua-resty-mlcache: L1/L2/L3 и защита от dog-pile
Вот сердце производительного шлюза. JWKS, маршруты, фиче-флаги - данные, которые читаются на каждом запросе и меняются редко. Тащить их в Redis или к auth-серверу каждый раз - расточительство. Решение - lua-resty-mlcache, трёхуровневый кэш.
- L1 - lua-resty-lrucache, живёт в памяти Lua VM конкретного воркера. Доступ без сериализации, нанонсекунды. Но у каждого воркера свой L1.
- L2 - lua_shared_dict, общий для всех воркеров одной ноды. Данные сериализуются, но один воркер прогрел - остальные читают из общей памяти.
- L3 - сам callback (поход в Redis/HTTP/БД). Вызывается только при промахе L1 и L2.
Код: Выделить всё
local mlcache = require "resty.mlcache"
local cache = mlcache.new("jwks_cache", "mlcache_shm", {
lru_size = 1000, -- размер L1
ttl = 3600, -- свежесть 1 час
neg_ttl = 30, -- кэшировать и "не найдено" 30с (против перебора)
resty_lock_opts = { timeout = 0.5 },
})
-- callback (L3) дёргается лишь при промахе L1+L2, и то single-flight
local function fetch_jwks(kid)
local http = require "resty.http"
local httpc = http.new()
httpc:set_timeout(1000)
local res = httpc:request_uri("https://auth.cyberlake.ru/jwks", { method = "GET" })
httpc:set_keepalive() -- вернуть в пул, как и Redis
if not res or res.status ~= 200 then return nil, "jwks fetch failed" end
return res.body
end
local jwks, err = cache:get("current_kid", nil, fetch_jwks, "current_kid")
Здесь же прямая связь с micro-caching: тот же приём (короткий ttl 1-5 секунд на ответ бэкенда) гасит шквал одинаковых запросов, а single-flight гарантирует, что прогрев делает один, а не толпа. Используя lua-resty-http в callback, ты строишь полноценный кэширующий слой на Lua там, где proxy_cache не хватает гибкости (например ключ кэша зависит от распарсенного claim).
Грабли, которые ловят всех
- Доверие alg из токена. Главная дыра. Всегда фиксируй алгоритм сервером, отвергай none в любом регистре, проверяй aud и iss, а не только подпись.
- Соединение Redis/HTTP не возвращено в пул. Забыл set_keepalive - получил новый коннект на каждый запрос. Звал set_keepalive на битом соединении - отравил пул. После сетевой ошибки всегда close.
- Переменная уровня модуля для данных запроса. Положил claims или соединение в local на верхнем уровне файла - и данные одного клиента утекли в запрос другого. Для запроса только ngx.ctx, для общих данных ngx.shared.
- mlcache без cache:update. L1 у воркеров разъезжается после инвалидации, видишь устаревшие значения. Заведи таймер на update.
- Cosocket в balancer_by_lua. Там нельзя ходить в сеть. Резолви маршрут на access-фазе.
- lua-resty-limit-traffic как стабильная зависимость. Она experimental, пинуй версию и тестируй при обновлении.
Собери руками шлюз, который проверяет токен и кэширует ключи.
- Подними OpenResty (1.31.1.1) и Redis в docker-compose. Положи в http-блок: lua_shared_dict mlcache_shm 64m; lua_shared_dict mlcache_lock 8m;
- Через luarocks/opm поставь lua-resty-jwt и lua-resty-mlcache (или lua-resty-openidc, если есть Keycloak).
- В отдельном .lua-модуле создай mlcache.new и callback fetch_jwks, который ходит lua-resty-http на твой JWKS endpoint (или мок-файл) и возвращает ключ. Поставь ttl=3600, neg_ttl=30.
- В location /api/ повесь access_by_lua_file: достань Bearer, через cache:get("kid", ...) возьми ключ, валидируй jwt:verify с claim_spec, где alg = equals("RS256"), exp = is_not_expired, aud = equals(...). При провале - ngx.exit(401).
- Заведи таймером ngx.timer.every(1, ...) вызов cache:update, чтобы L1 синхронизировался.
- Проверь: валидный токен - 200; токен с alg:none - 401; токен, подписанный HS256 твоим публичным ключом - 401 (alg-confusion отбит). Положи log-line с временем callback и убедись, что на втором и далее запросе fetch_jwks НЕ вызывается (кэш работает).
- Бонус: положи в Redis route:VIP -> 10.0.0.50:8080, резолви маршрут на access-фазе по claim и роуть через balancer_by_lua.
- Почему нельзя доверять полю alg из заголовка JWT и как технически работает атака alg-confusion с RS256/HS256?
- Что делает single-flight в lua-resty-mlcache и какую конкретную проблему при массовом промахе кэша он решает?
- Почему set_keepalive нельзя звать на соединении после сетевой ошибки и что будет с пулом, если это сделать?
- Почему маршрут из Redis резолвят на access-фазе, а не прямо в balancer_by_lua?
Боевой openresty nginx lua api gateway держится на четырёх китах: авторизация на access-фазе с жёсткой фиксацией alg (или lua-resty-openidc для настоящего OIDC), Redis через пул с обязательным set_keepalive, динамическая маршрутизация через balancer_by_lua с резолвом на access, и трёхуровневый mlcache с single-flight, который гасит dog-pile и снимает нагрузку с auth-сервера и БД. Голый lua-resty-jwt без фиксации алгоритма - это дыра по умолчанию; lua-resty-limit-traffic - мощно, но experimental. Сделаешь правильно - одна нода тянет десятки тысяч rps, p99 ровный, а сервер авторизации даже не замечает нагрузки.