Перейти к содержанию

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))
(iat/exp скопируй из старых токенов чтобы не менять срок).

Чеклист ротации (по порядку)

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).