---
title: "Пароли: bcrypt вместо MD5"
description: "bcrypt в Go: GenerateFromPassword и CompareHashAndPassword из x/crypto, соль и cost, почему MD5 не для паролей и как устроено хеширование паролей golang."
course: "JWT-аутентификация в Go: golang-jwt и bcrypt"
url: "https://golangmaster.ru/courses/jwt-go/lessons/09-bcrypt-paroli/"
published: "2026-09-19"
updated: "2026-10-02"
minutes: 18
---

# Пароли: bcrypt вместо MD5

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

bcrypt в Go — стандартный ответ на вопрос, что делать с паролями пользователей: хеширование паролей через golang.org/x/crypto/bcrypt делает утечку базы дорогой для нападающего, а логин — простым для вас. Разбираем GenerateFromPassword и CompareHashAndPassword, соль внутри хеша, стоимость cost и почему MD5 и SHA256 для паролей не годятся вовсе.

Сценарий, от которого защищается весь урок: база утекла. Дамп попал в чужие руки вместе с колонкой users.password — и теперь единственное, что стоит между паролями пользователей и их взломом, — то, как вы эту колонку заполняли. Открытый текст: взломано мгновенно, все переустановки паролей и письма с извинениями не спасут репутацию. MD5 или SHA256: пароли не открытым текстом, но оба алгоритма проектировались быть быстрыми, и в этом их приговор: на видеокарте перебираются миллиарды вариантов в секунду, а словарь частых паролей прогоняется по дампу за минуты. Хеширование паролей golang решает это иначе: для паролей нужны медленные хеши с настройкой медленности и встроенной солью — bcrypt из официального расширения golang.org/x/crypto. Он ничего не шифрует: хеш нельзя развернуть обратно, а проверка работает пересчётом. В этом уроке — регистрация с GenerateFromPassword, вход с CompareHashAndPassword, разбор формата хеша и подбор стоимости. Здесь же и граница темы: JWT из прошлых уроков выдаётся только после того, как пароль сошёлся, — bcrypt это самое начало цепочки логина.

### Регистрация: хешируем пароль перед сохранением

```go
func register(w http.ResponseWriter, r *http.Request) {
        var in struct {
                Login    string
                Password string
        }
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
                http.Error(w, "битое тело запроса", http.StatusBadRequest)
                return
        }

        hash, err := bcrypt.GenerateFromPassword(
                []byte(in.Password), bcrypt.DefaultCost,
        )
        if err != nil {
                http.Error(w, "не удалось хешировать пароль", http.StatusInternalServerError)
                return
        }

        err = saveUser(r.Context(), in.Login, string(hash))
        if err != nil {
                http.Error(w, "логин занят", http.StatusConflict)
                return
        }
        w.WriteHeader(http.StatusCreated)
}
```

GenerateFromPassword принимает пароль байтами и стоимость; внутри она сама генерирует случайную соль и возвращает готовый хеш строкой из шестидесяти символов — отдельную колонку под соль заводить не нужно. В базу идёт string(hash) и больше ничего: пароль в открытом виде не существует нигде, кроме полученного запроса.

### bcrypt go: соль, cost и формат хеша

Хеш bcrypt — строка вроде $2a$10$N9qo8uLOickgx2ZMRZoMye... — и она читается слева направо как анкета. Первые четыре символа — версия алгоритма, дальше два знака стоимости: десятка из примера выше — это cost. Затем двадцать два символа соли в собственной кодировке bcrypt и тридцать один символ собственно хеша: вместе ровно шестьдесят. Соль здесь — самое важное инженерное решение: она случайна для каждого вызова GenerateFromPassword, поэтому один и тот же пароль двух пользователей даёт два разных хеша, и радужные таблицы, заранее посчитанные для миллиона популярных паролей, становятся бесполезны — нападающему пришлось бы считать их заново для каждой соли. Стоимость cost управляет медленностью: это степень двойки в раундах подготовки ключа, каждый плюс один к cost удваивает время хеширования. В пакете заданы границы: bcrypt.MinCost, bcrypt.DefaultCost и bcrypt.MaxCost; по умолчанию берётся десятая стоимость — десятки миллисекунд на типичном сервере, точное число меряйте у себя. Границы пакета защищают и от наивной экономии: cost ниже минимума или выше максимума вернёт ошибку недопустимой стоимости.

> **Живое место: экономия на cost.** Cost четыре ради скорости в тестах, случайно уехавший в прод — история, которую я видел не раз: логин летает, а перебор паролей по утёкшему дампу становится быстрым и дешёвым, ровно чего bcrypt должен был не дать. Моё правило: DefaultCost в коде, а настройка — переменной окружения, потому что цена миллисекунд на логине не окупается скоростью перебора у нападающего. Честная граница исключения: тяжёлые фоновые пакетные проверки с тысячами вызовов могут взять cost поменьше — но тогда это явно вынесенная константа, а не дефолт по забывчивости.

### Вход: CompareHashAndPassword и пересчёт

Проверка пароля не разворачивает хеш — она пересчитывает. CompareHashAndPassword читает из сохранённого хеша соль и стоимость, прогоняет присланный пароль через те же шаги и сравнивает результаты: совпало — ошибка nil, не совпало — bcrypt.ErrMismatchedHashAndPassword. Из механики пересчёта следуют три практических правила. Первое: никогда не сравнивайте хеши сами — ни строками, ни повторным хешированием с ручной сверкой: пакет сравнивает в постоянном времени, чтобы утечка по времени не подсказывала наполовину угаданные символы; собственное сравнение этот слой защиты выбрасывает. Второе: соль отдельно не хранят и не передают — она уже внутри хеша, и второй колонки в схеме таблицы не заводить. Третье: ответ на неверный логин и неверный пароль — одинаковый, «неверный логин или пароль» со статусом 401: уточнение «такого пользователя нет» превращает эндпоинт в перечислитель зарегистрированных логинов. Ошибку пакета наружу не пробрасываем — она для лога, а не для клиента; честные ответы API уже разбирались в уроке про ошибки токенов, и принцип здесь тот же.

### Логин: сверка пароля с хешем

```go
func login(w http.ResponseWriter, r *http.Request) {
        var in struct {
                Login    string
                Password string
        }
        if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
                http.Error(w, "битое тело запроса", http.StatusBadRequest)
                return
        }

        hash, err := loadHash(r.Context(), in.Login)
        if err != nil {
                http.Error(w, "неверный логин или пароль", http.StatusUnauthorized)
                return
        }
        err = bcrypt.CompareHashAndPassword(hash, []byte(in.Password))
        if err != nil {
                http.Error(w, "неверный логин или пароль", http.StatusUnauthorized)
                return
        }

        // пароль сошёлся: здесь выдаётся пара access/refresh из прошлого урока
        access, refresh, err := issuePair(loadUserID(r.Context(), in.Login), "user")
        if err != nil {
                http.Error(w, "не удалось выпустить пару", http.StatusInternalServerError)
                return
        }
        writeJSON(w, http.StatusOK, map[string]string{
                "access": access, "refresh": refresh,
        })
}
```

Обе ветки отказа отвечают одинаково: нет пользователя и не сошёлся пароль клиент не различает — это лишает перечислитель логинов обратной связи. Ошибка пакета уходит в лог, а не в ответ. На успехе выдаётся пара access/refresh из прошлого урока: bcrypt закрывает первый шаг логина, JWT — второй.

### Цена хеширования и подбор cost под себя

Медленность bcrypt — не побочный эффект, а платёж за безопасность, и её стоит измерить руками: тест с GenerateFromPassword на своём железе за минуту покажет, что DefaultCost стоит у вас пятьдесят или сто миллисекунд. Отсюда два практических следствия. Первое — бюджет входа: десятки миллисекунд на логин незаметны человеку, но заметны на пике нагрузки, поэтому генерацию хеша не ставят в горячий путь каждого запроса — она живёт в регистрации и смене пароля, а логин проверяет, что тоже стоит ресурсов, и ограничивается rate limiter-ом. Второе — рост стоимости со временем: железо дешевеет, и cost, безопасный пять лет назад, сегодня слабее; рабочая схема — при успешном входе сравнивать cost сохранённого хеша с текущим стандартом команды и, если он отстал, тихо пересчитывать хеш на новом cost. У bcrypt есть и жёсткий потолок: пароль длиннее семидесяти двух байт алгоритм не принимает, современные версии x/crypto возвращают на этом ошибку ErrPasswordTooLong, так что проверку длины лучше сделать до вызова, а не ловить её на регистрации. Честная оговорка про соседей по x/crypto: argon2id там же и считается современнее — он стойче к GPU-атакам по памяти, но bcrypt остаётся рабочей классикой с двадцатилетней историей и живёт во всех легаси-схемах; менять его на argon2 стоит осознанно, а не по привычке к новизне.

### Вариация: переезд со SHA-256 без сброса паролей

Наследство встречается чаще зелёного поля: в таблице лежат старые SHA-256, а сбрасывать пароли всех пользователей разом нельзя. Рабочий мост — двойное хеширование со флагом схемы в отдельной колонке. Новые записи живут как bcrypt от sha-256-хеша пароля; при входе код сначала считает SHA-256 от присланного пароля, потом сверяет его CompareHashAndPassword с сохранённым bcrypt-хешем. Старые записи проверяются прямым сравнением SHA-256, и каждый успешный вход по ним немедленно пересчитывается на bcrypt со снятием флага. Через пару месяцев активности флаги исчезают, старый столбец удаляется, и схема становится обычной. Граница метода: это временный переход, а не архитектура — держать два механизма вечно значит удваивать поверхность проверки, поэтому срок жизни моста фиксируется в задаче, а не в воспоминаниях.

### Чем пароли хешировать: сравнение по сути

| Алгоритм | Соль | Медленность | Годность для паролей |
| --- | --- | --- | --- |
| MD5 | нет в схеме паролей | микросекунды, ломается GPU | нет: снят даже для контрольных сумм |
| SHA-256 | нужно городить самому | быстрый — это его назначение | нет: скорость против вас |
| bcrypt | встроена в хеш, случайна | настраивается cost, удвоение на единицу | да: рабочая классика |
| argon2id | встроена | настраивается памятью и временем | да: современный выбор x/crypto |

### Правила паролей, которые легко сломать

- Пароль не логируется никогда — ни на регистрации, ни на входе, ни в ошибках; в логах логин допустим, пароль нет.
- Одинаковый ответ на незнакомый логин и неверный пароль: 401 и «неверный логин или пароль».
- Соль не хранится отдельно: она внутри хеша bcrypt, вторая колонка не нужна.
- Сравнение паролей — только CompareHashAndPassword: ручное сравнение строк выбрасывает защиту по времени.
- Длина пароля проверяется до хеширования: потолок bcrypt — 72 байта, современные версии пакета вернут ErrPasswordTooLong.
- Cost хеша растёт со временем: при входе старый слабый cost тихо пересчитывается на текущий.

### Проверка: стоимость по умолчанию

**Вопрос:** Какой cost bcrypt использует по умолчанию в пакете x/crypto?

- ✅ bcrypt.DefaultCost — значение 10
- bcrypt.MinCost — значение 4
- bcrypt.MaxCost — значение 31
- Стоимость обязательна к явному указанию, дефолта нет

GenerateFromPassword принимает cost параметром, и стандартный выбор — константа bcrypt.DefaultCost со значением 10. MinCost и MaxCost — границы допустимого: ниже и выше пакет вернёт ошибку недопустимой стоимости. Каждый шаг вверх по cost удваивает время хеширования.

### Проверка: два пользователя, один пароль

**Вопрос:** Два пользователя зарегистрировались с одинаковым паролем. Что лежит в базе?

- Два одинаковых хеша: bcrypt детерминирован
- ✅ Два разных хеша: соль случайна для каждого вызова GenerateFromPassword
- Один хеш на двоих с двумя ссылками
- Хеш у одного, открытый пароль у второго

Соль генерируется случайно внутри каждого вызова GenerateFromPassword, поэтому одинаковые пароли дают разные строки хешей — и это убийца радужных таблиц: посчитанные заранее таблицы не подходят ни под одну из солей. Сравнение это учитывает: соль извлекается из самого хеша при проверке.

### Проверка: успешное сравнение

**Вопрос:** Что возвращает bcrypt.CompareHashAndPassword на совпавшем пароле?

- true — булево значение
- ✅ nil — ошибки нет
- Строку-подтверждение
- Число совпавших символов

Функция возвращает только error: nil означает, что пересчитанный по соли из хеша пароль сошёлся. При расхождении возвращается bcrypt.ErrMismatchedHashAndPassword, которое сравнивают через errors.Is, если нужно различить её от других причин. Булевой формы у проверки нет.

### Чужой пароль против хеша

```go
hash, _ := bcrypt.GenerateFromPassword(
        []byte("правильный-пароль"), bcrypt.DefaultCost)

err := bcrypt.CompareHashAndPassword(hash, []byte("другой-пароль"))
// что в err и что увидит клиент в login-хендлере урока?
```

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

- nil: хеши одинаковой длины и формата
- ✅ bcrypt.ErrMismatchedHashAndPassword, клиент получает 401 «неверный логин или пароль»
- Паника сравнения строк разной длины
- err равен строке «invalid password»

Пересчёт по соли из хеша даст другую строку, и CompareHashAndPassword вернёт ErrMismatchedHashAndPassword. Хендлер из урока на любую из этих причин отвечает одинаково — 401 без уточнений, поэтому нападиающий не узнает, что пароль именно не подошёл, а не пользователь отсутствует.

Ссылка на предыдущий урок здесь прямая: пара access/refresh из [урока про refresh-токены](/courses/jwt-go/lessons/08-refresh-token/) выдаётся в login-хендлере сразу после успешного CompareHashAndPassword — аутентификация по паролю и аутентификация по токену склеиваются именно на этой строке. Дальше логин обрастает обвязкой: в [уроке про middleware аутентификации](/courses/jwt-go/lessons/10-middleware-auth/) пара станет обязательной на защищённых маршрутах, а [итоговый проект курса](/courses/jwt-go/lessons/25-itogovyj-proekt/) соберёт всё вместе — регистрацию с bcrypt, выдачу пары и роли. Для хранения хешей в настоящей базе пригодится [курс по GORM](/courses/gorm/) — там модель User со строковым полем под шестидесятисимвольный хеш ложится на таблицу в одну строку. Карта всех тем — [на странице курса jwt-go](/courses/jwt-go/). Честная оговорка про запуск: bcrypt запускается и без сервера — локальный Go, go get golang.org/x/crypto/bcrypt и короткий тест генерации со сравнением покажут вам цену DefaultCost на вашем железе быстрее любого текста.

> **Как измерить стоимость у себя.** Мини-тест на две строки: GenerateFromPassword пароля с DefaultCost, вокруг — time.Now и время выполнения. Прогоните трижды и возьмите среднее: это цена одного логина на вашем сервере. Затем запустите с cost на единицу больше и убедитесь, что время удвоилось — так на глаз подбирается значение для переменной окружения вместо дефолта.

### Допишите регистрацию и вход

Восстановите вызовы bcrypt: генерация хеша с дефолтной стоимостью и сравнение на входе.

```go
// регистрация
hash, err := bcrypt.___(password, bcrypt.___)

// вход
err = bcrypt.CompareHashAndPassword(hash, password)
```

Подсказка: Хеш создаёт GenerateFromPassword, а стандартная стоимость в пакете называется DefaultCost.

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

### Соберите проверку пароля на входе

Расставьте строки login-хендлера: от загрузки хеша до тихого отказа.

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

1. `func login(w http.ResponseWriter, r *http.Request) {`
2. `hash, err := loadHash(r.Context(), in.Login)`
3. `err = bcrypt.CompareHashAndPassword(hash, []byte(in.Password))`
4. `if err != nil {`
5. `http.Error(w, "неверный логин или пароль", http.StatusUnauthorized)`
6. `}`

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

- **bcrypt** — Медленный хеш для паролей из golang.org/x/crypto/bcrypt: встроенная случайная соль, стоимость cost, потолок пароля в 72 байта. Хеш — строка из 60 символов вида $2a$10$...
- **GenerateFromPassword** — bcrypt.GenerateFromPassword(password, cost): генерирует соль, возвращает готовый хеш. Cost — константы MinCost, DefaultCost, MaxCost.
- **CompareHashAndPassword** — Проверка пароля пересчётом: извлекает соль и cost из хеша, пересчитывает, сравнивает в постоянном времени. Совпадение — nil, расхождение — ErrMismatchedHashAndPassword.
- **Соль** — Случайные данные внутри хеша: делают одинаковые пароли разными строками и обесценивают радужные таблицы. Отдельно не хранится и не передаётся.
- **cost** — Параметр медленности — степень двойки в раундах: каждый плюс один удваивает время. DefaultCost 10; растёт со временем по пересчёту при входе.

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

- bcrypt из golang.org/x/crypto — медленный хеш для паролей: соль внутри строки, cost настраивает медленность, хеш занимает 60 символов.
- Регистрация — GenerateFromPassword с DefaultCost; в базу идёт string(hash) и ничего больше.
- Вход — CompareHashAndPassword: пересчёт по соли из хеша, совпадение даёт nil, расхождение — ErrMismatchedHashAndPassword.
- Ручное сравнение хешей недопустимо: пакет сравнивает в постоянном времени против утечки по таймингу.
- Одинаковый ответ на неверный логин и неверный пароль — без перечисления зарегистрированных логинов.
- Потолок bcrypt — 72 байта пароля, ErrPasswordTooLong в современных версиях; cost растёт со временем пересчётом при входе.

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

Соберите регистрацию и логин с bcrypt локально: GenerateFromPassword на регистрации, CompareHashAndPassword на входе, пары access/refresh из прошлого урока на успехе. Измерьте время DefaultCost на своём железе, затем проверьте, что неверный пароль и незнакомый логин отвечают одинаково. Добавьте пересчёт хеша при входе, если cost хеша ниже текущего стандарта команды.

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

### bcrypt или argon2id — что выбрать для нового проекта?

Оба из x/crypto и оба рабочие: argon2id современнее и стойче к GPU-атакам за счёт требования памяти, bcrypt — классика с двадцатилетней историей и простыми настройками. Если команда уже живёт с bcrypt — менять без причины не нужно; для нового проекта argon2id оправдан, а главное в любом случае — не MD5 и не SHA-256.

### Почему нельзя просто SHA-256 с солью?

SHA-256 быстрый по замыслу, и скорость играет против вас: миллион вариантов на видеокарте считаются мгновенно, соль лишь убивает радужные таблицы, но не сам перебор. Парольные хеши специально медленные и настраиваемые — bcrypt и argon2 придуманы именно для этого.

### Что делать с паролем длиннее 72 байт?

bcrypt не принимает такие пароли, современные версии x/crypto возвращают ErrPasswordTooLong. Лучшее решение — проверять длину до хеширования и отдавать внятную ошибку. Обрезать пароль молча не стоит: пользователь думает, что защищён длинной фразой, а работает только её кусок.
