---
title: "Авторизация соединения"
description: "Авторизация websocket на Go: токен в query через r.URL.Query().Get(\"token\"), jwt.Parse до апгрейда и обёртка над Upgrade. Кейсы, ошибки и защита токена."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/13-avtorizaciya-ws/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Авторизация соединения

*Курс «WebSocket в Go: real-time чаты и уведомления», урок 13 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/websocket/lessons/13-avtorizaciya-ws/*

Авторизация websocket происходит на рукопожатии: пока HTTP-запрос не превратился в соединение, у сервера есть последний шанс спросить, кто стучится, — по токену из query, который проверяется обычным jwt.Parse до апгрейда. В этом уроке напишем обёртку над Upgrade, разберём, почему браузерный клиент не может послать заголовок Authorization, и поговорим о цене токена в URL.

Наш хаб принимает всех: любой человек, знающий адрес, открывает соединение и получает рассылку, а после прошлого урока может ещё и писать личные сообщения от своего имени — вернее, от имени пустой строки в userID. Открытый socket — редкий случай, когда дыру видно сразу, поэтому закрываем её до того, как строить что-то поверх. Хорошая новость: рукопожатие websocket — это обычный HTTP-запрос со специальными заголовками, и к нему применимы все привычные инструменты — заголовки, cookie, middleware и статус-коды. Значит, проверять клиента нужно там же, где вы проверяли обычные HTTP-хендлеры, — до апгрейда. Плохая новость одна, но существенная: браузерный клиент websocket не умеет задавать кастомные заголовки, и привычный Authorization Bearer в рукопожатие не положишь. Отсюда вся специфичная для ws механика этого урока: токен в query, одноразовые тикеты и аккуратная обёртка, которая не даёт соединению открыться до проверки.

### Авторизация websocket: проверка на рукопожатии

Порядок действий в хендлере становится жёстким: вытащить токен из query, проверить подпись и срок, вытащить userID из claims, и только потом вызвать Upgrade. Авторизация websocket до апгрейда означает, что невалидный клиент получает обычный HTTP-ответ с кодом 401 и никогда не становится соединением: ни одного кадра, ни одной записи в карте хаба, ни одной горутины насосов. Проверка после апгрейда создаёт уязвимое окно: соединение уже открыто, клиент успевает подписаться на события или отправить кадр, а затем его отключают — вся информация, которую он успел вытащить, уже утекла. Идентичность, полученную из claims, записываем в поле Client.userID — прошлое задание уже построило на нём маршрутизацию личных сообщений. Саму подпись проверяет jwt.Parse со своим секретом и keyfunc; детали проверки алгоритма и структуры claims мы разбирали в курсе про JWT, здесь возьмём минимальный рабочий вариант.

### Обёртка над Upgrade с проверкой токена

```go
func upgradeAuthorized(w http.ResponseWriter, r *http.Request) (*websocket.Conn, string) {
        tokenString := r.URL.Query().Get("token")
        claims := jwt.MapClaims{}
        token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
                return secret, nil
        })
        if err != nil || !token.Valid {
                http.Error(w, "unauthorized", http.StatusUnauthorized)
                return nil, ""
        }
        sub, _ := claims["sub"].(string)

        conn, err := upgrader.Upgrade(w, r, nil)
        if err != nil {
                return nil, ""
        }
        return conn, sub
}
```

Токен достаётся из query одним вызовом r.URL.Query().Get, проверяется ParseWithClaims, и только после token.Valid выполняется апгрейд. Невалидный токен — обычный http.Error с 401: браузер покажет ошибку рукопожатия, а не загадочный обрыв после подключения. Для краткости опущена проверка алгоритма подписи — она обязательна и разобрана в курсе про JWT; sub из claims становится userID клиента.

### Почему токен в query, а не в заголовке

Браузерный конструктор WebSocket принимает только URL и список подпротоколов — заголовки через него не передаются, и это ограничение API, а не Go. Рабочих обхода три, у каждого своя цена. Первый — токен в query: просто, работает везде, но URL попадает в access-логи серверов и прокси, в историю браузера и в логи мониторинга; утечка из лога делает токен добычей любого, кто читает эти логи. Второй — cookie: браузер прикладывает их к рукопожатию автоматически, токен в URL не светится, но открывает дверь к междоменным атакам, о которых подробно в уроке про Origin. Третий — для небраузерных клиентов ничего не мешает послать обычный заголовок Authorization, потому что wscat и мобильные SDK заголовки умеют. Практическое правило: браузер получает токен через query или cookie, всё остальное — через заголовок, и сервер готов принять его из двух мест.

### Кейс: токен в access-логах nginx

Типичный инцидент: чат работает, а служба безопасности поднимает вопрос — в access-логах прокси полным текстом лежат адреса вида wss://chat.example/ws?token=eyJhbG. Логи уезжают в хранилище на девяносто дней, доступ к ним у половины команды, и токен, который живёт пятнадцать минут, выглядит уже не так безобидно, как в коде. Классический ответ — одноразовый тикет: клиент сначала делает обычный авторизованный POST на эндпоинт, который проверяет настоящий JWT и выдаёт короткую случайную строку с жизнью в тридцать секунд, а затем открывает websocket с тикетом в query. В логи попадает тикет, бесполезный через тридцать секунд и одноразовый по построению. Это два запроса вместо одного и немного состояния на сервере — цена разумная, когда токены долгоживущие и логи чужие. Если же токен живёт минуты и логи изолированы, токен в query остаётся приемлемым компромиссом: идеал не стоит своей цены на каждом проекте.

> **Не апгрейдите до проверки.** Соблазн велик: сначала Upgrade, потом проверка и Close — код получается на строку короче. Это окно уязвимости: соединение уже живо, клиент успел зарегистрироваться в хабе и получить первые кадры рассылки, прежде чем его отключат. Проверка всегда первая, апгрейд — последняя строка обёртки; и никакой defer с закрытием не компенсирует кадры, которые уже улетели.

### serveWs с userID из claims

```go
func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
        conn, userID := upgradeAuthorized(w, r)
        if conn == nil {
                return
        }

        client := &Client{
                hub:    hub,
                conn:   conn,
                userID: userID,
                send:   make(chan *Envelope, 256),
        }
        hub.register <- client

        go client.writePump()
        client.readPump()
}
```

Обёртка вернула соединение и userID — или нулевой указатель, если рукопожатие отклонено, и хендлер просто выходит. Дальше всё как в девятом уроке, с одним отличием: Client рождается уже с userID, и хаб с первой минуты знает, чьи кадры читает и кому направлять личные сообщения. Анонимные подключения в эту схему не входят — для них нужен отдельный эндпоинт с гостевыми идентификаторами.

### Соединение живёт дольше токена

Подключённый в 12:59 клиент с токеном до 13:15 спокойно держит соединение в 15:00 — и это правильно, а не дыра. Проверка подписи — операция на рукопожатии; после неё сессия живёт своей жизнью, и сервер не обязан перечитывать exp на каждом кадре. У этого правила есть граница, и она стоит того, чтобы её помнить: если токен отзывают — пользователь сменил пароль, ключ скомпрометирован, — соединение продолжит жить до первого разрыва, потому что сервер больше не сверяется со списком отозванных. Для чатов это обычно приемлемо: сессия закончится вместе с соединением, а новые будут проверены заново. Для чувствительных систем в ветке проверок добавляют периодическую ревалидацию или короткий pongWait, после которого клиент переподключается с свежим токеном. Механика переподключения с backoff — тема семнадцатого урока, а отзыв токенов целиком разбирается в материале про JWT.

### Обёртка в конвейере: middleware вместо дублирования

Как только ws-эндпоинтов становится два, копипаста проверки начинает пахнуть. Решение то же, что для обычного HTTP: middleware, который достаёт токен, проверяет его и передаёт userID дальше через контекст запроса. В net/http это обёртка-функция: она возвращает 401 при провале, а при успехе кладёт идентификатор в контекст и вызывает следующий хендлер — тот достаёт userID оттуда и не знает про jwt ничего. В роутерах типа chi мидлварь вешается на группу маршрутов одной строкой, и ws-эндпоинт наследует проверку автоматически, как и обычные JSON-эндпоинты. Один нюанс стоит знать заранее: Upgrade занимает хендлер надолго — соединение живёт в теле хендлера, поэтому мидлварь обязана завершиться до вызова readPump. Всё, что middleware делает после вызова next — логирование статуса, доступ к ResponseWriter, — с ws-соединением уже не работает: рукопожатие съедено.

Не всякий socket — человек в браузере: свои соединения открывают сервисы-подписчики, мобильные SDK и внутренние воркеры. Для них та же проверка, но токен другой: сервисный, с собственным claim о роли и сроке, который выпускает ваша система конфигурации. Отличать гостей от людей удобно прямо в claims: поле роли едет вместе с идентификатором, и Client получает третье поле kind, по которому хаб решает, что этому типу можно. Сервисному соединению можно читать события всех комнат, гостевому — только свою; человек получает полные права по своей роли. Важная гигиена: не заводите для внутренних подписчиков отдельный незащищённый порт с пометкой «только внутри» — внутренние сети давно не считаются безопасными, и рано или поздно порт найдёт сканер. Один эндпоинт, один механизм проверки, различие в claims — меньше движущихся частей, чем отдельные правила на каждый тип клиента.

### Куда положить токен для рукопожатия

| Способ | Браузер | Небраузерный клиент | Главная цена |
| --- | --- | --- | --- |
| Query-параметр | да, без вариантов | да | токен попадает в логи и историю |
| Cookie | да, автоматически | нужно вручную | междоменное рукопожатие, урок про Origin |
| Заголовок Authorization | нет: API не умеет | да, естественный путь | не годится как единственный способ |
| Одноразовый тикет | да, два запроса | да | лишний эндпоинт и состояние |

### Проверка: заголовок или query

**Вопрос:** Почему токен для браузерного websocket кладут в query, а не в Authorization?

- Так запрос получается короче и быстрее
- ✅ Браузерный WebSocket API не позволяет задавать кастомные заголовки
- Заголовки не шифруются в wss
- Так требует протокол WebSocket

Конструктор WebSocket принимает только URL и список подпротоколов — заголовков в его интерфейсе нет вообще. Шифрование тут ни при чём: в wss защищены и заголовки, и query. Для небраузерных клиентов заголовок работает, поэтому сервер часто принимает токен из двух мест.

### Проверка: сессия против exp

**Вопрос:** Токен был выдан с exp через пятнадцать минут, клиент держит соединение три часа. Что корректно?

- Соединение обязано разорваться ровно на пятнадцатой минуте
- Сервер должен проверять exp на каждом кадре
- ✅ Соединение живёт: проверка была на рукопожатии, истечение exp само по себе ничего не рвёт
- Клиент обязан прислать новый токен в каждом сообщении

Рукопожатие — единственная точка проверки: соединение устанавливается, и сессия не пере-проверяется автоматически. Исключение — отзыв: смена пароля или компрометация не дойдут до живого соединения, для этого нужна ревалидация или короткий срок жизни соединения с reconnect.

### Что увидит браузер при 401

```go
// сервер: upgradeAuthorized вернул 401,
// соединение не открыто
const ws = new WebSocket("wss://chat.example/ws?token=bad")
```

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

- Сработает onopen, а затем сразу onclose
- ✅ Сработает onerror и затем onclose: рукопожатие отклонено HTTP-ответом
- Браузер покажет диалог ввода логина и пароля
- WebSocket навсегда останется в состоянии CONNECTING

Отклонённое рукопожатие — это не открытый socket, который потом закрыли: соединение так и не перешло в OPEN, поэтому onopen не сработает вовсе. Браузер получит HTTP-статус и переведёт соединение в CLOSED через событие onerror — по нему и диагностируйте невалидный токен.

> **Проверьте отказ локально.** Соберите обёртку и запустите сервер на локальном Go: вкладка с валидным токеном в query должна подключиться, а вкладка с испорченным — получить ошибку рукопожатия в консоли браузера. Затем уберите проверку из обёртки и посмотрите, как тот же испорченный токен открывает соединение и начинает получать рассылку.

### Соберите начало обёртки

Впишите четыре пропуска: путь к query, имя параметра, проверка валидности токена и HTTP-статус отказа.

```go
func upgradeAuthorized(w http.ResponseWriter, r *http.Request) (*websocket.Conn, string) {
        tokenString := r.___().Get("token")
        claims := jwt.MapClaims{}
        token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
                return secret, nil
        })
        if err != nil || !token.___ {
                http.Error(w, "unauthorized", http.___)
                return nil, ""
        }
```

Подсказка: Query достаётся через r.URL.Query(), валидность — поле Valid у token, а отказ до апгрейда — обычный http.StatusUnauthorized.

Правильные ответы в пропусках: `URL`, `Valid`, `StatusUnauthorized`

### Соберите порядок проверки

Расставьте строки обёртки в правильном порядке: извлечение токена, проверка подписи, отказ и только потом успех.

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

1. `tokenString := r.URL.Query().Get("token")`
2. `token, err := jwt.ParseWithClaims(tokenString, claims, keyFunc)`
3. `if err != nil || !token.Valid {`
4. `http.Error(w, "unauthorized", http.StatusUnauthorized)`
5. `return nil, ""`
6. `}`

### Карточки: авторизация ws

- **Проверка до апгрейда** — Невалидный клиент получает HTTP 401 и никогда не становится соединением: ни кадра, ни записи в хабе, ни горутин насосов.
- **Токен в query** — r.URL.Query().Get("token") — единственный практичный способ для браузера, потому что API WebSocket не задаёт заголовки. Цена — URL в логах.
- **Одноразовый тикет** — POST выдаёт короткую случайную строку на десятки секунд, websocket открывается с тикетом в query. Логи видят бесполезное значение.
- **Сессия дольше exp** — Соединение не перечитывает exp после рукопожатия. Граница правила — отзыв токена: живое соединение об этом не узнает само.
- **CheckOrigin** — Вторая половина проверки рукопожатия: кто именно открыл страницу с токеном. Полный разбор — пятнадцатый урок.

Теперь у хаба есть идентичность: Client рождается с userID, и маршрутизация из прошлого урока обрела источник правды. Оставшийся зазор в защите рукопожатия — источник запроса: токен может быть валидным, а запрос — чужим, поэтому следующий урок — [Origin и междоменные соединения](/courses/websocket/lessons/15-origins/). Для проверки подписи, структуры claims и отзыва мы использовали минимум; полный набор приёмов — в [курсе JWT в Go](/courses/jwt-go/), особенно материал про [отзыв токенов](/courses/jwt-go/lessons/15-otzyv-tokenov/). Напоминание про клиентскую часть и то, как выглядит неудачное рукопожатие, — в [пятом уроке про браузерный клиент](/courses/websocket/lessons/05-js-klient/), а пары насосов — в [девятом](/courses/websocket/lessons/09-readpump-writepump/). Проверяйте оба сценария локально — валидный и испорченный токен — и держите проверку первой строкой обёртки. План курса целиком — на странице [курса WebSocket](/courses/websocket/).

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

- Авторизация websocket живёт на рукопожатии: токен проверяется до Upgrade, иначе появляется окно утечки кадров.
- Токен достаётся из query: r.URL.Query().Get("token") — потому что браузерный API не задаёт заголовки.
- Невалидный токен — обычный http.Error с 401: браузер видит ошибку рукопожатия, соединение не открывается.
- UserID из claims записывается в Client и питает маршрутизацию личных сообщений из прошлого урока.
- Соединение живёт дольше exp: проверка одноразовая, граница правила — отзыв токена.
- Токен в query виден в логах: долгоживущие токены — повод для одноразовых тикетов.

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

Реализуйте upgradeAuthorized с проверкой токена и запретом апгрейда при ошибке, затем одноразовый тикет: POST-эндпоинт принимает валидный JWT, выдаёт строку с жизнью в тридцать секунд и хранит её в карте с TTL, а обёртка принимает только тикет. Проверьте локально: тикет работает один раз, второй раз даёт 401, а в логах вместо JWT видна короткая строка.

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

### Можно ли авторизовать соединение первым сообщением после подключения?

Технически да, но до проверки клиент участвует в рассылке: он получает кадры, занимает место в карте и может слать мусор. Проверка на рукопожатии дешевле и строже: чужой отклоняется HTTP-статусом и не создаёт вообще ничего.

### Что делать с анонимными посетителями?

Отдельный эндпоинт или гостевой тикет: сервер выдаёт временный идентификатор и помечает соединение как гостевое, а хаб решает, что гостям можно — обычно только чтение своей комнаты. Главное, чтобы это было осознанное решение, а не совпадение открытого эндпоинта.

### Насколько страшен токен в query на самом деле?

Зависит от двух вещей: срока жизни токена и того, кто читает логи. Минутный токен в изолированных логах — приемлемый компромисс; суточный в логах, доступных половине компании, — повод внедрить одноразовые тикеты. Решение принимает цена утечки, а не эстетика.
