---
title: "CSRF: защита cookie-сессий"
description: "CSRF защита go-сервиса: вектор атаки с чужого сайта, SameSite cookie Lax и Strict, токен X-CSRF-Token и двойная отправка, живые дыры GET-эндпоинтов. С кодом."
course: "JWT-аутентификация в Go: golang-jwt и bcrypt"
url: "https://golangmaster.ru/courses/jwt-go/lessons/21-csrf/"
published: "2026-09-19"
updated: "2026-10-02"
minutes: 16
---

# CSRF: защита cookie-сессий

*Курс «JWT-аутентификация в Go: golang-jwt и bcrypt», урок 21 из 25. ~16 минут. Обновлено 02.10.2026. Источник: https://golangmaster.ru/courses/jwt-go/lessons/21-csrf/*

CSRF защита go-сервиса начинается с вопроса, который стоит задать ещё до первой формы: что произойдёт, если браузер пользователя отправит POST на ваш эндпоинт не с вашей страницы, а с чужой? Ответ неприятный — с cookie-сессией ничего не помешает: браузер сам приложит куку, сервер выполнит перевод денег или смену почты. Разбираем вектор атаки по шагам, флаги SameSite и токен X-CSRF-Token — и места, где оба слоя молча не работают.

Ситуация: интернет-банк открыт в первой вкладке, во второй — форум, где под постом спрятана невидимая форма, отправляющая POST на api.bank.example/transfer. Пользователь кликает «показать спойлер», форма уходит, и перевод исполняется — потому что браузер по-честному приложил куку банка к запросу. Это и есть CSRF, cross-site request forgery: атака не ломает криптографию и не крадёт токен, она просто запускает законный запрос из чужого браузера. Важно понять границы: атакующий не видит ответ — Same-Origin Policy запрещает чужой странице читать содержимое ответа с вашего домена, — но побочный эффект уже исполнен, и этого достаточно. Схема с заголовком Authorization из [восемнадцатого урока](/courses/jwt-go/lessons/18-avtorization-zagolovok/) к вектору почти нечувствительна: чужой скрипт не умеет достать токен из памяти фронтенда и не может выставить заголовок. Уязвимы cookie-сессии из [шестнадцатого урока](/courses/jwt-go/lessons/16-httponly-cookie/) — именно их автоматика и делает возможной атаку.

### Вектор атаки: чужая форма и ваша кука

Механика по шагам. Первое: пользователь залогинен в вашем сервисе, кука access лежит в браузере. Второе: пользователь открывает страницу атакующего — пост на форуме, комментарий, письмо в HTML-рассылке. Третье: на этой странице стоит форма с action на ваш эндпоинт и автосабмитом — один вызов form.submit() в скрипте, а то и просто большая невидимая кнопка. Четвёртое: браузер отправляет кросс-сайтовый POST и, если политика SameSite разрешает, прикладывает куку вашего домена. Пятое: сервер видит валидную сессию и исполняет действие, не задавая себе вопрос, кто инициатор. Тонкость, которую часто пропускают: кука отправляется по правилам домена, а не страницы-инициатора — браузеру всё равно, откуда пришла форма. Отсюда и название атаки: подделка кросс-сайтового запроса, когда «подделывается» контекст, а не данные. Сервер честно думает, что действие запросил владелец сессии, хотя решение принимала чужая страница.

### Уязвимый хендлер, каким его рисуют в атаке

```go
func handleTransfer(w http.ResponseWriter, r *http.Request) {
        if r.Method != http.MethodPost {
                http.Error(w, "только POST", http.StatusMethodNotAllowed)
                return
        }
        to := r.PostFormValue("to")
        amount, err := strconv.ParseInt(r.PostFormValue("amount"), 10, 64)
        if err != nil || to == "" {
                http.Error(w, "битые параметры", http.StatusBadRequest)
                return
        }
        claims, ok := UserFromContext(r.Context())
        if !ok {
                http.Error(w, "нет сессии", http.StatusUnauthorized)
                return
        }
        bank.Transfer(claims.Subject, to, amount) // аутентификация есть, происхождения — нет
        w.WriteHeader(http.StatusOK)
}
```

Хендлер делает всё правильно по материалам предыдущих уроков: метод проверен, параметры разобраны, сессия вытащена из контекста middleware. Отсутствует ровно одно: проверка того, что запрос инициирован вашим сайтом. Для cookie-сессии этого мало — валидная кука говорит «в браузере залогинен владелец», но не «владелец нажал кнопку сам».

### SameSite cookie: Lax, Strict и None

Атрибут SameSite у куки управляет кросс-сайтовыми отправками, и это первый слой защиты. SameSite cookie со значением Lax пропускает куку только в безопасных переходах верхнего уровня — клик по ссылке, редирект с GET, — но не в кросс-сайтовых POST, iframe и fetch. Strict запрещает отправку с чужого сайта вообще: кука уезжает, только если запрос родился на вашем же домене. None разрешает всё, но по современным правилам обязан идти в паре с Secure — без него браузеры куку молча выбросят. Полезный факт эксплуатации: с февраля 2020 года Chrome для кук без явного SameSite применяет Lax по умолчанию, Firefox и Safari подтянулись позже, но рассчитывать на чужой дефолт — плохая стратегия: старые встроенные браузеры и клиенты живут долго. Практическая связка для jwt-сервиса: access-куке — Lax, refresh-куке из [девятнадцатого урока](/courses/jwt-go/lessons/19-zhiznennyj-cikl/) — Strict, потому что refresh не нужен ни в каких переходах по ссылкам, только собственные вызовы на /refresh.

### Lax закрывает POST: что остаётся вектором

Почему Lax закрывает большинство векторов: классический CSRF требует POST — автосабмит формы, а POST с чужого сайта Lax отсекает. Остаётся список исключений, и он короткий, но реальный. Первое: GET-эндпоинты с побочным эффектом — отписка по ссылке, подтверждение email с одновременным логином, «выйти со всех устройств» по клику из письма; переход верхнего уровня Lax пропускает, и кука уедет. Второе: None-куки, которые ставят для кросс-доменных фронтендов — там CSRF-токен обязателен всегда. Третье: наследие — SameSite cookie поддержка появилась в основных браузерах в 2017-2020 годах, и если ваша аудитория включает старые WebView Android-приложений, слой CSRF-токена не отменяется. Отдельно помните, что Strict имеет свою цену: пользователь, пришедший по ссылке из письма, окажется разлогиненным — кука не поедет с переходом, и первое действие придётся делать после повторного входа. Для refresh-куки это приемлемо, для access-куки витрины — спорно.

### Ставим флаги: Lax для access, Strict для refresh

```go
func accessCookie(token string) *http.Cookie {
        return &http.Cookie{
                Name: "access", Value: token, Path: "/",
                HttpOnly: true, Secure: true,
                SameSite: http.SameSiteLaxMode,
                MaxAge:   int((15 * time.Minute).Seconds()),
        }
}

func refreshCookie(token string) *http.Cookie {
        return &http.Cookie{
                Name: "refresh", Value: token, Path: "/api/auth/refresh",
                HttpOnly: true, Secure: true,
                SameSite: http.SameSiteStrictMode,
                MaxAge:   int((30 * 24 * time.Hour).Seconds()),
        }
}
```

Два помощника держат флаги в одном месте, как договаривались в уроке про жизненный цикл: расползание cookie-конфигурации по файлам — способ получить куку без HttpOnly в одном хендлере и None в другом. Access получает Lax: сессия должна переживать переход по ссылке с внешней страницы. Refresh получает Strict и узкий Path — его работа выполняется только собственными вызовами приложения.

> **Живое место: GET с побочным эффектом.** Правило «меняет состояние — только POST» ломается чаще, чем кажется: /api/unsubscribe?email=, /logout, /session/revoke-all в GET встречаются в продакшене постоянно — потому что удобно поставить ссылку в письмо. С SameSite Lax такая ссылка — рабочий вектор: клик с чужой страницы — это переход верхнего уровня, кука приложится, эндпоинт исполнится. Лечится жёстко: любые изменяющие операции переводятся на POST, а GET оставляет только чтение. Проверяйте маршруты поиском по хендлерам: каждый GET, который пишет в базу или меняет сессию, — кандидат в уязвимости.

### CSRF-токен: второй слой csrf защита go

SameSite — слой браузера, и положиться только на него нельзя: у токена должен быть второй, серверный слой. csrf защита go строится на идее простого неравенства: атакующий может отправить запрос от имени браузера жертвы, но не может прочитать данные вашего домена — Same-Origin Policy не пускает. Значит, достаточно требовать в каждом изменяющем запросе секрет, который чужая страница достать не умеет. Для cookie-сессий стандарт де-факто — двойная отправка: сервер ставит отдельную куку csrf с токеном без HttpOnly, фронтенд-скрипт читает её и прикладывает значение заголовком X-CSRF-Token; middleware сравнивает значение куки и заголовка. Чужой сайт отправит куку автоматически — но заголовок выставить не сможет: чтобы его узнать, надо прочитать куку вашего домена, а это запрещено. Второй подход — синхронизатор: токен хранится в сессии на сервере и вставляется в формы скрытым полем; он нужен классическим серверным шаблонам, где JS рядом нет.

### Middleware X-CSRF-Token с двойной отправкой

```go
func issueCSRFCookie(w http.ResponseWriter) string {
        tok := make([]byte, 32)
        if _, err := rand.Read(tok); err != nil {
                panic(err)
        }
        val := base64.RawURLEncoding.EncodeToString(tok)
        http.SetCookie(w, &http.Cookie{
                Name: "csrf", Value: val, Path: "/",
                Secure: true, SameSite: http.SameSiteLaxMode,
        }) // без HttpOnly: значение должен читать JS фронтенда
        return val
}

func RequireCSRF(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                c, err := r.Cookie("csrf")
                hdr := r.Header.Get("X-CSRF-Token")
                if err != nil || hdr == "" || subtle.ConstantTimeCompare([]byte(c.Value), []byte(hdr)) != 1 {
                        http.Error(w, "csrf-токен не совпал", http.StatusForbidden)
                        return
                }
                next.ServeHTTP(w, r)
        })
}
```

Кука csrf ставится без HttpOnly сознательно: её клиент — JavaScript, который кладёт значение в заголовок на каждый изменяющий запрос. Сравнение идёт через subtle.ConstantTimeCompare — постоянное время, чтобы не подсказывать перебором длину совпадения. Middleware навешивается только на изменяющие маршруты: GET-запросы токен не несут, и требовать его там — способ сломать все переходы по ссылкам.

### Слои защиты cookie-сессии

| Слой | Что закрывает | Чего не закрывает |
| --- | --- | --- |
| SameSite Lax | кросс-сайтовые POST, iframe, fetch | переходы верхнего уровня по GET-ссылкам |
| SameSite Strict | все кросс-сайтовые запросы | удобство: вход по внешней ссылке без куки |
| CSRF-токен (double submit) | любой запрос без валидного заголовка | сценарии, где XSS уже читает страницу |
| Bearer-схема с токеном в памяти | CSRF по определению нет куки | XSS: скрипт вынесет токен заголовком |
| Правило «состояние — только POST» | GET-векторы при Lax | ничего, если дисциплина нарушена |

### Границы double submit: субдомены и XSS

У двойной отправки есть граница, о которой стоит знать до инцидента: поддоменная инъекция. Если атакующий контролирует любой субдомен вашего сайта — забытый стейджинг, пользовательский контент на blog.example.com, — он может поставить куку на родительский домен, и double submit перестанет быть надёжным: чужая кука тоже сравнится с чужим заголовком. Лечение — привязка CSRF-токена к сессии: токен не просто совпадает с кукой, но и совпадает со значением, выпущенным для конкретной сессии, тогда чужая подмена отсекается. Вторая граница — XSS: если скрипт атакующего уже исполняется на вашей странице, он прочитает csrf-куку так же, как ваш собственный фронтенд, и CSRF-слои тут бессильны — это территория CSP и гигиены зависимостей. Спорный вопрос, который регулярно всплывает в ревью: нужно ли всё это внутренней админке на десять человек. Практичный ответ: SameSite Lax и POST-only — да, обязательны; полноценный CSRF-токен — с момента, когда админка смотрит наружу хотя бы ссылками.

> **Как повторить у себя.** Понадобится локальный Go и два имени в /etc/hosts — bank.local и evil.local на 127.0.0.1: поднимите сервис с Lax-кукой и страницу-атакующего с автосабмитом, и перевод уйдёт; затем добавьте SameSite Strict и убедитесь, что POST перестал доезжать. Браузерная песочница этого курса HTTP-запросов и кук не умеет — она исполняет только fmt, strings, strconv, math, sort, errors и time, поэтому эксперимент с доменами честно делается локально. Файл куки curl с флагом --cookie покажет, какие флаги реально приехали в Set-Cookie.

### Проверка: что пропускает Lax

**Вопрос:** Кука поставлена с SameSite=Lax. Какой кросс-сайтовый запрос поедет с ней?

- POST-форма с автосабмитом со страницы атакующего
- fetch из скрипта чужого сайта на ваш API
- ✅ Переход по обычной ссылке с чужого сайта (top-level GET)
- Загрузка картинки <img src> с вашего домена

Lax разрешает только безопасные переходы верхнего уровня: клик по ссылке, GET-редирект. POST-формы, fetch и субресурсы вроде картинок куку не получают. Отсюда и живое место урока: единственный оставшийся вектор — GET-эндпоинты с побочным эффектом, доступные по ссылке.

### Проверка: где живёт CSRF-токен

**Вопрос:** При двойной отправке где находится CSRF-токен?

- Только в httpOnly куке, недоступной скрипту
- ✅ В куке без HttpOnly и в заголовке X-CSRF-Token одновременно
- Только в теле ответа сервера, фронтенд его пересылает в URL
- В localStorage рядом с access-токеном

Схема так и называется — двойная отправка: одно и то же значение уезжает дважды, кукой и заголовком. Кука без HttpOnly нужна, чтобы JS мог прочитать значение; чужой сайт значение достать не может из-за Same-Origin Policy, поэтому подделать заголовок ему нечем.

### Форма с чужой страницы

```go
// Кука: access, SameSite=Lax
// Страница evil.example автосабмитом отправляет:
// <form method="POST" action="https://api.bank.local/transfer">
//   <input name="to" value="attacker"><input name="amount" value="100">
// </form>
// Что вернёт /transfer?
```

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

- 200: кука валидная, SameSite ей не мешает
- ✅ 401: кука не приложилась, middleware не увидел сессию
- 403: сервер распознал страницу-инициатора
- 500: ParseInt упал на amount

Кросс-сайтовый POST при Lax идёт без куки, middleware из десятого урока не находит сессию и отвечает 401. Сервер не распознаёт инициатора — он просто не получает куку. Тот же сценарий с SameSite None прошёл бы насквозь: вот почему None всегда тянет за собой CSRF-токен.

### Соберите защиту в Set-Cookie и middleware

Допишите флаги куки и проверку заголовка: политика для access-куки, сравнение и статус отказа.

```go
http.SetCookie(w, &http.Cookie{
        Name: "access", Value: token, Path: "/",
        HttpOnly: true, Secure: true,
        SameSite: http.SameSite___Mode,
})
c, err := r.Cookie("csrf")
hdr := r.Header.Get("X-CSRF-Token")
if err != nil || subtle.ConstantTimeCompare([]byte(c.Value), []byte(hdr)) != 1 {
        http.Error(w, "csrf-токен не совпал", http.Status___)
        return
}
```

Подсказка: Access-куке по умолчанию ставят Lax, а несошедшийся CSRF-токен отвечает Forbidden — опознание прошло, действие запрещено.

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

### Соберите проверку CSRF-запроса

Расставьте шаги изменяющего запроса с двойной отправкой в правильном порядке.

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

1. `req.Header.Set("X-CSRF-Token", readCsrfCookie())`
2. `fetch("/api/transfer", { method: "POST", headers: req })`
3. `c, err := r.Cookie("csrf")`
4. `hdr := r.Header.Get("X-CSRF-Token")`
5. `if subtle.ConstantTimeCompare([]byte(c.Value), []byte(hdr)) != 1 { reject() }`
6. `bank.Transfer(user, to, amount)`

### Карточки: CSRF

- **CSRF** — Кросс-сайтовая подделка запроса: чужая страница запускает запрос к вашему API из браузера жертвы, и кука прикладывается автоматически. Ответ атакующий не читает — только побочный эффект.
- **SameSite Lax** — Кука уезжает в безопасных переходах верхнего уровня (клик по ссылке, GET-редирект) и не уезжает в кросс-сайтовых POST, fetch и iframe. Дефолт современных браузеров с 2020 года.
- **SameSite Strict** — Кука не отправляется с чужих сайтов вовсе. Максимальная защита, но вход по ссылке из письма приходит разлогиненным — кука с переходом не едет.
- **Double submit cookie** — CSRF-токен лежит в куке без HttpOnly и прикладывается заголовком X-CSRF-Token. Чужой сайт куку отправит, но прочитать и выставить заголовок не сможет.
- **Постоянное время сравнения** — subtle.ConstantTimeCompare для CSRF-токенов: время ответа не зависит от позиции первого несовпадения и не подсказывает перебор.
- **GET с побочкой** — Отписка, logout, отзыв сессий в GET — дыра, которую Lax не закрывает: переход по ссылке верхнего уровня несёт куку. Правило: меняет состояние — только POST.

Cookie-сессия теперь защищена двумя слоями: браузерным SameSite и серверным токеном. Дальше по курсу: куда едут эти сессии в распределённой системе — [двадцать второй урок про микросервисы](/courses/jwt-go/lessons/22-jwt-mikroservisy/); почему payload можно читать, но нельзя править — [двадцать третий](/courses/jwt-go/lessons/23-poddelka-podpisi/); финальная сборка всего курса — [итоговый auth-сервис](/courses/jwt-go/lessons/25-itogovyj-proekt/). Фундамент, на котором стоит урок: флаги и выдача куки — [шестнадцатый урок](/courses/jwt-go/lessons/16-httponly-cookie/), сравнение cookie-сессий с сессиями на Redis — [урок про сессии в go-redis](/courses/go-redis/lessons/21-sessii-web/), а полный цикл логина и логаута — [девятнадцатый](/courses/jwt-go/lessons/19-zhiznennyj-cikl/). Карта всех тем — [на странице jwt-go](/courses/jwt-go/).

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

- CSRF — атака контекстом: чужая страница запускает запрос из браузера жертвы, кука прикладывается сама.
- SameSite Lax отсекает кросс-сайтовые POST и остаётся дефолтом для access-куки; Strict берём для refresh.
- Второй слой — CSRF-токен: двойная отправка кука плюс X-CSRF-Token и сравнение в постоянном времени.
- GET с побочным эффектом — дыра, которую Lax не закрывает: меняет состояние — только POST.
- Границы double submit: субдоменная инъекция и уже случившийся XSS — там нужны привязка к сессии и CSP.
- Bearer-схема с токеном в памяти CSRF не боится, зато боится XSS — это другая сторона того же компромисса.

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

Соберите стенд из двух доменов на локальном Go: сервис с /transfer и страницу-атакующего с автосабмитом. Прогоните три конфигурации: без SameSite, с Lax и со Strict — и запишите, в каких уходит POST-форма. Затем добавьте middleware X-CSRF-Token с двойной отправкой и проверьте, что легитимный фронтенд проходит, а страница-атакующий ловит 403 даже без SameSite. В конце найдите в собственном коде все GET-маршруты, которые меняют состояние, и переведите их на POST.

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

### Мы храним токен в localStorage и шлём через Authorization. CSRF всё ещё нужен?

Сам CSRF-слой — нет: куки нет, прикладывать нечего. Но платить приходится в другой валюте: любой XSS читает localStorage и уносит токен навсегда, тогда как при cookie-сессии он может лишь слать запросы. Выбор между схемами разобран в уроке про хранение токена.

### Достаточно ли проверки заголовка Origin или Referer?

Это полезный третий слой, но не замена: Origin может отсутствовать в некоторых переходах и старых клиентах, а фильтры по списку доменов регулярно ломаются при добавлении нового окружения. Стандартная связка — SameSite плюс CSRF-токен, Origin — бонусный лог-сигнал.

### Почему CSRF-кука без HttpOnly — это безопасно?

Потому что её компрометация не даёт ничего сверх того, что XSS и так получил бы: скрипт с заражённой страницы может слать запросы напрямую. HttpOnly куке нужен access-токен, csrf-куке — нет; её единственная работа — быть прочитанной легитимным JS и приложенной заголовком.
