Александр Некрасов · Автономное руководство

PHP: полный мануал с примерами, функциями и ООП

Разделы этой страницы
  1. Безопасность: границы доверия, доступ и контекст вывода
  2. Полная карта официальной ветки Security
  3. От реального действия к границам доверия и минимальным правам
  4. CGI, FastCGI, PHP-FPM и Apache: кто выполняет код
  5. Сессия: ID в cookie, серверное состояние и обязательные настройки
  6. Файлы: allowlist, корневой каталог, права и нулевой байт
  7. База: роль, канал, хранение и SQL как четыре разные границы
  8. Ошибки: подробности разработчику, нейтральный ответ посетителю
  9. Внешние данные: тип, форма, смысл, право и место назначения
  10. Скрытие PHP уменьшает подсказки, но не заменяет защиту
  11. Обновления: поддерживаемый стек, стенд, наблюдение и откат
  12. Первая граница безопасности: пользовательский текст остаётся текстом
  13. Данные могут быть корректными и всё равно недопустимыми
  14. Экранирование зависит от места назначения
  15. Пароли, токены и журналы
  16. Защита аккаунта — это жизненный цикл, а не одна функция
  17. Защита должна быть наблюдаемой
  18. Практика: проверьте решение, а не только успешный путь
  19. Применить разные границы в одном кабинете
  20. Функции и методы этой темы
  21. Код и руководство с собой
PHP 8.5.11149 глав4458 карточек справочника
Дерево руководства

Глава 29 · Практика

Безопасность: границы доверия, доступ и контекст вывода

XSS, SQL-инъекции, IDOR, секреты, пароли и опасные функции через задачи магазина.

Полная карта официальной ветки Security

Эта глава следует всей официальной ветке Security, а затем расширяет её рабочими формами, входом, сессиями, файлами, SQL, журналами и жизненным циклом аккаунта. Это карта слоёв: функция PHP не способна исправить права ОС, открытый web-root или избыточные права пользователя базы.

Официальная веткаГде разобрана и что читатель получает
Introduction · General considerationsМодель полномочий процесса, границы доверия, риск и проверка отказов — разделы ниже.
Installed as CGI binary · 4 casesОтдельный разбор CGI/FastCGI: почему интерпретатор и исходники не должны быть публичными.
Installed as an Apache moduleПрава пользователя web-процесса, отдельные writable-каталоги и запрет запуска web-сервера от root.
Session Security · session.security · session.security.iniКороткая карта здесь и полный раздел сессий с настройками и HTTP-стендом.
Filesystem Security · Null bytesAllowlist, realpath, права ОС, ссылки и историческая граница нулевого байта.
Database Security · Design · Connection · Storage · SQL injectionМинимальные права, защищённый канал, хранение секретов, параметры SQL и реальный SQLite-опыт.
Error Reporting · User Submitted DataНейтральный ответ, закрытый журнал, тип/форма/диапазон входа и проверка полномочий.
Hiding PHP · Keeping CurrentУменьшение лишних признаков без иллюзии защиты; обновление всего стека с откатом.

От реального действия к границам доверия и минимальным правам

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

Запрос на скачивание счёта
  1. Браузер прислал cookie сессии и /orders/42/invoice
  2. PHP признал тип ID, но ещё не доверяет значению 42
  3. Сессия подтверждает user_id=7; сервер проверяет, что заказ42 принадлежит7
  4. Код выбирает известный файл счёта; процесс имеет чтение только нужного каталога
  5. Ответ получает пользователь; журнал получает актор7, действие и request_id без cookie и секрета
ПроверкаНа какой вопрос отвечаетЧего не заменяет
Тип, форма, длина, диапазонПохоже ли значение на ожидаемый order_id/email/date?Права читать заказ, SQL-параметры, HTML-вывод.
АутентификацияКто предъявил пароль/сессию?Разрешение на конкретный заказ или действие.
АвторизацияРазрешено ли этому актору действие над этим объектом?Проверку типов и безопасный формат вывода.
Минимальные права процессаЧто сможет код после ошибки или компрометации?Исправление ошибки приложения, но ограничивает последствия.

Абсолютной защиты не бывает: меры выбирают по последствиям и удобству, а затем проверяют не только успешный путь. Маленький сайт тоже получает автоматические запросы. Ищите слабейшую связь: строгий журнал не доказывает личность, если весь вход держится на подменяемом cookie; HTTPS не исправляет SQL-склейку; скрытая кнопка не заменяет серверную авторизацию.

CGI, FastCGI, PHP-FPM и Apache: кто выполняет код

Сначала выясните, кто запускает PHP. В CLI это пользователь терминала. В Apache module код наследует права процесса Apache. В PHP-FPM запрос выполняет пользователь выбранного пула. В старом CGI-сценарии web-сервер вызывает отдельный бинарник. От этого зависит, какие файлы, сокеты, процессы и базы доступны при любой ошибке приложения.

Проверка развёртывания до публикации
  1. Определить SAPI и цепочку: браузер → web-сервер → PHP module/FastCGI/FPM/CGI
  2. Оставить в public только входные PHP-файлы и публичные assets; config/vendor/private/uploads — вне web-root или запрещены сервером
  3. Запустить web-процесс отдельным непривилегированным пользователем; root не нужен для исправления прав записи
  4. Разрешить запись только в uploads/cache/session/log, чтение — только там, где оно требуется
  5. Проверить прямой URL к .env, исходнику, backup, parser/CGI binary и закрытому файлу: каждый должен получить отказ
Официальный случайСохраняемый смыслСовременное решение/граница
CGI: возможные атакиCGI исторически передаёт query как аргументы процесса, а PATH_INFO — как имя документа. PHP-CGI отказывается трактовать аргументы как файл, но прямой URL к интерпретатору всё равно является отдельной поверхностью.Не выставлять php-cgi как скачиваемый/прямо вызываемый файл; обновить PHP и web-сервер; проверить отказ для /cgi-bin/php?... и /cgi-bin/php/....
Case 1: только публичные файлыСтарая модель допускала прямой и перенаправленный способы вызова, если закрытых по паролю/IP документов вообще нет; приложение не должно менять смысл из-за способа вызова.У реального приложения закрытые данные есть почти всегда; public-каталог должен быть отдельным, прямой CGI-вход — закрытым.
Case 2: cgi.force_redirectPHP должен принимать только запрос, прошедший ожидаемое перенаправление; исторический Apache-вариант опирается на нестандартную переменную REDIRECT_STATUS.Директива относится к CGI и только к совместимому web-серверу; она не заменяет актуальную конфигурацию FPM/прокси.
Case 3: doc_root/user_dirОтдельный script-root уменьшает риск показа исходника. CGI строит имя из doc_root (или PHP_DOCUMENT_ROOT) и PATH_INFO; user_dir — отдельное исключение, которое тоже надо учитывать.Не полагаться на одну директиву: отделить web-root, запретить backup/.env, согласовать корни PHP и web-сервера, проверить ответы реальным HTTP.
Case 4: parser вне web treeИнтерпретатор вне дерева сайта не раздаётся как документ. Официальный исторический вариант запускает executable-скрипт через shebang и требует cgi.discard_path для PATH_INFO/PATH_TRANSLATED.Бинарники и конфигурация вне public; shebang-сценарий — исторический Unix CGI, не универсальная инструкция для FPM/Windows.
Apache modulePHP получает права пользователя Apache.Минимальные права, отдельные каталоги записи и серверная авторизация административных маршрутов.

Сессия: ID в cookie, серверное состояние и обязательные настройки

Сессия связывает случайный ID из cookie с серверным состоянием. Она помогает узнать следующий запрос, но не проверяет пароль, право на чужой объект или CSRF сама. Украденный действующий ID имеет возможности своей серверной записи, поэтому нужна защита транспорта, ротация на входе, сроки и отзыв.

Настройка/действиеБазовое решениеПочему одной настройки мало
cookie_lifetime0 для обычной сессии браузераЗакрытие браузера не отзывает уже украденный ID; сервер проверяет idle/absolute сроки.
use_cookies / use_only_cookiesOn / OnID в URL течёт в историю, логи и Referer.
use_strict_modeOnОтклоняет неинициализированный ID, но не уже украденную существующую сессию.
cookie_httponly / cookie_secureOn / On на HTTPSHttpOnly не лечит XSS-действия, Secure требует HTTPS на всём маршруте.
cookie_samesiteLax или Strict по реальному входу/OAuthЭто дополнительный слой CSRF; state-changing запрос всё равно проверяет токен.
use_trans_sidOffВключение deprecated с PHP 8.4; URL — плохой носитель ID.
gc_maxlifetime / GCМинимально разумная уборка + плановая задача backendGC удаляет записи вероятностно и не является проверкой авторизации.
save_pathЗакрытый каталог для пользователя PHPОбщий читаемый каталог на shared host позволяет искать чужие записи.

Полный справочник конфигурации объясняет область изменения, единицы и backend; схема фиксации, ротации и сроков — жизненный цикл; HTTP-проект входа действительно отправляет cookie и проверяет коды 200/303/400/401/403/503. Не используйте ID сессии как CSRF-токен и не печатайте его в HTML или журнал.

Файлы: allowlist, корневой каталог, права и нулевой байт

Если имя файла пришло из query, формы, CSV или API, строка ../ может вывести путь из ожидаемого каталога. Простая приписка расширения этого не исправляет. Нулевой байт из исторического примера PHP.net раньше мог обрезать путь на границе C; современные версии обычно отвергают такой путь, но правило остаётся тем же: внешний ввод не становится безопасным именем файла.

Безопасный выбор документа
  1. Внешнее значение invoice — только логическое имя, не путь
  2. Allowlist переводит invoice в известный invoice.txt
  3. realpath получает канонический существующий путь
  4. Проверка префикса гарантирует нахождение внутри корневого каталога и ловит ссылку наружу
  5. file_get_contents читает выбранный файл; права ОС позволяют процессу только необходимое
Разрешить два документа и отклонить traversal и нулевой байт до файловой операцииСамостоятельный пример
<?php
declare(strict_types=1);

$root = __DIR__ . '/safe-files';
mkdir($root);
file_put_contents($root . '/invoice.txt', 'Счёт №42');
file_put_contents($root . '/help.txt', 'Как оплатить заказ');

function readDocument(string $requested, string $root): ?string
{
    $files = [
        'invoice' => 'invoice.txt',
        'help' => 'help.txt',
    ];
    if (!array_key_exists($requested, $files)) {
        return null;
    }

    $rootPath = realpath($root);
    $filePath = realpath($root . DIRECTORY_SEPARATOR . $files[$requested]);
    if ($rootPath === false || $filePath === false) {
        return null;
    }
    $prefix = $rootPath . DIRECTORY_SEPARATOR;
    if (!str_starts_with($filePath, $prefix)) {
        return null; // В том числе защищает от ссылки наружу каталога.
    }

    $content = file_get_contents($filePath);
    return $content === false ? null : $content;
}

foreach (['invoice', '../secret', "invoice\0.php", 'help'] as $name) {
    $content = readDocument($name, $root);
    echo $name === 'invoice' ? 'invoice' : ($name === 'help' ? 'help' : 'чужое имя');
    echo ': ', $content ?? 'отказ', "\n";
}

unlink($root . '/invoice.txt');
unlink($root . '/help.txt');
rmdir($root);
Ожидаемый результат
invoice: Счёт №42
чужое имя: отказ
чужое имя: отказ
help: Как оплатить заказ

Опыт создаёт два файла только в распакованной папке, читает разрешённые значения и удаляет данные. Наружный путь и строка с нулевым байтом не доходят до filesystem API. Для загрузок дополнительно нужны проверка размера/MIME, случайное серверное имя, карантин и запрет выполнения.

ГраницаЧто проверить
Права ОСКонфигурация и исходники не world-readable; web-процесс не владеет всем проектом и пишет только в выделенные каталоги.
Новый файлИспользовать случайное серверное имя и безопасное создание; не доверять original filename.
Существующий путьAllowlist предпочтительнее; для динамического дерева — realpath и containment с учётом ссылок.
Удаление/заменаАвторизация на объект, защита от гонки, журнал и восстановление; проверка пути должна относиться к тому же объекту, над которым выполняется действие.

Дальше: файлы и каталоги, загрузка файлов и проверка владельца объекта.

База: роль, канал, хранение и SQL как четыре разные границы

У базы четыре разные границы: кто подключается, как защищён канал, какие данные можно вернуть и как строится SQL. Подготовленное выражение отделяет текст запроса от значения, но не решает выбор чужого заказа, избыточные права пользователя базы и шифрование хранилища.

Увидеть SQL-инъекцию при склейке и тот же ввод отклонить до параметризованного запросаСамостоятельный пример
<?php
declare(strict_types=1);

$db = new PDO('sqlite::memory:', null, null, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES => false,
]);
$db->exec('CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, item TEXT)');
$db->exec("INSERT INTO orders VALUES
    (1, 7, 'Книга'),
    (2, 7, 'Кабель'),
    (3, 8, 'Чужой заказ')");

$hostile = '7 OR 1=1';
$unsafeSql = 'SELECT COUNT(*) FROM orders WHERE user_id = ' . $hostile;
echo 'Склейка дала строк: ', $db->query($unsafeSql)->fetchColumn(), "\n";

function ordersForUser(PDO $db, mixed $rawUserId): array
{
    if (!is_string($rawUserId) || !ctype_digit($rawUserId)) {
        return [];
    }
    $userId = (int) $rawUserId;
    if ($userId < 1) {
        return [];
    }

    $statement = $db->prepare(
        'SELECT item FROM orders WHERE user_id = :user_id ORDER BY id'
    );
    $statement->bindValue(':user_id', $userId, PDO::PARAM_INT);
    $statement->execute();
    return $statement->fetchAll(PDO::FETCH_COLUMN);
}

echo 'Тот же внешний ввод: ', json_encode(
    ordersForUser($db, $hostile),
    JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
), "\n";
echo 'Пользователь 7: ', json_encode(
    ordersForUser($db, '7'),
    JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
), "\n";
Ожидаемый результат
Склейка дала строк: 3
Тот же внешний ввод: []
Пользователь 7: ["Книга","Кабель"]

Временная SQLite-база живёт только в памяти процесса. Первая строка намеренно выполняет небезопасный SELECT и показывает три записи вместо двух; данные учебные и не покидают процесс. Рабочая функция сначала требует целочисленную строку, затем привязывает int к :user_id. В настоящем сервисе user_id обычно берут из проверенной сессии, а не из формы.

Официальная темаРабочее решениеОставшаяся граница
Designing DatabasesПриложение подключается не owner/superuser; роли чтения, записи, миграции и отчёта получают только нужные права.Украденные app-credentials всё ещё позволяют всё, что разрешено этой роли.
ConnectingTLS с проверкой сертификата для сетевой БД; секрет не в public/repository/log.TLS защищает канал, но не строку на диске и не скомпрометированный сервер.
Encrypted StorageДанные, которые надо восстановить, шифруют с управлением ключами; пароль сохраняют через password_hash, потому что его восстанавливать не надо.Ключ рядом с ciphertext и общий без ротации сужают пользу шифрования.
SQL InjectionФиксированный SQL + параметры; allowlist для имён столбцов/ORDER BY, потому что placeholders представляют значения.После безопасного SQL всё ещё проверить владельца/роль каждой возвращаемой строки.

Полная работа с подключением, таблицей, транзакцией и ошибкой находится в практической главе PDO. Хранение пароля — в password_*, обратимое шифрование — в Sodium.

Ошибки: подробности разработчику, нейтральный ответ посетителю

Разработчику нужна причина сбоя; посетителю не нужны путь C:\sites\..., SQL, DSN, stack trace или секрет. Поэтому в разработке включают E_ALL, а в опубликованном окружении отключают показ ошибок, оставляют закрытый журнал и связывают две стороны безопасным request_id.

Один сбой — два представления
  1. Операция бросила исключение с внутренним контекстом
  2. Граница HTTP ловит Throwable и не отправляет его message/trace
  3. Закрытый журнал получает request_id, безопасный event и тип ошибки
  4. Пользователь получает нейтральный текст с тем же request_id
  5. Оператор находит событие; секреты, cookie, Authorization и пароль в журнал не пишутся
Связать нейтральный ответ с закрытым журналом и не записать пароль из исключенияСамостоятельный пример
<?php
declare(strict_types=1);

function publicOrderResponse(callable $operation, string $requestId, string $log): string
{
    try {
        $operation();
        return 'Заказ сохранён.';
    } catch (Throwable $error) {
        $record = json_encode([
            'request_id' => $requestId,
            'event' => 'order_save_failed',
            'error_type' => $error::class,
        ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
        file_put_contents($log, $record . "\n", FILE_APPEND | LOCK_EX);
        return 'Не удалось сохранить заказ. Код обращения: ' . $requestId;
    }
}

$log = __DIR__ . '/private-error.log';
$requestId = 'req-demo-001'; // В сервисе создаётся непредсказуемо для запроса.
$answer = publicOrderResponse(
    static fn () => throw new RuntimeException('dsn=mysql:host=secret-db; password=hunter2'),
    $requestId,
    $log
);

echo 'Пользователь: ', $answer, "\n";
$privateRecord = file_get_contents($log);
echo 'Журнал связывает событие: ', str_contains($privateRecord, $requestId) ? 'да' : 'нет', "\n";
echo 'Секрет попал в журнал: ', str_contains($privateRecord, 'hunter2') ? 'да' : 'нет', "\n";
unlink($log);
Ожидаемый результат
Пользователь: Не удалось сохранить заказ. Код обращения: req-demo-001
Журнал связывает событие: да
Секрет попал в журнал: нет

Файл журнала создаётся в распакованной папке и удаляется. Учебный request_id фиксирован ради точного вывода; в приложении он создаётся для каждого запроса и проходит через все сервисы. Не разрешайте query-параметром включать debug или менять display_errors.

Средаdisplay_errorslog_errors / error_logЧто видит клиент
Локальная разработкаOn допустим на закрытом компьютереOn полезенПолная диагностика без реальных секретов.
Тестовый стендOff по умолчанию; доступ разработчика через журнал/APMOn, закрытый путь/сервисТот же нейтральный контракт, что в production.
ProductionOffOn; доступ, очистка переводов строк, срок храненияКод статуса и нейтральное сообщение с request_id.

Внешние данные: тип, форма, смысл, право и место назначения

«Пользовательские данные» — шире формы. Недоверенными приходят query, POST, JSON body, заголовки, cookies, имя загруженного файла, webhook, CSV поставщика и ответ внешнего API. Данные из базы тоже сохраняют происхождение: однажды импортированный HTML не становится безопасным только потому, что теперь лежит в таблице.

Принять строковый ID заказа и наблюдаемо отклонить массив, команду и значение вне диапазонаСамостоятельный пример
<?php
declare(strict_types=1);

function readOrderId(mixed $value): ?int
{
    if (!is_string($value) || $value === '' || !ctype_digit($value)) {
        return null;
    }

    $orderId = (int) $value;
    return $orderId >= 1 && $orderId <= 1_000_000 ? $orderId : null;
}

$requests = [
    'Обычная строка' => '42',
    'Массив вместо строки' => ['42'],
    'Строка с командой' => '42; DELETE',
    'Ноль вне диапазона' => '0',
];

foreach ($requests as $label => $raw) {
    $orderId = readOrderId($raw);
    echo $label, ': ', $orderId === null
        ? "отказ\n"
        : "заказ {$orderId}\n";
}
Ожидаемый результат
Обычная строка: заказ 42
Массив вместо строки: отказ
Строка с командой: отказ
Ноль вне диапазона: отказ

Функция получает mixed, потому что HTTP-клиент способен прислать order_id[]=42. Проверки идут в порядке: тип → непустая запись → только цифры → допустимый диапазон. После этого всё равно требуется авторизация на конкретный заказ.

ШагПример вопросаКогда выполнять
Источник и формаЭто строка, массив или отсутствующее поле? Откуда оно пришло?Сразу на границе запроса/интеграции.
СмыслID положительный? Дата существует? Статус входит в allowlist?До изменения состояния.
ПолномочиеЭтот user_id может изменить именно order_id=42?На сервере перед каждым действием.
Преобразование для назначенияHTML, URL, JSON, SQL или аргумент процесса?Как можно ближе к конкретному месту использования.
НаблюдаемостьКак отличить отказ валидации от сбоя зависимости без утечки ввода?При проектировании ответа и журнала.

Скрытие PHP уменьшает подсказки, но не заменяет защиту

Отключение expose_php, удаление баннера сервера и нейтральные расширения уменьшают случайную выдачу сведений, но не создают границу доступа. Фреймворк, cookie, заголовки и поведение ошибок всё равно могут выдать стек. Атака также может идти без определения технологии.

МераПолезный эффектЧего она не делает
expose_php=OffНе добавляет PHP-версию в стандартный заголовок X-Powered-By.Не исправляет уязвимость и не скрывает приложение полностью.
Нейтральные ответы ошибокНе выдаёт путь, SQL и версии при неудачном вводе.Не заменяет журнал, обновления и проверку доступа.
public без .env/.git/backupПрямой URL не отдаёт типичные секретные файлы.Не отменяет права ОС и проверку конфигурации web-сервера.
«Маскировка» расширенияИногда уменьшает простой fingerprint.Парсить все .html как PHP может ухудшить скорость и расширить поверхность; это не основная защита.

Обновления: поддерживаемый стек, стенд, наблюдение и откат

«PHP обновлён» не означает, что обновлено приложение. В стек входят ОС и web-сервер, PHP runtime и расширения, Composer-зависимости, фреймворк/плагины, контейнерный образ и внешние сервисы. Для каждого нужна поддерживаемая ветка, источник уведомлений, тестовый стенд и понятный откат.

Обновление без прыжка вслепую
  1. Инвентаризация фиксирует версии runtime, расширений, lock-файла и образа
  2. Проверяется официальный lifecycle/advisory и changelog, включая несовместимости и deprecated
  3. Копия данных + staging: миграции, вход, формы, очереди, cron, API и откат
  4. Поэтапный выпуск с метриками ошибок/задержки и проверкой журналов
  5. При проблеме — заранее отрепетированный rollback; затем обновлённый inventory
Плохой сигналПочему опасноЧто фиксировать
«Обновим когда взломают»Автоматические сканеры не ждут размера сайта.Владелец компонента, канал advisory, срок установки критического патча.
Только php -vРасширение или библиотека может иметь отдельную уязвимость.Runtime + modules + OS packages + composer.lock + plugins/images.
Обновление сразу на единственном сервереМиграция или изменение поведения может остановить сервис.Backup, staging, smoke/integration tests, rollback и окно наблюдения.
Неподдерживаемая ветка «ещё работает»Исправление безопасности для неё может не выйти.Дата окончания поддержки и план перехода до неё.

Актуальные даты нельзя навечно зашивать в статью: проверяйте официальные поддерживаемые версии PHP и уведомления каждого поставщика перед решением. Эта глава объясняет процесс, а не объявляет конкретную версию безопасной на все будущие даты.

Первая граница безопасности: пользовательский текст остаётся текстом

В отзыве человек может ввести угловые скобки. Когда сайт вставляет строку в HTML без обработки, браузер может принять её за разметку. Нам нужно показать сам текст, а не дать ему изменить страницу.

Что уже пригодится. переменная и присваивание, массив, объявление и вызов функции

Граница формата
  1. Входная строка от пользователя
  2. Проверяем смысл и допустимый размер
  3. Экранируем для конкретного места вывода
  4. Браузер показывает текст

htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') принимает строку, правила преобразования и кодировку. | объединяет флаги. Результат возвращается строкой; функция не отправляет ответ сама.

Безопасно показываем название товара в HTMLСамостоятельный пример
<?php
declare(strict_types=1);

$title = '<b>Болт & гайка</b>';
$safeTitle = htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo '<p>' . $safeTitle . '</p>';
Ожидаемый результат
<p>&lt;b&gt;Болт &amp; гайка&lt;/b&gt;</p>

В терминале видны HTML-сущности. Браузер покажет буквальный текст <b>Болт & гайка</b>, а не жирную надпись. Это правило для HTML-текста и подходящих кавыченных атрибутов; для JavaScript, URL и SQL нужны их собственные правила.

Следующая ступень. Дальше разделяем проверку формы, права пользователя, защиту запросов SQL и межсайтовые запросы. Одной универсальной «очистки строки» для всех этих мест нет.

Данные могут быть корректными и всё равно недопустимыми

Наглядная модель

У заказа есть номер — но есть ли право его читать?

Корректное число order_id=42 ещё не означает, что посетителю разрешён чужой заказ.

  1. Форма данных

    Принимаем целый положительный order_id. Массив или некорректное значение отклоняем.

  2. Пользователь и право доступа

    По проверенной сессии узнаём пользователя. Чужой заказ ему не выдаём.

  3. Запрос к базе

    Значения передаём параметрами SQL. Проверка доступа не защищает от склейки SQL сама по себе.

  4. Вывод в нужный контекст

    Текст для HTML экранируем как HTML. Для JSON используем JSON-кодирование, а не HTML-замены.

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

Запрос /orders/42 содержит вполне корректное целое число. Но если заказ принадлежит другому покупателю, возвращать его нельзя. Валидация формата не заменяет проверку полномочий. Начинайте проектирование действия с вопроса: кто выполняет операцию, над каким объектом и при каких условиях? Проверку делают на сервере при каждом обращении, включая скачивание файла, AJAX и экспорт. Спрятанная кнопка в интерфейсе не является защитой.

Удобно описывать границу доверия: всё из браузера, внешнего API, импортированного CSV и административной формы считается внешними данными. Даже текст, который уже хранится в вашей базе, не становится безопасным HTML. Сотрудник может вставить вредный фрагмент по ошибке, а интеграция — принести его из каталога партнёра. Разрешение читать запись также не означает разрешение её изменять: роли и условия действия проверяются отдельно.

Авторизация должна быть близка к запросу данных или сервисной операции. Например, выборка WHERE id=:id AND customer_id=:customer_id уменьшает шанс забыть проверку владельца после чтения. Для администратора задайте явную политику. Если объект не должен быть виден посторонним, одинаковый ответ 404 для отсутствующего и чужого объекта не раскрывает факт существования.

Проверка доступа до возврата данныхСамостоятельный пример
<?php
declare(strict_types=1);

function canEditOrder(array $actor, array $order): bool {
    return $actor['role'] === 'manager'
        || ($actor['id'] === $order['customerId'] && $order['status'] === 'draft');
}
$order = ['customerId'=>7,'status'=>'draft'];
foreach ([['id'=>7,'role'=>'customer'],['id'=>8,'role'=>'customer'],
    ['id'=>8,'role'=>'manager']] as $actor) {
    echo canEditOrder($actor,$order) ? "allowed
" : "denied
";
}
Ожидаемый результат
allowed
denied
allowed

Actor создаётся из серверной аутентификации, не из role в POST. Здесь массивы уже имеют проверенную структуру. В проекте ту же политику применяют к HTTP-контроллеру и фоновым командам.

Экранирование зависит от места назначения

Для обычного текста HTML используют htmlspecialchars с UTF-8 и кавычками; для SQL — параметры PDO; для JSON — json_encode; для компонента URL — rawurlencode. Это разные грамматики. HTML-экранирование не лечит SQL-инъекцию. Подготовленный SQL не защищает HTML от XSS. Универсальной функции «сделать строку безопасной для всего» нет.

Не вставляйте внешнюю строку в inline-JavaScript, CSS или имя атрибута. Если данные нужны скрипту, безопаснее получать JSON отдельным запросом либо использовать корректно сериализованный блок данных с подходящими флагами и безопасным чтением. Для ссылок дополнительно проверяют допустимый протокол: htmlspecialchars не запретит javascript:. Разметку, введённую пользователем, очищают специализированным HTML-санитайзером с разрешённым набором элементов; strip_tags не является полноценной политикой безопасности.

Не вызывайте unserialize для недоверенного ввода: восстановление объектов способно запускать магические методы и цепочки библиотечного кода. Для обмена структурированными данными используйте JSON с проверкой схемы. eval, динамический include и shell-команды с пользовательской строкой расширяют поверхность атаки. Нередко безопасная альтернатива — разрешённый список операций и обычный вызов функции без запуска оболочки.

Ссылка в карточке поставщика с разрешённым протоколомСамостоятельный пример
<?php
declare(strict_types=1);

function approvedLink(string $url): ?string {
    if (filter_var($url, FILTER_VALIDATE_URL) === false) return null;
    $scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
    return in_array($scheme,['https','http'],true) ? $url : null;
}
foreach (['https://example.com/catalog','javascript:alert(1)','not a url'] as $url) {
    echo approvedLink($url) === null ? "reject
" : "allow
";
}
Ожидаемый результат
allow
reject
reject

Это политика ссылки для браузера, а не защита HTTP-клиента от SSRF. Перед помещением разрешённого адреса в href всё равно экранируйте кавычки. Серверное скачивание требует более строгой политики адресов.

Пароли, токены и журналы

Пароли хранят как результат password_hash, а проверяют password_verify. Не шифруйте пароль обратимо и не используйте md5 или обычный sha256. Соль и параметры уже входят в формат хеша. Столбец под PASSWORD_DEFAULT закладывайте с запасом, например 255 символов, поскольку алгоритм по умолчанию может измениться. Сложность выбирают измерением на своём сервере: вычисление специально дорогое, поэтому вход требует ограничений частоты.

После успешной проверки используйте password_needs_rehash и при необходимости сохраните новый хеш. Для bcrypt действует предел 72 байта: политика длинных паролей должна учитывать алгоритм, а не молча считать число символов. Argon2id требует поддержки сборкой. Секреты API берут из окружения или хранилища секретов, ограничивают по правам и ротируют; .env не размещают в public и не коммитят. Примеры в документации используют только тестовые значения.

Сообщение «неверная почта или пароль» уменьшает перечисление пользователей. Но одинаковый текст сам по себе не выравнивает время выполнения: для отсутствующего пользователя проверяют заранее подготовленный фиктивный хеш того же класса сложности. Не генерируйте дорогой фиктивный хеш на каждый запрос. MFA, уведомления о входе и серверные лимиты дополняют пароль; IP не является надёжной личностью пользователя.

Защита аккаунта — это жизненный цикл, а не одна функция

От создания хеша до одноразового восстановления сервер должен сохранять одну связь: кто доказал право, какое действие разрешено сейчас и какие старые возможности после изменения надо отозвать. Реальный HTTP-вход и выход показывает cookie, CSRF и смену ID; новые опыты добавляют смену пароля и сброс доступа.

Не смешиватьПочему
password_hash и обычный hashПервый рассчитан на человеческий пароль; второй подходит для высокоэнтропийного reset-validator и контрольных сумм.
Аутентификацию и авторизациюВерный пароль подтверждает аккаунт, но не право читать чужой заказ.
Cookie и полномочиеCookie лишь приносит ID; сервер заново проверяет состояние, срок, версию и права.
SameSite и CSRF-токенОни закрывают разные части межсайтового запроса и применяются слоями.

Защита должна быть наблюдаемой

Производственные ошибки записывают в закрытый журнал, пользователю показывают нейтральный ответ с идентификатором обращения. Не возвращают SQL, файловые пути и конфигурацию. Журналы тоже надо защищать: экранировать переводы строк в пользовательских значениях, исключать Authorization, cookies и тела платёжных запросов, ограничивать срок хранения и доступ.

Для изменений каталога полезен аудит: актор, действие, идентификатор записи, время и безопасное описание изменения. Логирование не отменяет транзакционность. Для высокорисковых действий повторно подтверждают полномочия, проверяют актуальность сессии и обеспечивают защиту от повторов. CSP, HTTPS и заголовки безопасности добавляют уровни защиты, но не исправляют отсутствующую проверку владельца или вывод сырого HTML.

Практика: проверьте решение, а не только успешный путь

Администратор импортировал название товара <img src=x onerror=alert(1)>. Где исправлять: при сохранении в базу или при показе карточки?

Разбор решения

Для обычного названия храните текст, ограничьте его длину и допустимость по правилам каталога, а при HTML-выводе экранируйте. При CSV-экспорте понадобится другая защита от формул, при JSON — сериализация. Исправление только формы импорта оставляет уязвимыми другие источники.

Применить разные границы в одном кабинете

Полный локальный кабинет соединяет проверки: типы полей → CSRF → пароль → новый ID → сроки → безопасный вывод → POST-выход. Отдельно сохранены все правила XSS/SQL/SSRF и доверия этой главы: ни одна из этих проверок не заменяет остальные.

ГраницаЧто видно в проекте
Тип вводаpassword[] даёт400, а не TypeError посреди страницы.
CSRFPOST без правильного токена даёт403 и не выполняет выход.
АутентификацияНеверный пароль даёт401; user_id от клиента не назначает права.
АвторизацияУчебный кабинет только для серверной записи Анны. Для чужого заказа нужна отдельная проверка владельца.
ВыводИмя проходит h перед HTML; пароль не возвращается в form value.
СрокПросроченный auth удаляется до показа заказа; GC не заменяет эту проверку.

Упражнение: измените idle30 на10, войдите при1000 и запросите кабинет при1010. Ожидается форма входа с сообщением об истечении. Затем верните30. Не делайте срок полем запроса клиента.

Функции и методы этой темы

Откройте карточку, чтобы увидеть договор функции, примеры, ограничения и связанные инструменты.

password_hashСоздать строку проверки пароля при регистрации, не сохраняя исходный пароль.

PHP 5.5+ · Core

function password_hash(#[SensitiveParameter] string $password, string|int|null $algo, array $options = []): string

Намеренно затратная операция. Подбирайте параметры на своём сервере и ограничивайте поток запросов; быстрый обычный хеш не заменяет функцию для паролей.

Сохранить возможность входа, не записывая пароль. Первый аргумент — введённая фраза, второй — константа политики алгоритма. Присваивание сохраняет возвращённую строку. PHP включает в неё соль и параметры, поэтому хранить соль отдельным полем не требуется. В учебном файле только запись аккаунта, не исходная фраза.

Путь конкретных данных
  1. Фраза Учебный вход Анны → password_hash
  2. В account.json сохраняются username=anna и вся строка hash
  3. Повторное чтение и verify разрешают тот же ввод

Распакуйте все файлы архива вместе. Команда php example.php выполняет этот пример; файлы из архива не нужно придумывать самостоятельно.

Создать строку проверки пароля при регистрации, не сохраняя исходный пароль.

Параметр / результатКонтракт
passwordИсходные байты пароля. Не применяйте trim(), htmlspecialchars() или SQL-экранирование до хеширования: они меняют пароль. SensitiveParameter с PHP 8.2 скрывает аргумент в трассировке, но не в ваших логах.
algoОбязательный второй аргумент: обычно PASSWORD_DEFAULT или явно согласованный PASSWORD_ARGON2ID/PASSWORD_BCRYPT. null с PHP 8 означает алгоритм по умолчанию; старые числовые ID принимаются для совместимости, новый код использует константы.
optionsМассив доверенной политики. bcrypt: cost 4–31, default 12 с PHP 8.4 (раньше 10). Argon2: memory_cost в KiB, time_cost в проходах, threads как параметр параллелизма. Пределы/провайдеры ниже.
РезультатСтрока с идентификатором алгоритма, стоимостью, солью и результатом. У bcrypt60 ASCII-байтов; для меняющегося PASSWORD_DEFAULT предусмотрите хранилище с запасом, например 255 байтов.
ОшибкиС PHP 8 неверный алгоритм/параметр даёт ValueError, общий сбой хеширования — Error, тип аргумента — TypeError; до 8.0 ошибки могли дать false. Сбой генерации соли: ValueError, с 8.3 исходный Random\RandomException прикреплён как previous.
bcrypt и сольbcrypt учитывает первые 72 байта; NUL в PHP 8.5 запрещён ValueError. salt устарела с 7.0; с 8.0 игнорируется с предупреждением. Новую случайную соль создаёт PHP.

Название функции пишется перед скобками. В первом аргументе пароль, во втором — константа алгоритма. $storedHash = ... сохраняет возвращённую строку. Это не шифрование: функции «достать пароль обратно» нет. Восстановление доступа делают через смену пароля, а не расшифровку.

Первое создание и проверкаСамостоятельный пример
<?php
declare(strict_types=1);

$hash = password_hash('Учебная фраза', PASSWORD_DEFAULT);
if (password_verify('Учебная фраза', $hash)) {
    echo "Пароль совпал. Следующий шаг — начать вход.\n";
}
Ожидаемый результат
Пароль совпал. Следующий шаг — начать вход.

Случайная соль меняет строку hash при каждом создании; проверка читает параметры из уже сохранённой строки. Сравнивать два новых password_hash через === нельзя.

Теперь сохраним результат между запусками. json_encode превращает массив username/hash в JSON-строку, file_put_contents пишет её в account.json возле example.php. file_get_contents читает байты обратно, json_decode с true возвращает массив. Флаги JSON_THROW_ON_ERROR сообщают о сбое исключением, проверка записанной длины обнаруживает неполную запись. Ни одну из этих функций не нужно применять к исходной фразе перед password_hash.

Сохранить возможность входа, не записывая парольСамостоятельный пример
<?php
declare(strict_types=1);

$hash = password_hash('Учебный вход Анны', PASSWORD_DEFAULT);
$record = json_encode(['username' => 'anna', 'hash' => $hash], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (file_put_contents(__DIR__ . '/account.json', $record) !== strlen($record)) { throw new RuntimeException('Аккаунт не сохранён'); }
$saved = json_decode(file_get_contents(__DIR__ . '/account.json'), true, 512, JSON_THROW_ON_ERROR);
echo 'Сохранён аккаунт: ', $saved['username'], "\n";
echo password_verify('Учебный вход Анны', $saved['hash']) ? "Повторный ввод открывает вход.\n" : "Вход запрещён.\n";
Ожидаемый результат
Сохранён аккаунт: anna
Повторный ввод открывает вход.

account.json появляется возле example.php. Его структура {"username":"anna","hash":"строка password_hash"}; конкретная строка меняется из-за соли, поэтому не подставляйте показанное описание вместо настоящего файла. Для сайта файл/БД должен быть вне public. Запускать только с учебной фразой. Полный HTTP-проект уже кладёт users.json в private. Полный вход через HTML, cookie и сессию.

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

Один пароль можно проверить по двум независимо созданным хешамСамостоятельный пример
<?php
declare(strict_types=1);

$first = password_hash('demo phrase', PASSWORD_BCRYPT, ['cost' => 4]);
$second = password_hash('demo phrase', PASSWORD_BCRYPT, ['cost' => 4]);
echo "Две серверные записи созданы независимо.\n";
echo password_verify('demo phrase', $first) ? "Первая запись принимает исходную фразу.\n" : "Первая запись отклоняет ввод.\n";
echo password_verify('demo phrase', $second) ? "Вторая запись тоже принимает исходную фразу.\n" : "Вторая запись отклоняет ввод.\n";
Ожидаемый результат
Две серверные записи созданы независимо.
Первая запись принимает исходную фразу.
Вторая запись тоже принимает исходную фразу.

Вывод показывает результат описанного выше опыта с указанными исходными данными.

Неверная политика — ошибка конфигурации сервиса. Не сохраняйте пустую строку в поле password_hash после отказа. Публичному клиенту не нужен технический текст с переданными значениями.

Ошибки настроек и NUL не превращаются в пригодный хешСамостоятельный пример
<?php
declare(strict_types=1);

foreach ([['demo', 'no-such-algorithm', []], ['demo', PASSWORD_BCRYPT, ['cost' => 3]],
          ["demo\0tail", PASSWORD_BCRYPT, ['cost' => 4]]] as [$password, $algo, $options]) {
    try {
        password_hash($password, $algo, $options);
    } catch (ValueError $error) {
        echo "Хеш не создан\n";
    }
}
Ожидаемый результат
Хеш не создан
Хеш не создан
Хеш не создан

Поддержка Argon2i появилась 7.2, Argon2id7.3; с 7.4 идентификаторы алгоритмов стали строками, а Sodium может предоставлять альтернативную реализацию. С 8.4 возможен OpenSSL-провайдер. Все варианты и допустимые настройки подробно разобраны в таблице алгоритмов. Цена специально высокая: это замедляет массовые догадки по украденной БД, но требует лимитов входа на самом сервисе.

Связанные функции: password_verify, password_needs_rehash, password_algos, password_get_info.

Сначала понять регистрацию и вход; параметры защиты и границы примеров.

Принимает пароль и алгоритм, возвращает строку с параметрами и солью. Ошибки аргументов или генерации приводят к исключениям; соль вручную не задают.

Рядом по задаче. password_verify проверяет пароль, password_needs_rehash помогает обновить алгоритм/стоимость.

password_verifyПроверить введённый пароль относительно сохранённой строки и получить true или false.

PHP 5.5+ · Core

function password_verify(#[SensitiveParameter] string $password, string $hash): bool

Намеренно затратная операция. Подбирайте параметры на своём сервере и ограничивайте поток запросов; быстрый обычный хеш не заменяет функцию для паролей.

Разрешить верный ввод и показать отказ при ошибке. Первый аргумент — новый исходный ввод, второй — сохранённая сервером строка. Ветвление if выполняет одну из двух веток. Функция не создаёт сессию; после успеха контроллер отдельно назначает права.

Путь конкретных данных
  1. Сервер хранит строку проверки фразы Учебный вход Анны
  2. verify получает новый исходный ввод и сохранённый hash
  3. Верная фраза разрешена; ошибочный ввод даёт понятный отказ

Распакуйте все файлы архива вместе. Команда php example.php выполняет этот пример; файлы из архива не нужно придумывать самостоятельно.

Проверить введённый пароль относительно сохранённой строки и получить true или false.

Параметр / результатКонтракт
passwordИсходный пароль при входе; ровно тот же договор о байтах/кодировке, что при регистрации. SensitiveParameter не запрещает приложению случайно записать его в журнал.
hashВся сохранённая строка password_hash(); возможна совместимость с поддерживаемыми crypt()-хешами. Не принимать хеш из запроса клиента вместо серверной записи аккаунта.
Возвратtrue при совпадении, false при несовпадении либо неподходящем/повреждённом хеше. Аргументы неверного типа при strict_types вызывают TypeError.
Время и область защитыСравнение внутри функции рассчитано на защиту от timing-атак. Это не обещает одинаковое время всех веток контроллера входа, запросов БД и разных алгоритмов/стоимостей.

Первый аргумент — то, что человек ввёл, второй — то, что хранится. В функцию не передают заново password_hash($password). if выбирает дальнейшую ветку по логическому результату.

Разрешить верный ввод и показать отказ при ошибкеСамостоятельный пример
<?php
declare(strict_types=1);

$saved = password_hash('Учебный вход Анны', PASSWORD_DEFAULT);
$input = 'Учебный вход Анны';
if (password_verify($input, $saved)) { echo "Пароль совпал. Можно начать сессию Анны.\n"; }
else { echo "Неверное имя или пароль.\n"; }
$input = 'ошибочный ввод';
if (password_verify($input, $saved)) { echo "Вход разрешён.\n"; }
else { echo "Неверное имя или пароль.\n"; }
Ожидаемый результат
Пароль совпал. Можно начать сессию Анны.
Неверное имя или пароль.

Здесь hash создан заранее в начале опыта. На сайте его читают из закрытой записи пользователя, а не принимают из POST. Общая ошибка не раскрывает существование аккаунта. Не применяйте trim/htmlspecialchars к паролю: это изменяет проверяемые байты. Полный вход через HTML, cookie и сессию.

Не лечите ошибку trim() над вводом или хешем. Проверьте договор регистрации/входа и длину столбца БД. Обрезанный хеш невозможно исправить добавлением недостающих символов наугад; нужен корректный исходный хеш либо смена пароля.

Пробел — часть пароля, повреждённый хеш не восстанавливаетсяСамостоятельный пример
<?php
declare(strict_types=1);

$stored = password_hash(' phrase with spaces ', PASSWORD_BCRYPT, ['cost' => 4]);
echo password_verify(' phrase with spaces ', $stored) ? "Исходный ввод: вход разрешён.\n" : "Исходный ввод: отказ.\n";
echo password_verify(trim(' phrase with spaces '), $stored) ? "После trim: вход разрешён.\n" : "После trim: пароль изменился, вход запрещён.\n";
echo password_verify(' phrase with spaces ', substr($stored, 0, 25)) ? "Обрезанный хеш принят.\n" : "Обрезанный хеш не позволяет подтвердить пароль.\n";
Ожидаемый результат
Исходный ввод: вход разрешён.
После trim: пароль изменился, вход запрещён.
Обрезанный хеш не позволяет подтвердить пароль.

Вывод показывает результат описанного выше опыта с указанными исходными данными.

Учебный массив заменяет серверную запись. Авторизация требует актуальных прав; не храните пароль в сессии и не используйте присланный user_id/cookie как доказательство входа.

Проверка пароля не отменяет блокировку аккаунтаСамостоятельный пример
<?php
declare(strict_types=1);

$account = ['active' => false,
    'password_hash' => password_hash('demo phrase', PASSWORD_BCRYPT, ['cost' => 4])];
$passwordMatches = password_verify('demo phrase', $account['password_hash']);
echo $passwordMatches && $account['active'] ? "Вход разрешён\n" : "Вход не разрешён\n";
Ожидаемый результат
Вход не разрешён

Функция не проверяет MD5/SHA-256 строку как парольную схему автоматически. Для старой базы нужен отдельный контролируемый переход либо сброс пароля. После успешной проверки может понадобиться password_needs_rehash. Полный HTTP-маршрут — сессии и вход.

Связанные функции: password_hash, password_needs_rehash, password_get_info.

Сначала понять регистрацию и вход; параметры защиты и границы примеров.

Извлекает алгоритм и параметры из хеша и возвращает bool. Неверный пароль и неподходящий хеш не дают успешной проверки. Не сравнивайте новый password_hash со старым строковым равенством.

Рядом по задаче. password_needs_rehash вызывается после успешного входа; hash_equals предназначен для других секретных строк.

password_needs_rehashСравнить параметры сохранённого хеша с текущей политикой и решить, требуется ли новый хеш.

PHP 5.5+ · Core

function password_needs_rehash(string $hash, string|int|null $algo, array $options = []): bool

Обновить политику после подтверждённого входа. Функция сравнивает параметры сохранённого хеша с доверенной политикой приложения. true означает различие, включая возможное понижение стоимости. Поэтому сначала verify, затем решение политики, новый hash и сохранение.

Путь конкретных данных
  1. Сохранена учебная bcrypt-политика cost4
  2. verify успешен → needs_rehash с cost5 обнаруживает отличие
  3. Новый hash сохранён в upgraded.hash; прежняя фраза работает

Распакуйте все файлы архива вместе. Команда php example.php выполняет этот пример; файлы из архива не нужно придумывать самостоятельно.

Сравнить параметры сохранённого хеша с текущей политикой и решить, требуется ли новый хеш.

Параметр / результатКонтракт
hashСерверный хеш, который только что успешно проверили при входе. Сам пароль функция не получает и не проверяет.
algoЦелевой алгоритм из доверенной конфигурации. Строковые ID с 7.4; int для старой совместимости, null означает default в текущем PHP.
optionsЦелевые параметры той же схемы, что password_hash: cost или параметры Argon2. Различие, даже в сторону меньшей стоимости, может дать true.
Возвратtrue — формат/настройки отличаются; false — обновление по этой проверке не требуется. Это не оценка общей безопасности и не проверка пароля.
Ошибки и ловушкаТипы аргументов проверяются. В PHP 8.5 неизвестный целевой algo даёт false, а не ValueError как password_hash. Валидация options зависит от провайдера; проверяйте политику заранее реальным hash/verify тестом.

Название не означает «перехешировать прямо сейчас»: функция лишь возвращает bool. Новый хеш требует исходный пароль, который доступен во время успешного входа. Не хешируйте старую строку поверх и не запускайте обновление до проверки пользователя.

Обновить политику после подтверждённого входаСамостоятельный пример
<?php
declare(strict_types=1);

$input = 'Учебная миграция';
$stored = password_hash($input, PASSWORD_BCRYPT, ['cost' => 4]);
$policy = ['cost' => 5];
if (!password_verify($input, $stored)) { exit("Неверные данные входа\n"); }
if (password_needs_rehash($stored, PASSWORD_BCRYPT, $policy)) {
    $stored = password_hash($input, PASSWORD_BCRYPT, $policy);
    if (file_put_contents(__DIR__ . '/upgraded.hash', $stored) !== strlen($stored)) { throw new RuntimeException('Новый хеш не сохранён'); }
    echo "Сохранена новая учебная политика: bcrypt cost5.\n";
}
$saved = file_get_contents(__DIR__ . '/upgraded.hash');
echo password_verify($input, $saved) ? "Прежний пароль продолжает открывать вход.\n" : "Вход запрещён.\n";
Ожидаемый результат
Сохранена новая учебная политика: bcrypt cost5.
Прежний пароль продолжает открывать вход.

cost4→5 — быстрый опыт механизма, совершенно не рекомендация защиты. Реальную стоимость выбирают измерением и политикой проекта. В многопользовательской БД обновление выполняют условно по старому значению/версии, чтобы этот вход не перезаписал только что сменённый пароль. Полный вход через HTML, cookie и сессию.

Первый true допускает понижение, второй false вызван неверной политикой, третий true не доказывает знание пароля. Приложение обязано отдельно контролировать политику и порядок verify→rehash.

Различие параметров не значит улучшениеСамостоятельный пример
<?php
declare(strict_types=1);

$stored = password_hash('demo phrase', PASSWORD_BCRYPT, ['cost' => 5]);
echo password_needs_rehash($stored, PASSWORD_BCRYPT, ['cost' => 4]) ? "cost5 → cost4 тоже считается отличием, хотя стоимость снижается.\n" : "Параметры совпали.\n";
echo password_needs_rehash($stored, 'no-such-algorithm') ? "Неизвестная политика требует пересчёта.\n" : "Неизвестный алгоритм дал false; это не разрешение политики.\n";
echo password_needs_rehash('not-a-hash', PASSWORD_BCRYPT, ['cost' => 5]) ? "Повреждённая строка требует пересчёта, но пароль ещё не подтверждён.\n" : "Строка соответствует политике.\n";
Ожидаемый результат
cost5 → cost4 тоже считается отличием, хотя стоимость снижается.
Неизвестный алгоритм дал false; это не разрешение политики.
Повреждённая строка требует пересчёта, но пароль ещё не подтверждён.

Вывод показывает результат описанного выше опыта с указанными исходными данными.

При развёртывании разных версий политики два сервера могут постоянно менять параметры друг за другом. Согласуйте rollout и не сравнивайте Argon2/bcrypt по одной общей цифре «больше значит лучше». Разбор сохранения без потери параллельной смены — пример с условным UPDATE.

Связанные функции: password_verify, password_hash, password_get_info, password_algos.

Сначала понять регистрацию и вход; параметры защиты и границы примеров.

Сравнивает метаданные существующего хеша с целевым алгоритмом и параметрами. Возвращает bool, не проверяет пароль и не меняет запись.

Рядом по задаче. password_get_info показывает метаданные, password_hash создаёт новую строку.

random_bytesСоздать непредсказуемый материал токена и безопасно представить его текстом.

PHP 7.0+ · random

random_bytes(int $length): string

Выдать и погасить учебный одноразовый токен. random_bytes(32) возвращает 32 случайных байта. bin2hex делает из них текст для передачи. Секретный токен выдаётся клиенту, сервер хранит его хеш, назначение, срок и признак погашения. Здесь выдача/предъявление происходят в одном процессе, без письма и сети.

Путь конкретных данных
  1. 32 случайных байта → текстовый токен
  2. Запись сервера связывает digest с user7 и сроком1060
  3. Первое предъявление разрешает действие, второе отклоняется

Распакуйте все файлы архива вместе. Команда php example.php выполняет этот пример; файлы из архива не нужно придумывать самостоятельно.

Создать непредсказуемый материал токена и безопасно представить его текстом.

Часть вызоваДоговор
lengthКоличество байт, целое ≥1. Не длина будущего hex/base64 текста.
ВозвратСтрока ровно length равномерно выбранных байт из системного CSPRNG.
ОшибкиValueError при length<1; Random\RandomException при отказе источника с PHP 8.2, ранее Exception. Неподходящий тип в строгом вызове — TypeError.

random_bytes(16) означает «вернуть 16 случайных байт». Это бинарная строка, а не 16 печатных букв. Переменная $bytes принимает результат вызова; bin2hex кодирует каждый байт двумя шестнадцатеричными символами. Кодирование не добавляет случайности и не уменьшает её, пока результат не обрезан.

Выдать и погасить учебный одноразовый токенСамостоятельный пример
<?php
declare(strict_types=1);

$token = bin2hex(random_bytes(32));
$record = ['user_id' => 7, 'digest' => hash('sha256', $token), 'expires' => 1060, 'used' => false];
$now = 1000;
foreach ([$token, $token] as $provided) {
    if (!$record['used'] && $now < $record['expires'] && hash_equals($record['digest'], hash('sha256', $provided))) {
        $record['used'] = true;
        echo 'Пользователь ', $record['user_id'], ": подтверждение принято.\n";
    } else { echo "Ссылка больше не действует.\n"; }
}
Ожидаемый результат
Пользователь 7: подтверждение принято.
Ссылка больше не действует.

Это модель одноразовости, не готовый многопроцессный сервис. В БД проверка срока/used и погашение должны быть одним атомарным условным действием. Генерация случайности не выполняет эти проверки. Для CSRF полный проект хранит токен в сессии и проверяет hidden-поле формы; токен не выводится в журналы.

Функция возвращает текст для выдачи и отпечаток для проверки. Пример ничего не сохраняет и не отправляет. Реальный одноразовый токен дополнительно связывают с пользователем, назначением и сроком, а погашение делают атомарным. Случайность сама по себе не гарантирует уникальность: для уникального ID нужен также UNIQUE-договор хранилища.

Отделить создание токена от его храненияСамостоятельный пример
<?php
declare(strict_types=1);

function newToken(): array
{
    $publicToken = bin2hex(random_bytes(32));
    return ['token' => $publicToken, 'digest' => hash('sha256', $publicToken)];
}
$issued = newToken();
echo strlen($issued['token']), '/', strlen($issued['digest']), "\n";
echo hash_equals($issued['digest'], hash('sha256', $issued['token'])) ? "Проверка совпала\n" : "Отказ\n";
Ожидаемый результат
64/64
Проверка совпала

Проверяйте выбранный приложением предел длины до вызова: положительная длина всё равно может быть неприемлемой по памяти. Ошибку источника случайности не подменяют предсказуемым токеном.

Нулевая длина — ошибка требованияСамостоятельный пример
<?php
declare(strict_types=1);

try { random_bytes(0); }
catch (ValueError) { echo "Нужен хотя бы один байт\n"; }
Ожидаемый результат
Нужен хотя бы один байт

В строке возможны NUL и недопустимые последовательности UTF-8. Для текста используйте bin2hex либо согласованное base64-кодирование; не «очищайте» случайные байты регулярным выражением. Источники ОС и история их выбора перечислены в таблице системных источников. Функция есть с PHP 7.0; исторический random_compat для PHP 5.2–5.6 не делает устаревшую версию PHP подходящей для нового сайта.

Продолжить: random_int, Random\Randomizer::getBytes.

Как выбрать случайность и прочитать первый вызов.

Возвращает бинарную строку заданной длины. Длина должна быть положительной; при невозможности получить случайность выбрасывается исключение. Для передачи в HTML/URL кодируйте байты.

Рядом по задаче. bin2hex кодирует байты; random_int выбирает равномерное целое.

hash_equalsСравнить проверяемую секретную строку без раннего выхода по первому отличающемуся байту.

PHP 5.6+ · hash

hash_equals(string $known_string, string $user_string): bool

Принять подлинную учебную запись и отклонить изменение. hash_equals сравнивает известную строку первым аргументом с полученной вторым. Предварительно проверяйте тип: массив из запроса строкой не становится. В опыте отправитель и получатель локальные, ключ намеренно учебный; вывод MAC здесь воспроизводим, реальный ключ не публикуют.

Путь конкретных данных
  1. Первый аргумент: серверный MAC order=42
  2. Второй: полученный MAC в том же hex-формате
  3. Совпадение принимает исходную запись; изменение отклоняется

Распакуйте все файлы архива вместе. Команда php example.php выполняет этот пример; файлы из архива не нужно придумывать самостоятельно.

Сравнить проверяемую секретную строку без раннего выхода по первому отличающемуся байту.

Параметр / результатКонтракт
known_stringВычисленное сервером ожидаемое значение: всегда первый аргумент.
user_stringПроверяемая строка, полученная извне: второй аргумент.
returntrue только при одинаковой длине и содержимом; иначе false. Сравнение чувствительно к регистру и не нормализует hex/Base64.
Принять подлинную учебную запись и отклонить изменениеСамостоятельный пример
<?php
declare(strict_types=1);

$known = hash_hmac('sha256', 'order=42', 'local-demo-key');
$received = $known;
echo 'Подпись: ', $known, "\n";
echo hash_equals($known, $received) ? "Исходная запись принята.\n" : "Подпись не совпала.\n";
$changed = hash_hmac('sha256', 'order=43', 'local-demo-key');
echo hash_equals($known, $changed) ? "Изменение принято.\n" : "Изменённая запись отклонена.\n";
Ожидаемый результат
Подпись: 656a184e13c773994fcbc3e7a6030a261700ca9545352c0fb7142f52352e2e1b
Исходная запись принята.
Изменённая запись отклонена.

HMAC не скрывает текст и не предотвращает повтор того же сообщения. При протоколе с событиями добавляют аутентифицированные время/ID и атомарный учёт повторов. Для Base64 вызывайте hash_hmac с true, затем кодируйте сырые байты, а не hex.

Это правило конкретного протокола: ровно 64 нижних hex-знака. Функция получает недоверенное значение параметром, проверяет форму и возвращает bool. В данном формате длина публична. Для другого формата менять нужно валидацию, а не положение аргументов hash_equals.

Отвергнуть массив и неправильную длину заголовкаСамостоятельный пример
<?php
declare(strict_types=1);

function acceptsSignature(mixed $provided, string $expected): bool {
    if (!is_string($provided) || strlen($provided) !== 64) { return false; }
    if (preg_match('/\A[0-9a-f]{64}\z/', $provided) !== 1) { return false; }
    return hash_equals($expected, $provided);
}
$expected = hash_hmac('sha256', 'order=42', 'demo-only-key');
echo acceptsSignature($expected, $expected) ? "Правильная подпись: запись принята.\n" : "Правильная подпись: отказ.\n";
echo acceptsSignature(['bad'], $expected) ? "Массив принят.\n" : "Массив вместо подписи: запрос отклонён.\n";
echo acceptsSignature('short', $expected) ? "Короткая подпись принята.\n" : "Короткая подпись: запрос отклонён.\n";
Ожидаемый результат
Правильная подпись: запись принята.
Массив вместо подписи: запрос отклонён.
Короткая подпись: запрос отклонён.

Вывод показывает результат описанного выше опыта с указанными исходными данными.

Ошибки и ограничения. Разные длины дают false немедленно: функция не скрывает длину известной строки и не обещает одинаковое wall-clock время всех запросов. Аргументы обязаны быть строками; неверные типы дают TypeError, даже если другие встроенные функции могли бы их привести. Не проводите сетевой timing-тест вместо выполнения контракта. Для пользовательского пароля нужен password_verify, не сравнение plaintext через hash_equals.

Продолжить: hash_hmac, hash_hmac_file. Как выбрать задачу и алгоритм.

Возвращает bool и предназначена для сравнения без зависимости времени от позиции различающегося байта. Длины всё равно влияют на поведение; формат и размер проверяют заранее.

Рядом по задаче. password_verify для паролей; hash_hmac для вычисления webhook-подписи.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.