---
title: "Безопасность: аутентификация"
description: "Безопасность go-kit: JWT-аутентификация на endpoint как middleware, identity в контексте, 401 против 403, HS256 и RS256, TLS-терминация и секреты."
course: "go-kit: микросервисный тулкит"
url: "https://golangmaster.ru/courses/go-kit/lessons/23-bezopasnost/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Безопасность: аутентификация

*Курс «go-kit: микросервисный тулкит», урок 23 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-kit/lessons/23-bezopasnost/*

Урок 22 научил stringsvc свидетельствовать о фактах: шина, издатель, outbox. Но осталась последняя дыра канона: каждый входящий запрос анонимен. Endpoint честно выполнит Uppercase для кого угодно, кто дотянулся до порта, а в payload события из урока 22 не едет имя того, кто заказал работу. В демо на ноутбуке это терпимо; в проде, где сервис слушает сеть, — открытая дверь. Запрос «аутентификация go kit» не приводит в готовый модуль, и это осознанный выбор библиотеки: kit не приносит auth из коробки, потому что аутентификация слишком разная, чтобы прятать её во фреймворк. Собирается она из канонных деталей, которые ты уже знаешь: обёртка из урока 14 защищает endpoint, error encoder из урока 05 превращает отказ в 401, конфиг из урока 18 приносит секрет, метрики из урока 07 считают отказы. Сегодня ты соберёшь привратника руками: JWT-валидация на endpoint, identity в контексте, 401 против 403, HS256 против RS256, TLS-терминация и правила хранения секретов. Мост из прошлого урока прямой: события получат автора, а сервис — дверь с замком.

### Анонимный endpoint — открытая дверь

Посчитай ущерб на конкретике. stringsvc слушает :8080 в сети компании: любой, кто узнал адрес, вызывает Uppercase, ставит задачи в очередь из урока 21, читает факты из шины урока 22. Он не злодей — просто коллега с curl'ом; но лимит из урока 09 делится между честными и случайными, метрики врут, а инцидент не расследуется: в логах из урока 06 нет имени виновника. Два вопроса путать нельзя: аутентификация — «кто ты», проверка токена; авторизация — «что тебе можно», проверка прав. Первая — это endpoint middleware из урока 14: одна обёртка закрывает сразу HTTP из урока 04 и gRPC из урока 13, потому что оба транспорта зовут один и тот же endpoint, — поэтому аутентификация go kit живёт на endpoint, а не в поле фреймворка. Вторая — доменная логика: сервис смотрит на identity и решает. Разнесение мест критично: токен парсится один раз на границе, вниз по стеку едет готовая личность, а не сырой заголовок.

### Канон: identity в контексте

Канон stringsvc не ломается: Uppercase и Count остаются мгновенными, очередь и события работают. Рядом появляется привратник, и у него строгая грамматика. HTTP-декодер из урока 04 вытаскивает заголовок Authorization и кладёт токен в поле структуры запроса — транспорт знает про заголовки, endpoint знает про домен. Endpoint middleware проверяет подпись и срок, после чего кладёт в context.Identity: subject (кто), scopes (что можно). Дальше по стеку ходит личность, а не токен: сервис из урока 03 читает контекст, если ему нужен автор; payload события из урока 22 получает поле actor; лог из урока 06 пишет subject в каждое сообщение. Контекст — единственный канал: тащить токен аргументами через слои — значит заставлять каждый слой знать про аутентификацию, и при смене схемы токенов переписывать весь сервис. Ключ контекста — приватный тип: две библиотеки, случайно взявшие строковый ключ «identity», затрут друг друга; свой тип-ключ делает коллизию невозможной.

### Auth: обёртка на endpoint

```go
// internal/endpoint/auth: аутентификация как endpoint middleware
type identityKey struct{}

type Identity struct {
        Subject string   // sub токена: пользователь или соседний сервис
        Scopes  []string // что разрешено: "uppercase", "count", "jobs"
}

type AuthableRequest interface {
        Token() string // каждую структуру запроса научим отдавать токен
}

var ErrUnauthorized = errors.New("auth: invalid or expired token")

func Auth(secret []byte, next endpoint.Endpoint) endpoint.Endpoint {
        return func(ctx context.Context, request interface{}) (interface{}, error) {
                req, ok := request.(AuthableRequest)
                if !ok {
                        return nil, ErrUnauthorized
                }
                claims, err := parseToken(secret, req.Token())
                if err != nil {
                        return nil, ErrUnauthorized // error encoder из урока 05 вернёт 401
                }
                ctx = context.WithValue(ctx, identityKey{}, Identity{
                        Subject: claims.Subject,
                        Scopes:  claims.Scopes,
                })
                return next(ctx, request)
        }
}
```

Обёртка повторяет форму endpoint middleware из урока 14: вход — контекст и запрос, выход — ответ или ошибка. Токен добывается интерфейсом AuthableRequest: HTTP-декодер из урока 04 положил его из заголовка Authorization, gRPC-декодер из урока 13 — из metadata; middleware один и тот же, потому что он не знает, откуда взялся токен. ErrUnauthorized — сентинел канона урока 03: error encoder из урока 05 мапит его в 401, а не в 500. Identity кладётся под приватным типом identityKey: прочитать её сможет только этот пакет — и только осознанно.

### JWT: паспорт с подписью издателя

JWT — три части через точку: header с алгоритмом, payload с claims и подпись. Claims — стандартные iss (кто издал), sub (о ком), exp (до когда), aud (для кого), плюс свои: scopes. Токен подписан, но не шифрован: payload читает любой, кто перехватил, — в нём нельзя класть секреты, только факты о личности. Сервис токен не издаёт, а проверяет: подпись говорит «этот паспорт выдал наш auth-сервис», exp — «он ещё не просрочен». Проверка обязательна целиком: подпись без exp пропустит мёртвый паспорт, exp без подписи — подделку. Запрос «jwt микросервисы» в поиске приводит к библиотекам, и это правильно: бери проверенную (golang-jwt), самописный разбор подписи — как собственный криптопрак: можно, если знаешь, что делаешь, и нельзя, если не знаешь. TTL держи коротким: минуты для пользовательских токенов, часы для сервисных — просроченный токен безопаснее отозванного с опозданием, потому что не требует инфраструктуры отзыва.

### Валидация: алгоритм, подпись, срок

```go
// internal/platform/jwt: разбор и проверка токена
type Claims struct {
        jwt.RegisteredClaims           // iss, sub, aud, exp — стандарт
        Scopes []string `json:"scopes,omitempty"`
}

func parseToken(secret []byte, raw string) (*Claims, error) {
        tok, err := jwt.ParseWithClaims(raw, &Claims{}, func(t *jwt.Token) (any, error) {
                if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
                        return nil, fmt.Errorf("unexpected alg: %v", t.Header["alg"])
                }
                return secret, nil // STRINGSVC_JWT_SECRET из урока 18
        })
        if err != nil || !tok.Valid {
                return nil, ErrUnauthorized
        }
        claims, ok := tok.Claims.(*Claims)
        if !ok {
                return nil, ErrUnauthorized
        }
        return claims, nil
}
```

ParseWithClaims проверяет exp автоматически: просроченный токен вернёт ошибку ещё до твоего кода. Главная строка — проверка семейства алгоритма: без неё атакующий подписывает токен alg=none или подсовывает публичный ключ в качестве HMAC-секрета — две исторические уязвимости реальных библиотек. Фиксируем HMAC и сравниваем с секретом из конфига урока 18: секрет приходит env'ом на старте, fail fast, если пуст. RegisteredClaims даёт стандартные поля бесплатно, scopes — свой claim: авторизация прочитает его из identity.

### 401 против 403: два разных отказа

Отказы аутентификации — тоже контракт API, и клиент различает их по коду. 401 Unauthorized: «не знаю, кто ты» — заголовка нет, подпись битая, токен просрочен; ответ уходит с WWW-Authenticate: Bearer, и клиент понимает: иди за новым токеном. 403 Forbidden: «знаю, кто ты, но нельзя» — подпись валидна, scope не тот: токен сервисного аккаунта, который умеет Count, стучится в админ-endpoint. В go-kit оба случая раскладываются канонно: ErrUnauthorized из middleware мапится в 401, доменные ошибки авторизации — через codeFrom из урока 05 в 403. Логи из урока 06 пишут причину отказа и subject, если он известен, — но никогда не пишут сам токен: утёкший в лог токен — это действующий ключ. Метрика auth_failures_total из урока 07 с меткой причины (no_token, expired, bad_signature, forbidden) превращает перебор в всплеск на графике, который заметит алерт, а не пользователь.

### HS256 против RS256: один секрет или пара ключей

HS256 — HMAC: издатель и проверяющий держат один и тот же секрет. Быстро, просто, для одного сервиса — идеально. Цена: каждый, кто умеет проверять, умеет и издавать; секрет приходится раздавать всем сервисам, и один скомпрометированный узел подделывает паспорта всей системы. RS256 — RSA: приватный ключ только у издателя, публичный раздаётся всем проверяющим. Проверить может каждый, подделать — никто: сервисы проверяют, издаёт один auth-сервис. Плата — скорость (RSA дороже HMAC на порядок при проверке) и инфраструктура: ключи, ротация, JWKS — публичный endpoint издателя с текущими ключами, который проверяющие кэшируют. Правило выбора: один сервис или пара доверенных — HS256; федерация микросервисов, где токены ездят между командами, — RS256. И в обоих случаях секреты не в репозитории: тема «go-kit безопасность» заканчивается не кодом, а дисциплиной — env из урока 18, secrets оркестратора из урока 24, ротация по плану, а не по инциденту.

### Матрица: чем подписывать и чем защищать канал

| Критерий | HS256 | RS256 | mTLS |
| --- | --- | --- | --- |
| Подпись токена | симметричный HMAC | асимметричный RSA | сертификаты сторон |
| У проверяющего | общий секрет | публичный ключ | свой сертификат и CA |
| Кто может издавать | владелец секрета | владелец приватного ключа | никто: подписан канал |
| Скорость проверки | микросекунды | на порядок дороже | handshake раз на соединение |
| Компрометация | секрет издаёт чужие токены | приватный ключ у одного | сертификат отзывается |
| Ротация | сменить env и перезапустить | JWKS и kid в header | перевыпуск сертификата |
| Ключи в конфиге (урок 18) | STRINGSVC_JWT_SECRET | путь к публичному ключу | пути к cert и key |
| Канон в курсе | выбор stringsvc | рост до федерации | транспорт сервис-сервис |
| Риск по умолчанию | секрет раздали всем | нет кэша JWKS | бюрократия PKI |

### TLS: где кончается открытый HTTP

Токен в заголовке перехватывается так же, как пароль, если канал открыт: транспорт шифруем. Вопрос один: где терминировать TLS. Вариант первый — сам сервис: http.Server с TLSConfig, сертификаты из файлов, Let's Encrypt через autocert; сквозное шифрование, но и сложность в коде: продление, cipher suites, прокладка ключей. Вариант второй — канон микросервисов: на границе стоит reverse-proxy, nginx или ingress, он терминирует TLS и внутрь приватной сети передаёт открытый HTTP; pod слушает :8080, а прокси добавляет X-Forwarded-Proto: https — по нему сервис видит, что снаружи канал был шифрованным, и может отказывать запросам, пришедшим мимо прокси. gRPC из урока 13 живёт по тем же правилам: credentials на клиенте и сервере, а внутри кластера — mTLS, когда обе стороны предъявляют сертификаты и проверяют друг друга взаимно. Сертификаты хранятся секретами оркестратора, а не в образе — об этом урок 24.

### Секреты: где им жить

Секрет — то, что открывает дверь целиком, и у него три заповеди. Первая: не в репозитории — git помнит всё, даже удалённое; ключ из коммита трёхлетней давности жив, пока не ротирован. Вторая: не в образе и не в коде — ENV в Dockerfile виден docker inspect'ом (урок 24), константа в исходнике видна всем, у кого есть бинарник. Третья: не в логе — маскируй секрет и токен в логгере из урока 06. Живут секреты в env на старте (двенадцать факторов из урока 18) или в secrets-хранилище оркестратора; fail fast обязательна: пустой STRINGSVC_JWT_SECRET — не дефолт, а отказ стартовать. Ротация — часть жизни: смена секрета инвалидирует все HS256-токены разом — это фича, кнопка «разлогинить всех». Кто издаёт токены — отдельный вопрос: в проде auth-сервис, в демо хватит утилиты, подписывающей токен тем же секретом; главное — издатель и проверяющий не живут в одном бинарнике случайно.

### Токен между сервисами: путешествие продолжается

Микросервис вызывает микросервис: заказ едет в биллинг, биллинг — в нотификацию. Кто там, с другой стороны? Тот же запрос, и он обязан ехать с тем же паспортом: service.ProxyingMiddleware из урока 17 при пересылке копирует заголовок Authorization вперёд — identity сохраняется на всём пути, и каждый сервис в цепочке видит исходного subject, а не соседа. Лог из урока 06 в каждом сервисе пишет один и тот же subject — инцидент собирается в цепочку. Глубже — mTLS: сосед предъявляет сертификат, подписанный тем же CA, и ты знаешь, кто стучится, ещё до токена; это защита от имитации адреса в сети. Для событий из урока 22 правило то же: payload несёт actor — кто заказал работу, — и аудит через месяц отвечает на вопрос «кто запустил этот конвейер», не опрашивая свидетелей. Аутентификация — не одна дверь на входе, а паспорт, который признают на каждом рубеже.

**Вопрос:** Куда встроить аутентификацию, чтобы закрыть и HTTP из урока 04, и gRPC из урока 13 одним слоем?

- В HTTP-декодер: там лежит заголовок Authorization
- ✅ В endpoint middleware из урока 14: оба транспорта зовут один endpoint
- В каждый сервисный метод: там видно всё
- Дублировать проверку в обоих декодерах

Endpoint — общее дно обоих транспортов: обёртка на endpoint защищает HTTP и gRPC сразу, а декодерам пришлось бы повторять логику дважды и по-разному. Сервисные методы — слишком поздно и слишком глубоко: каждый должен помнить про токен, и в новом методе забудут. Декодер возвращает структуру запроса, а не ошибки протокола: 401 из декодера пришлось бы выдумывать отдельно. Канон: транспорт кладёт токен в запрос, endpoint middleware проверяет, identity едет в контексте.

**Вопрос:** Из parseToken убрали проверку t.Method. Какая атака становится возможной?

- Никакая: подпись всё равно проверяется секретом
- ✅ Токен с alg=none: библиотека пропустит неподписанный payload
- Токен с exp в будущем, но битой подписью
- Токен с чужим iss

Исторические уязвимости JWT — про алгоритм: без проверки метода атакующий выставляет alg=none и убирает подпись либо подписывает токен публичным ключом сервера, который и так известен, объявив alg=HS256. Проверка подписи есть, но выполняется не тем способом, который ожидал сервер, — и токен признаётся валидным. Фикс — белый список семейства алгоритмов до выбора ключа. exp и iss в этих атаках не при чём: claims атакующего выглядят валидно, подделана сама подпись.

**Вопрос:** Токен валиден: подпись свежая, exp не вышел, но в scopes нет uppercase. Что вернёт stringsvc?

- 401 Unauthorized: аутентификация не пройдена
- ✅ 403 Forbidden: личность известна, права нет
- 200 OK: scopes проверит сам клиент
- 500: ошибка конфигурации scopes

Аутентификация пройдена: identity в контексте, subject известен. Отказ в праве — авторизация, её ответ 403: клиенту не нужен новый токен, нужен токен с другими scopes. 401 — только про «не знаю, кто ты»: нет токена, битая подпись, просрочен. Перепутать дорого: клиент на 401 запустит цикл перелогина, который не поможет, и сломает себе интеграцию, а на 403 — поймёт, что вопрос о правах, и пойдёт к издателю токенов.

### Просроченный паспорт

```go
secret := []byte("demo-secret")
// exp указан в прошлом
raw := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhbm4iLCJleHAiOjE1MDAwMDAwMDB9.x"

_, err := parseToken(secret, raw)
if err != nil {
        fmt.Println("reject:", ErrUnauthorized)
} else {
        fmt.Println("welcome")
}
```

**Что выведет программа?**

- welcome: подпись проверяется отдельно от срока
- ✅ reject: auth: invalid or expired token — ParseWithClaims отклонил exp из прошлого
- Паника: строка токена обрезана
- reject: unexpected alg — алгоритм не прошёл

exp — стандартное поле RegisteredClaims, ParseWithClaims проверяет его сам: время в прошлом — ошибка ещё до твоих проверок, err не nil, ветка reject. Подпись в примере обрезана для краткости — библиотека вернула бы ошибку формата, итог тот же reject. Алгоритм здесь валидный HS256 и не при чём: срок сам по себе — основание для 401. В лог урока 06 при этом уходит причина expired, а метрика auth_failures_total растёт на своей метке.

### Скоупы решают

```go
func allowed(scopes []string, need string) bool {
        for _, s := range scopes {
                if s == need {
                        return true
                }
        }
        return false
}

func main() {
        id := Identity{Subject: "ann", Scopes: []string{"count", "jobs"}}
        fmt.Println(allowed(id.Scopes, "uppercase"), allowed(id.Scopes, "count"))
}
```

**Что выведет программа?**

- true true: subject ann доверенный
- ✅ false true: uppercase в scopes нет, count есть
- true false: порядок скоупов важен
- false false: скоупы не совпадают с именами методов

allowed — вся авторизация в миниатюре: линейный поиск нужного права в списке из токена. uppercase не выдан — false; count выдан — true. В боевых проверках механики больше — иерархии, wildcards, отказ по умолчанию, — но принцип тот же: решение принимает код на основе claims, а не настроение middleware. Место такой проверки — сервис или отдельная обёртка после auth: в транспортном слое identity ещё не существует.

### Собери Auth

Восстанови четыре пропуска endpoint-обёртки: достань токен, откажи честно, положи identity в контекст, передай ход.

```go
func Auth(secret []byte, next endpoint.Endpoint) endpoint.Endpoint {
        return func(ctx context.Context, request interface{}) (interface{}, error) {
                req := request.(AuthableRequest)
                claims, err := parseToken(secret, req.___())
                if err != nil {
                        return nil, ___ // 401 через error encoder
                }
                ctx = context.___(ctx, identityKey{}, Identity{
                        Subject: claims.Subject,
                        Scopes:  claims.Scopes,
                })
                return ___(ctx, request)
        }
}
```

Подсказка: Первое — метод интерфейса AuthableRequest; второе — сентинел из этого урока; третье — функция пакета context, кладущая значение под ключ; четвёртое — параметр-обёртка, которому передаётся ход.

Правильные ответы в пропусках: `Token`, `ErrUnauthorized`, `WithValue`, `next`

### Путь запроса с паспортом

Расставь этапы пути авторизованного запроса в stringsvc.

Правильный порядок строк:

1. `клиент получил токен у auth-сервиса: sub, scopes, exp, подпись HS256`
2. `HTTP-декодер из урока 04 вынул заголовок Authorization и положил токен в структуру запроса`
3. `endpoint middleware Auth проверил алгоритм, подпись и exp токена`
4. `identity с subject и scopes легла в контекст под приватным типом-ключом`
5. `endpoint вызвал сервис: бизнес-логика исполнилась, не зная про токен`
6. `у анонимного запроса error encoder вернул 401, при нехватке scope — 403`
7. `лог из урока 06 записал subject и причину, но не сам токен`
8. `метрика auth_failures_total посчитала отказ по причине, алерт увидел всплеск`

> **Токен — тоже секрет.** Утёкший токен — действующий ключ от сервиса до конца TTL. Поэтому: никакой токен не попадает в лог (маскируй Authorization), не попадает в URL (GET с токеном в строке остаётся в access-логах прокси навсегда), не возвращается в ошибках. Короткий TTL — страховка: пользовательский токен живёт минуты, сервисный — часы; скомпрометированный протухнет сам, без инфраструктуры отзыва. И никогда не пиши собственный разбор JWT: парсить подпись руками — как изобретать свой breaker из урока 10 вместо gobreaker: выглядит похоже, гарантирует ничего.

> **Дешевизна проверки.** HMAC-проверка — микросекунды: обёртка из этого урока не станет узким местом даже под лимитом из урока 09. Расходы живут в другом: JWKS-кэш при RS256 — обновляй в фоне, а не на каждом запросе; метки причин отказов в Prometheus — четыре метки из этого урока; алерт на всплеск auth_failures_total. И пропиши SLI: доля 401 среди запросов — сигнал и перебора, и кривого клиента, у которого токен истекает раньше, чем он успевает обновить. Второй полезный гейдж — возраст валидных токенов: резко помолодел — значит, произошла ротация секрета.

> **Честно о запуске кода.** Auth-обёртка, парсер JWT и скоупы живут в реальном сервисе с библиотекой golang-jwt, в песочнице курса этот код не исполняется — интерактивы урока построены на чтении кода. Локально оживает за минуту: подпиши токен HS256 утилитой с твоим секретом, curl с заголовком Authorization: Bearer — получи 200; сотри последний символ подписи — 401; подожди exp — снова 401, уже по сроку, с другой меткой причины.

### Термины урока

- **JWT** — Три части через точку: header с алгоритмом, payload с claims (iss, sub, exp, scopes), подпись издателя; payload читается всеми — секретов в нём нет.
- **Identity в контексте** — Проверив токен, middleware кладёт subject и scopes в context под приватным типом-ключом; вниз по стеку едет личность, а не сырой токен.
- **401 против 403** — 401 — не знаю, кто ты: нет токена, битая подпись, просрочен; 403 — знаю, но нельзя: валидный токен, не хватает scope.
- **HS256** — Симметричный HMAC: общий секрет у издателя и проверяющих; быстро, но каждый проверяющий умеет издавать; выбор одного сервиса.
- **RS256** — Асимметричный RSA: приватный ключ у издателя, публичный у всех проверяющих; подделать нельзя; рост до федерации, ключи через JWKS.
- **TLS-терминация** — Точка, где HTTPS расшифровывается: на ingress перед приватной сетью (прокси сообщит через X-Forwarded-Proto) или в самом http.Server при сквозном шифровании.

### Что дальше

Сервис получил привратника: токен проверяется один раз на endpoint, identity едет в контексте, отказы различаются честно, а секреты живут по правилам. Но stringsvc всё ещё запускается go run на ноутбуке: ни образа, ни окружения, ни ответа оркестратору «я жив и готов». Урок 24-deploy упакует его в Docker: multi-stage образ без сборочного мусора, exec-форма ENTRYPOINT ради сигналов, health-чеки для проб, env вместо настроек в коде — и чек-лист прод-деплоя, где каждая строка отсылает к уроку курса. Карта курса — на [странице курса](/courses/go-kit/); обёртки endpoint — в [уроке 14-endpoint-middleware](/courses/go-kit/lessons/14-endpoint-middleware/); кодеки и честный 401 — в [уроке 05-codeki](/courses/go-kit/lessons/05-codeki/); секреты в env — в [уроке 18-konfiguraciya](/courses/go-kit/lessons/18-konfiguraciya/); метрики отказов — в [уроке 07-metriki](/courses/go-kit/lessons/07-metriki/); proxy, пересылающий Authorization, — в [уроке 17-struktura](/courses/go-kit/lessons/17-struktura/).

## Коротко: что запомнить

- Аутентификация — endpoint middleware из урока 14: одна обёртка защищает HTTP и gRPC, транспорт лишь кладёт токен в запрос.
- Identity — subject и scopes в context под приватным типом-ключом; сервис и события едят личность, а не сырой заголовок.
- JWT подписан, но не шифрован: проверяй целиком — алгоритм, подпись, exp; alg=none без белого списка — классическая дыра.
- 401 — не знаю, кто ты; 403 — знаю, но нельзя: перепутанный код уводит клиента в бесполезный перелогин.
- HS256 — общий секрет для пары доверенных; RS256 — приватный ключ издателя и публичные у проверяющих для федерации.
- TLS терминирует ingress перед приватной сетью или сам http.Server; X-Forwarded-Proto — признак шифрованного канала снаружи.
- Секреты: не в репо, не в образе, не в логе; env из урока 18, fail fast на пустой STRINGSVC_JWT_SECRET, ротация — план.
- Токен между сервисами пересылает service.ProxyingMiddleware из урока 17; в payload событий едет actor — аудит знает автора.

## Домашнее задание

Поставь привратника в stringsvc. Первое: собери internal/endpoint/auth с Identity, AuthableRequest и Auth из урока; научи структуры запросов из урока 02 отдавать токен, а HTTP-декодер из урока 04 — вытаскивать Authorization: Bearer. Второе: подпиши тестовый токен HS256 утилитой, задай STRINGSVC_JWT_SECRET через env из урока 18 и проверь три сценария: без заголовка — 401, битая подпись — 401, валидный токен — 200 и subject в логе из урока 06. Третье: добавь проверку scopes и добейся честной пары отказов: токен без uppercase — 403, просроченный exp — 401; обе причины должны светиться в auth_failures_total из урока 07 с разными метками. Четвёртое: поставь stringsvc за nginx или Caddy с TLS-терминацией, убедись, что X-Forwarded-Proto доезжает, и отклоняй запросы, пришедшие мимо прокси по открытому каналу. Пятое: переправь Authorization через service.ProxyingMiddleware из урока 17 ко второй реплике и проверь, что identity живёт на всём пути, а в payload события из урока 22 появился actor.

## Вопросы и ответы по уроку

### JWT не шифруется — это дыра?

Нет, это дизайн. Токен подписан, а не зашифрован: любой может прочитать claims, никто не может подделать. В claims кладут факты о личности — subject, scopes, срок, — а не секреты. Если payload действительно нужно спрятать, используют JWE или шифрованный канал; для API-аутентификации подписи достаточно, а TLS закрывает перехват.

### Кто издаёт токены — тот же stringsvc?

Лучше нет. Издание и проверка — разные роли: издателю нужен приватный ключ и пользователи, проверяющему — только публичная часть. В проде это отдельный auth-сервис или внешний IdP; в демо — утилита, подписывающая токен тем же секретом. Совмещать издателя с бизнес-сервисом — значит держать приватный ключ там, где чужой баг откроет дверь.

### Нужен ли refresh-токен и где его место?

Для пользовательских сессий — да: короткий access-токен в памяти клиента, refresh — у auth-сервиса с ротацией и отзывом. stringsvc этот вопрос не видит: он только проверяет access-токены и отвечает 401, когда истёк срок. Клиент получает 401, идёт к издателю за новым и повторяет запрос — сервису для этого не нужно ничего, кроме честного 401.
