---
title: "CORS: чиним браузерные запросы"
description: "Почему браузер пишет blocked by CORS policy и как это чинить: preflight OPTIONS, заголовки Access-Control-Allow-Origin и cors.Handler в chi. Отладка и примеры."
course: "Chi: роутер для Go HTTP-сервисов"
url: "https://golangmaster.ru/courses/chi/lessons/14-cors/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

# CORS: чиним браузерные запросы

*Курс «Chi: роутер для Go HTTP-сервисов», урок 14 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/chi/lessons/14-cors/*

Фронтенд на localhost:3000 стучится в API на :8080, сервер отрабатывает со статусом 200, а в консоли браузера краснеет blocked by CORS policy — и именно здесь chi cors превращает загадку в настройку из одного middleware. Разберём preflight, заголовки Access-Control-Allow-Origin, поля cors.Options и отладку ошибок, которых не видно на сервере.

Сцена, которую видел каждый: фронтенд-разработчик запускает приложение с localhost:3000, API на том же ноутбуке слушает :8080, и каждый fetch падает с ошибкой про CORS — при том что в логах сервера все запросы зелёные и со статусом 200. Сервер невиновен, браузер не сломан: это Same-Origin Policy, правило браузера о том, что страница с одного происхождения не может без разрешения читать ответы другого. CORS — Cross-Origin Resource Sharing — и есть формат этого разрешения: сервер объявляет заголовками, кому и что можно. В chi разрешение включается одним middleware из экосистемы роутера. В этом уроке — кто блокирует запросы на самом деле, что такое preflight и почему он не доходит до ваших хендлеров, из чего состоит cors.Options и как отлаживать ошибку, причиной которой обязан сервер, а симптомом — страдает только браузер.

### CORS: блокирует не сервер, а браузер

Same-Origin Policy — защита пользователя: вкладка с сайта банка не должна уметь тихо читать ответы вашего API, потому что вместе с ними уйдут cookies. Совпадение происхождения означает схему, домен и порт — localhost:3000 и localhost:8080 считаются разными; сами термины разобраны в [глоссарии](/glossary/). CORS — механизм, которым сервер добровольно ослабляет это правило: в ответе появляется заголовок Access-Control-Allow-Origin, и браузер решает, доверять ли ему. Отсюда главный факт урока: CORS исполняет исключительно браузер. curl, Postman и любой серверный клиент про заголовки согласия ничего не спрашивают и получают ответ как обычно. Если Postman показывает данные, а fetch в браузере — ошибку, значит сервер просто не отдал разрешения, и чинить надо его конфигурацию, а не фронтенд. Модуль go-chi/cors — самый ходовой вариант в cors go-экосистеме, и подключается он как обычный middleware.

### cors.Handler в цепочке

```go
package main

import (
        "net/http"

        "github.com/go-chi/chi/v5"
        "github.com/go-chi/cors"
)

func main() {
        r := chi.NewRouter()

        r.Use(cors.Handler(cors.Options{
                AllowedOrigins:   []string{"http://localhost:3000"},
                AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE"},
                AllowedHeaders:   []string{"Content-Type", "Authorization"},
                AllowCredentials: true,
                MaxAge:           300,
        }))

        r.Get("/api/notes", listNotes)
        r.Post("/api/notes", createNote)

        http.ListenAndServe(":8080", r)
}
```

cors.Handler живёт в отдельном модуле github.com/go-chi/cors — добавьте его go get-ом рядом с chi. Регистрация через r.Use до маршрутов, как у любого middleware: слой ставит разрешающие заголовки на каждый ответ и сам отвечает на preflight. Обратите внимание на AllowedOrigins: точный адрес фронтенда, а не звёздочка — вместе с AllowCredentials: true звёздочка не работает, почему — ниже. Для запуска нужен локальный Go: песочница сайта HTTP-сервер не поднимает.

### Preflight: невидимый OPTIONS-запрос

Перед многими запросами браузер делает предварительный: отправляет OPTIONS с заголовком Origin — откуда пришла страница — и Access-Control-Request-Method — какой метод собирается использовать; если запрос несёт кастомные заголовки вроде Authorization, к ним добавляется Access-Control-Request-Headers. Это preflight, предзапрос: браузер сначала спрашивает разрешения, и лишь получив заголовки согласия в ответе, отправляет настоящий запрос. cors.Handler перехватывает preflight сам и отвечает, не доходя до ваших хендлеров — в логах это выглядит как OPTIONS, которого вы не регистрировали. Если источник не в списке разрешённых, preflight уходит без заголовков согласия — в go-chi/cors с кодом 403 — и настоящий запрос не отправляется вовсе: браузер отказывается сам, не тратя трафик на заведомо запрещённый вызов. Посмотреть preflight глазами curl можно в следующем блоке.

### Preflight глазами curl

```go
# preflight: браузер спрашивает разрешение до отправки POST
$ curl -i -X OPTIONS http://localhost:8080/api/notes \
    -H "Origin: http://localhost:3000" \
    -H "Access-Control-Request-Method: POST" \
    -H "Access-Control-Request-Headers: content-type"

HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 300
```

Запрос OPTIONS на маршрут, которого в коде нет, — это preflight. cors.Handler видит Origin и Access-Control-Request-Method, сверяет их с cors.Options и отвечает сам: разрешение с нужными заголовками, до хендлера дело не доходит. Смените Origin на чужой домен — заголовки согласия исчезнут, и браузер такой preflight забракует. Тем же способом проверяют настройки, когда fetch падает: ответ curl показывает, чего именно не хватает.

### Проверка: анатомия preflight

**Вопрос:** Что браузер отправляет в preflight перед POST с application/json?

- Только заголовок Origin
- ✅ OPTIONS с Origin и Access-Control-Request-Method
- POST с заголовком Access-Control-Allow-Origin
- OPTIONS с Access-Control-Allow-Methods в теле

Preflight — это OPTIONS с Origin (откуда страница) и Access-Control-Request-Method (какой метод планируется); кастомные заголовки перечисляются в Access-Control-Request-Headers. Заголовки Allow-* наоборот приходят в ответе сервера — это его согласие, а не вопрос браузера.

### Простые и непростые запросы

Preflight нужен не всем. Простыми браузер считает запросы, которые исторически умели отправлять HTML-формы: GET, HEAD и POST с телом в text/plain, application/x-www-form-urlencoded или multipart/form-data и без кастомных заголовков — их браузер шлёт сразу, а проверяет уже заголовки ответа. Всё остальное требует preflight. И вот тут ошибка номер один при отладке JSON API: POST с Content-Type: application/json — непростой, потому что такой content-type формы не умеют. Разработчик смотрит на обычный POST и не понимает, откуда в логе OPTIONS. Любая авторизация через заголовок, например Authorization, тоже делает запрос непростым — как и большая часть [настоящих JSON API](/courses/chi/lessons/11-json-api/). Проверка простая: если fetch падает, а в логе сервера нет входящего запроса — отказал preflight, и до хендлера дело не дошло.

> **Звёздочка и credentials несовместимы.** AllowedOrigins: []string{"*"} вместе с AllowCredentials: true браузер не примет: при запросах с cookies безликая звёздочка в Access-Control-Allow-Origin запрещена, разрешение обязано называть точный Origin. Симптом: запрос с куками падает с CORS-ошибкой, хотя звёздочка стоит. Вторая ловушка того же рода: несколько Origin нельзя складывать через запятую в один заголовок — каждое происхождение это отдельное значение, и middleware решает эту задачу за вас.

### cors.Options: что настраивать

cors.Handler принимает структуру cors.Options, и почти вся настройка сводится к четырём полям: AllowedOrigins — какие происхождения пускать, AllowedMethods — какие HTTP-методы, AllowedHeaders — какие заголовки запроса разрешить сверх простых, AllowCredentials — можно ли слать cookies и авторизацию. Поле ExposedHeaders пропускают часто, а зря: без Access-Control-Expose-Headers браузер не даст JavaScript прочитать кастомные заголовки ответа — например Request-ID из [урока про встроенные middleware](/courses/chi/lessons/06-vstroennye-middleware/). MaxAge говорит браузеру, сколько секунд кэшировать ответ на preflight: чем больше, тем реже летят лишние OPTIONS. Разрешать стоит узко: конкретный домен фронтенда, а не звёздочку, и только реально используемые методы и заголовки — каждый пункт списка это доступ, который вы выдаёте чужим страницам.

### Поля cors.Options

- AllowedOrigins — список допустимых происхождений или звёздочка
- AllowedMethods — HTTP-методы, которые разрешено отправлять
- AllowedHeaders — кастомные заголовки запроса: Authorization, Content-Type, X-Request-ID
- ExposedHeaders — заголовки ответа, которые JavaScript сможет прочитать
- AllowCredentials — разрешение на cookies и авторизацию в запросе
- MaxAge — на сколько секунд браузер кэширует ответ на preflight

### Проверка: что видит JavaScript

**Вопрос:** Сервер обработал POST и вернул 200, но без Access-Control-Allow-Origin. Что увидит JavaScript в браузере?

- 200 и тело ответа: CORS влияет только на заголовки
- ✅ Ошибку CORS: ответ заблокирован браузером
- 403 от сервера, потому что источник запрещён
- Автоматический повтор запроса без cookies

Разрешения нет — браузер не отдаст ответ скрипту: fetch упадёт с ошибкой про CORS. Сервер при этом честно обработал запрос: блокирует не он, а браузер. Поэтому ошибка CORS лечится настройкой заголовков на сервере, а не правкой кода фронтенда.

### Что решит cors.Handler?

```go
package main

import "fmt"

func main() {
        allowed := map[string]bool{
                "http://localhost:3000": true,
        }

        requests := []string{
                "http://localhost:3000",
                "https://evil.example.net",
        }

        for _, origin := range requests {
                if allowed[origin] {
                        fmt.Println(origin, "-> заголовки согласия")
                } else {
                        fmt.Println(origin, "-> 403, заголовков нет")
                }
        }
}
```

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

- Обе строки: -> заголовки согласия
- ✅ localhost -> заголовки согласия; evil -> 403, заголовков нет
- localhost -> 403, заголовков нет; evil -> заголовки согласия
- Программа не скомпилируется: map[string]bool

cors.Handler решает точно так же: Origin из списка получает ответ с Access-Control-Allow-Origin и разрешением на запрошенный метод; чужой — отказ без заголовков согласия, в go-chi/cors со статусом 403. Браузер на второй вариант отвечает ошибкой CORS ещё до отправки настоящего запроса.

### Соберите подключение CORS

Впишите имя функции-обёртки и два поля cors.Options: список доменов и разрешение на заголовки авторизации.

```go
import "github.com/go-chi/cors"

r.Use(cors.___(cors.Options{
    ___: []string{"https://app.example.com"},
    AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
    ___:            []string{"Authorization", "Content-Type"},
}))
```

Подсказка: Первый пропуск — функция из пакета cors; два остальных — поля Options: домены и заголовки запроса.

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

### Отладка CORS: смотреть надо на сервер

Ошибка CORS в консоли браузера похожа на приговор без деталей: браузер сознательно скрывает подробности, чтобы не отдавать чужому сайту информацию о вашем ответе. Поэтому отлаживают со стороны сервера. Первым делом повторите preflight curl-ом: OPTIONS с заголовками Origin и Access-Control-Request-Method — и смотрите, какие Access-Control-* заголовки пришли в ответ. Пришли и правильные — дело в самом запросе: проверяйте, совпадает ли Origin посимвольно, включая порт и https. Не пришли — перечитайте AllowedOrigins, AllowedMethods и AllowedHeaders: чаще всего не хватает одного пункта из Access-Control-Request-Headers. Полезно логировать Origin в middleware: в многодоменных проектах разрешение регулярно забывают добавить для нового домена, и лог это покажет сразу.

### Сценарии запросов: что делает браузер

| Сценарий | Браузер | Что нужно серверу |
| --- | --- | --- |
| GET без кастомных заголовков | отправляет сразу, проверяет заголовки ответа | Access-Control-Allow-Origin в ответе |
| POST с application/json | сначала preflight OPTIONS, затем настоящий запрос | согласие на метод и Content-Type в preflight |
| Запрос с Authorization | сначала preflight с Access-Control-Request-Headers | AllowedHeaders, содержащий Authorization |
| Запрос с cookies (credentials) | требует точный Origin вместо звёздочки | точный Origin и AllowCredentials: true |

### Соберите сервер с CORS

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

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

1. `r := chi.NewRouter()`
2. `r.Use(middleware.RequestID)`
3. `r.Use(cors.Handler(cors.Options{AllowedOrigins: []string{"http://localhost:3000"}}))`
4. `r.Use(middleware.Recoverer)`
5. `r.Get("/api/notes", listNotes)`
6. `http.ListenAndServe(":8080", r)`

### Продакшн-настройка cors.Options

```go
r.Use(cors.Handler(cors.Options{
        AllowedOrigins:   []string{"https://app.example.com"},
        AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
        AllowedHeaders:   []string{"Content-Type", "Authorization", "X-Request-ID"},
        ExposedHeaders:   []string{"X-Request-ID"},
        AllowCredentials: true,
        MaxAge:           300,
}))
```

Полновесные настройки для реального домена: ExposedHeaders открывает X-Request-ID для JavaScript, MaxAge на 300 секунд кэширует preflight — пять минут браузер не дёргает сервер повторными OPTIONS. OPTIONS в списке методов можно не перечислять: cors.Handler отвечает на preflight сам, но явный метод не помешает, если preflight где-то проверяется ещё и вручную. От localhost до продакшена — замена одного значения в AllowedOrigins; конфигурацию выносят в переменные окружения, чтобы домен менялся без пересборки. Проверяйте всё локальным Go и двумя вкладками браузера: с разрешённого домена и с чужого.

> **Кастомные заголовки ответа не видны без ExposedHeaders.** Пусть сервер кладёт в ответ X-Request-ID или X-RateLimit-Remaining. В браузере response.headers.get("X-Request-ID") вернёт null: по умолчанию JavaScript видит только простые заголовки вроде Content-Type. Добавьте ExposedHeaders: []string{"X-Request-ID"} в cors.Options — и заголовок станет читаемым. Отсюда же правило отладки: в JS пусто, а в curl заголовок виден — не хватает ExposedHeaders, а не Allow-Origin.

### CORS защищает не сервер, а пользователя

Посыл, который стоит проговорить в конце: выключите CORS — и сервер не пострадает ни на бит. Браузерное правило не действует на curl и вредоносные скрипты вне браузера: они и так стучатся в ваш API без всяких разрешений. CORS нужен для другого: чтобы вкладка с чужого сайта не смогла от имени вашего пользователя — с его cookies — читать ответы вашего API. Поэтому честная настройка CORS — вопрос пользовательской безопасности, а не защиты сервера; защита сервера — это токены, авторизация и предохранители вроде таймаутов из [прошлого урока](/courses/chi/lessons/13-timeout-throttle/). И обратное следствие: CORS не авторизация. Если часть API рассчитана только на ваш фронтенд, надеяться, что отсутствие заголовков её защитит, — ошибка: любой не-браузерный клиент заголовки игнорирует.

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

- **Что исполняет CORS?** — Браузер. Он проверяет заголовки Access-Control-* в ответе сервера; curl и Postman про CORS ничего не знают.
- **Что такое preflight?** — Предварительный OPTIONS-запрос с Origin и Access-Control-Request-Method; браузер шлёт его перед непростыми запросами.
- **Как подключить CORS в chi?** — r.Use(cors.Handler(cors.Options{...})) из модуля github.com/go-chi/cors, до регистрации маршрутов.
- **Почему звёздочка не работает с credentials?** — Браузер запрещает Access-Control-Allow-Origin: * вместе с куками — нужен точный Origin из списка AllowedOrigins.
- **Что даёт MaxAge?** — Сколько секунд браузер кэширует ответ на preflight и не отправляет повторные OPTIONS.

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

- CORS исполняет браузер: без заголовков согласия он прячет ответ от JavaScript.
- Preflight — OPTIONS с Origin и Access-Control-Request-Method перед непростыми запросами.
- cors.Handler из github.com/go-chi/cors вешается через r.Use и отвечает на preflight сам.
- AllowedOrigins — точные домены: звёздочка несовместима с AllowCredentials.
- ExposedHeaders открывает кастомные заголовки ответа, MaxAge кэширует preflight.
- Ошибка CORS чинится на сервере, хотя видна только в консоли браузера.
- CORS не авторизация: серверный клиент его игнорирует.

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

Подключите cors.Handler к сервису из прошлых уроков: разрешите Origin фронтенда с портом, методы GET и POST и заголовок Content-Type. Проверьте curl-ом preflight на POST с application/json, затем уберите домен из AllowedOrigins и убедитесь, что заголовки согласия исчезли, а fetch в браузере упадёт с ошибкой CORS.

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

### Можно ли писать Access-Control-Allow-Origin вручную в хендлерах?

Технически да: w.Header().Set("Access-Control-Allow-Origin", ...) в каждом хендлере. Но preflight-OPTIONS придётся обрабатывать отдельно, а один новый домен превращается в правку десятков мест. cors.Handler делает то же самое централизованно.

### Почему в Postman нет ошибок CORS, а в браузере есть?

CORS исполняет только браузер: Postman, curl и серверные клиенты отправляют запросы без проверок. Если Postman возвращает данные, а fetch — ошибку, сервер работает, но не отдал заголовков согласия. Чинить нужно cors.Options.

### Нужен ли отдельный маршрут для OPTIONS?

Нет: cors.Handler перехватывает preflight до маршрутизации и отвечает сам. Отдельный r.Options нужен только без middleware или когда на preflight должна быть особая логика — обычно это не требуется.
