JWT_SECRET — единый секрет и ротация¶
⚠️ Главное правило¶
В системе ОДИН JWT-секрет (HS256). Им:
- подписывают токены: api-admin (hotel_api_v2), auth-svc, Supabase gotrue
- валидируют токены: все 11 микросервисов, Supabase PostgREST, Supabase realtime
Если хоть одна точка использует другой секрет → JWSError JWSInvalidSignature,
401, «Ошибка загрузки данных», падение Realtime WebSocket.
7 точек, которые ДОЛЖНЫ держать один и тот же секрет¶
| # | Сервис | Env-переменная | Файл |
|---|---|---|---|
| 1 | api-admin (hotel_api_v2) | SECRET_KEY ⚠️ |
.env.prod |
| 2 | api-admin (резерв) | HOTEL_API_JWT_SECRET |
.env.prod |
| 3 | api-admin / микросервисы | JWT_SECRET |
.env.prod + .env |
| 4 | Supabase gotrue (auth) | GOTRUE_JWT_SECRET / JWT_SECRET |
.env.prod |
| 5 | Supabase PostgREST (rest) | PGRST_JWT_SECRET |
.env.prod |
| 6 | Supabase realtime | (наследует JWT_SECRET) | .env.prod |
| 7 | ANON_KEY / SERVICE_ROLE_KEY | подписаны этим же секретом | .env.prod + .env |
⚠️ Ловушка #1 — приоритет SECRET_KEY¶
api-admin/app/config.py → _resolve_secret_key() берёт переменные в порядке:
SECRET_KEY → HOTEL_API_JWT_SECRET → JWT_SECRET
JWT_SECRET, но оставить старый SECRET_KEY — api-admin
будет подписывать СТАРЫМ. Всегда обновляй SECRET_KEY первым.
⚠️ Ловушка #2 — зашитый /server/.env в образе¶
В образ api-admin при сборке копируется .env (/server/.env). config.py делает
load_dotenv(/server/.env, override=False). override=False означает: если
переменная УЖЕ в окружении (из .env.prod) — она побеждает. Поэтому держи
SECRET_KEY в .env.prod — он перебьёт зашитый старый.
⚠️ Ловушка #3 — ANON_KEY и SERVICE_ROLE_KEY¶
Это JWT'ы (role: anon / role: service_role), подписанные тем же секретом.
При ротации их НУЖНО перевыпустить новым секретом, иначе Kong/PostgREST
отвергнут их → весь Supabase REST встанет (см. инцидент 2026-05-27).
Генерация:
import base64, hmac, hashlib, json
SECRET = "<новый секрет>"
def b64(x): return base64.urlsafe_b64encode(x).rstrip(b"=").decode()
def mint(role, iat, exp):
h = b64(json.dumps({"alg":"HS256","typ":"JWT"},separators=(",",":")).encode())
p = b64(json.dumps({"role":role,"iss":"supabase","iat":iat,"exp":exp},separators=(",",":")).encode())
s = b64(hmac.new(SECRET.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest())
return f"{h}.{p}.{s}"
print("ANON_KEY=" + mint("anon", 1772205033, 2087565033))
print("SERVICE_ROLE_KEY=" + mint("service_role", 1772205011, 2087565011))
Чеклист ротации (по порядку)¶
NEW="<новый_секрет_64+_символов>" # python -c "import secrets; print(secrets.token_urlsafe(48))"
cd /root/arkhyz-admin-main
cp .env.prod .env.prod.bak-$(date +%s)
cp .env .env.bak-$(date +%s)
# 1. Обнови во ВСЕХ местах .env.prod И .env:
# SECRET_KEY, HOTEL_API_JWT_SECRET, JWT_SECRET, PGRST_JWT_SECRET,
# GOTRUE_JWT_SECRET (если есть)
sed -i "s|^SECRET_KEY=.*|SECRET_KEY=$NEW|" .env.prod
sed -i "s|^HOTEL_API_JWT_SECRET=.*|HOTEL_API_JWT_SECRET=$NEW|" .env.prod
sed -i "s|^JWT_SECRET=.*|JWT_SECRET=$NEW|" .env.prod .env
sed -i "s|^PGRST_JWT_SECRET=.*|PGRST_JWT_SECRET=$NEW|" .env.prod
# 2. Перевыпусти ANON_KEY + SERVICE_ROLE_KEY новым секретом (скрипт выше),
# обнови в .env.prod И .env:
# ANON_KEY, SERVICE_ROLE_KEY, SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY,
# VITE_SUPABASE_ANON_KEY
# 3. Пересоздай ВСЕ затронутые контейнеры (env читается при старте):
docker compose -f docker-compose.yml -f docker-compose.prod.yml -f infra/compose/docker-compose.services.yml \
up -d --force-recreate --no-deps \
hotel_api_v2 auth-svc booking-svc chat-svc catalog-svc rtc-svc \
stories-svc social-svc gamification-svc analytics-svc platform-svc resort-svc pass-svc \
rest auth realtime kong
# 4. tourist-app / admin_web_v4 — пересобрать (VITE_SUPABASE_ANON_KEY в бандле):
cd tourist-app && npm run build && cd ..
# + admin_web_v4 build
# 5. Smoke (всё должно быть как ниже):
Smoke после ротации¶
NEW="<новый_секрет>"
# токен новым секретом → Supabase REST = 200
TOKEN=$(python3 -c "import base64,hmac,hashlib,json,time; s='$NEW'.encode(); b=lambda x:base64.urlsafe_b64encode(x).rstrip(b'=').decode(); h=b(json.dumps({'alg':'HS256','typ':'JWT'},separators=(',',':')).encode()); p=b(json.dumps({'sub':'t','role':'authenticated','exp':int(time.time())+3600},separators=(',',':')).encode()); print(f'{h}.{p}.'+b(hmac.new(s,f'{h}.{p}'.encode(),hashlib.sha256).digest()))")
curl -s -o /dev/null -w "PostgREST new-JWT: %{http_code}\n" \
"https://supabase.arkhyz-club.ru/rest/v1/users?select=id&limit=1" \
-H "apikey: $TOKEN" -H "Authorization: Bearer $TOKEN" # ждём 200
# api-admin secret совпадает:
docker exec arkhyz-hotel-api-prod python -c "from app.config import get_settings; print(get_settings().secret_key[-14:])"
# должно совпасть с хвостом $NEW
# все 7 публичных доменов = 200:
for u in app admin hotel restaurant instructor touroperator transfer; do
curl -sI -o /dev/null -w "$u: %{http_code}\n" "https://$u.arkhyz-club.ru"
done
Эффект для пользователей¶
Ротация разлогинивает всех — их старые токены подписаны прежним секретом. Делать в окно низкой нагрузки. После — пользователи логинятся заново (SMS/OAuth) и получают токены, подписанные новым секретом.
История инцидентов:
- 2026-05-27 — ротация без перевыпуска ANON/SERVICE_ROLE → весь Supabase REST лёг (откат).
- 2026-05-31 — api-admin подписывал старым SECRET_KEY (зашит в /server/.env),
Supabase валидировал новым → JWSInvalidSignature в админке. Фикс: SECRET_KEY
в .env.prod (перебивает зашитый через override=False).