Построение безопасного внутреннего REST API для B2B: ключи, scopes и аудит на PHP
Введение: специфика B2B API и его отличие от пользовательского
B2B API — это не публичный интерфейс для конечных пользователей, а инфраструктурный контракт между компаниями. Здесь другие требования к безопасности: ключи живут месяцами, партнёры имеют разные уровни доверия, а каждый запрос должен быть воспроизводимым и аудируемым. Ошибка в проектировании обходится дорого — в прямых потерях данных или нарушении SLA.
Принципиальные отличия от пользовательского API:
- Нет браузерных сессий — только machine-to-machine аутентификация.
- Долгоживущие учётные данные — API-ключи вместо краткосрочных токенов.
- Гранулярные права — один партнёр читает заказы, другой может их создавать.
- Обязательный аудит — юридические и compliance-требования фиксировать каждое действие.
- Версионирование — обратная совместимость критична, партнёр не обновляется по первому требованию.
Архитектура аутентификации: API-ключи vs OAuth2 vs mTLS
Для B2B-интеграций в 2026 году актуальны три подхода. Выбор зависит от требований к безопасности и сложности инфраструктуры партнёра.
API-ключи
Простейший вариант: статический секрет передаётся в заголовке X-Api-Key. Легко реализовать, достаточно для большинства внутренних B2B-интеграций. Минус — ключ нельзя отозвать мгновенно без участия базы данных, поэтому важно хранить только хеш.
OAuth2 Client Credentials
Подходит, если партнёр уже работает с OAuth2-экосистемой или нужны короткоживущие access-токены. Увеличивает сложность: нужен authorization server (например, Laravel Passport или отдельный сервис). Рекомендуется, когда партнёров десятки и нужна федерация прав.
mTLS (взаимная TLS-аутентификация)
Максимальная безопасность: клиент и сервер предъявляют сертификаты. Используется в финтехе и здравоохранении. Сложность развёртывания высокая — PKI, ротация сертификатов, поддержка со стороны партнёра.
Для большинства B2B SaaS-продуктов оптимальный выбор в 2026 году — API-ключи с хешированием + scopes + rate limiting. OAuth2 Client Credentials — если партнёров более 50 или требуется делегирование прав.
Реализация системы API-ключей на Laravel
Схема базы данных
Создадим таблицы для клиентов и их ключей в PostgreSQL:
-- Таблица B2B-клиентов (партнёров)
CREATE TABLE api_clients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
company VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Таблица API-ключей
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL REFERENCES api_clients(id) ON DELETE CASCADE,
key_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 хеш ключа
key_prefix VARCHAR(8) NOT NULL, -- первые 8 символов для идентификации
name VARCHAR(255), -- метка ключа ("production", "staging")
scopes JSONB NOT NULL DEFAULT '[]',
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX idx_api_keys_client ON api_keys(client_id);
Генерация и хранение ключей в Laravel
Никогда не храните ключ в открытом виде. Генерируем криптографически стойкий ключ, отдаём его клиенту один раз, сохраняем только хеш:
<?php
namespace App\Services;
use App\Models\ApiKey;
use Illuminate\Support\Str;
class ApiKeyService
{
/**
* Генерирует новый API-ключ для клиента.
* Возвращает plain-text ключ ТОЛЬКО один раз.
*/
public function generate(string $clientId, array $scopes, string $name = '', ?\DateTimeInterface $expiresAt = null): array
{
// 32 байта = 256 бит энтропии, base64url-encoded
$plainKey = 'b2b_' . Str::random(48);
$keyHash = hash('sha256', $plainKey);
$prefix = substr($plainKey, 0, 8);
$apiKey = ApiKey::create([
'client_id' => $clientId,
'key_hash' => $keyHash,
'key_prefix' => $prefix,
'name' => $name,
'scopes' => $scopes,
'expires_at' => $expiresAt,
]);
return [
'id' => $apiKey->id,
'key' => $plainKey, // показываем ОДИН раз
'key_prefix' => $prefix,
'scopes' => $scopes,
'expires_at' => $expiresAt,
];
}
/**
* Проверяет ключ и возвращает запись из БД.
*/
public function verify(string $plainKey): ?ApiKey
{
$hash = hash('sha256', $plainKey);
return ApiKey::query()
->where('key_hash', $hash)
->where('is_active', true)
->where(function ($q) {
$q->whereNull('expires_at')
->orWhere('expires_at', '>', now());
})
->with('client')
->first();
}
}
Middleware аутентификации
<?php
namespace App\Http\Middleware;
use App\Services\ApiKeyService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateApiKey
{
public function __construct(private ApiKeyService $keyService) {}
public function handle(Request $request, Closure $next): Response
{
$raw = $request->header('X-Api-Key');
if (!$raw) {
return response()->json(['error' => 'API key required'], 401);
}
$apiKey = $this->keyService->verify($raw);
if (!$apiKey || !$apiKey->client->is_active) {
return response()->json(['error' => 'Invalid or revoked API key'], 401);
}
// Обновляем last_used_at асинхронно через очередь, не блокируя ответ
dispatch(fn() => $apiKey->update(['last_used_at' => now()]))->afterResponse();
// Кладём данные в request для последующих слоёв
$request->attributes->set('api_key', $apiKey);
$request->attributes->set('api_client', $apiKey->client);
return $next($request);
}
}
Модель scopes: гранулярный контроль доступа
Scopes — это набор строк, описывающих права доступа. Соглашение об именовании: resource:action. Примеры для B2B-платформы:
orders:read— чтение заказовorders:write— создание и обновление заказовinvoices:read— чтение счетовproducts:*— все действия с товарамиwebhooks:manage— управление вебхуками
Middleware проверки scope
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckApiScope
{
public function handle(Request $request, Closure $next, string ...$requiredScopes): Response
{
$apiKey = $request->attributes->get('api_key');
if (!$apiKey) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
$grantedScopes = $apiKey->scopes ?? [];
foreach ($requiredScopes as $required) {
if (!$this->hasScope($grantedScopes, $required)) {
return response()->json([
'error' => 'Insufficient permissions',
'required' => $required,
], 403);
}
}
return $next($request);
}
private function hasScope(array $granted, string $required): bool
{
if (in_array($required, $granted, true)) {
return true;
}
// Поддержка wildcard: orders:* покрывает orders:read, orders:write
[$resource] = explode(':', $required);
return in_array($resource . ':*', $granted, true)
|| in_array('*', $granted, true);
}
}
Регистрация в маршрутах
// routes/api.php
Route::middleware(['auth.apikey', 'throttle.client'])
->prefix('v1')
->group(function () {
Route::get('/orders', [OrderController::class, 'index'])
->middleware('scope:orders:read');
Route::post('/orders', [OrderController::class, 'store'])
->middleware('scope:orders:write');
Route::get('/invoices', [InvoiceController::class, 'index'])
->middleware('scope:invoices:read');
});
Rate limiting на уровне клиента с Redis
В B2B важно изолировать квоты по клиентам — один партнёр не должен влиять на другого. Используем Redis с алгоритмом скользящего окна (sliding window).
Реализация кастомного rate limiter
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;
class ClientRateLimiter
{
// Лимиты по умолчанию; можно хранить per-client в БД
private const DEFAULT_LIMIT = 1000; // запросов
private const WINDOW_SECONDS = 60; // за 60 секунд
public function handle(Request $request, Closure $next): Response
{
$apiKey = $request->attributes->get('api_key');
$clientId = $apiKey?->client_id ?? 'anonymous';
$limit = $apiKey?->client->rate_limit ?? self::DEFAULT_LIMIT;
$window = self::WINDOW_SECONDS;
$now = microtime(true);
$redisKey = "ratelimit:{$clientId}";
// Sliding window log через sorted set
Redis::pipeline(function ($pipe) use ($redisKey, $now, $window) {
$pipe->zremrangebyscore($redisKey, '-inf', $now - $window);
$pipe->zadd($redisKey, $now, $now . mt_rand());
$pipe->expire($redisKey, (int) $window + 1);
});
$count = Redis::zcard($redisKey);
$remaining = max(0, $limit - $count);
$resetAt = (int) ($now + $window);
if ($count > $limit) {
return response()->json(
['error' => 'Rate limit exceeded', 'retry_after' => $window],
429
)->withHeaders([
'X-RateLimit-Limit' => $limit,
'X-RateLimit-Remaining' => 0,
'X-RateLimit-Reset' => $resetAt,
'Retry-After' => $window,
]);
}
$response = $next($request);
return $response->withHeaders([
'X-RateLimit-Limit' => $limit,
'X-RateLimit-Remaining' => $remaining,
'X-RateLimit-Reset' => $resetAt,
]);
}
}
Аудит-лог: запись всех запросов и изменений в PostgreSQL
Схема таблицы аудита
CREATE TABLE api_audit_log (
id BIGSERIAL PRIMARY KEY,
client_id UUID REFERENCES api_clients(id),
api_key_id UUID REFERENCES api_keys(id),
method VARCHAR(10) NOT NULL,
path TEXT NOT NULL,
query_params JSONB,
request_body JSONB, -- sanitized, без секретов
status_code SMALLINT NOT NULL,
ip_address INET,
user_agent TEXT,
duration_ms INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Партиционирование по месяцам для масштабируемости
CREATE TABLE api_audit_log_2026_01 PARTITION OF api_audit_log
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE INDEX idx_audit_client_date ON api_audit_log(client_id, created_at DESC);
CREATE INDEX idx_audit_status ON api_audit_log(status_code) WHERE status_code >= 400;
Middleware для записи в аудит
<?php
namespace App\Http\Middleware;
use App\Jobs\WriteAuditLog;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class AuditLogger
{
private array $sensitiveKeys = ['password', 'token', 'secret', 'card_number'];
public function handle(Request $request, Closure $next): Response
{
$start = microtime(true);
$response = $next($request);
$duration = (int) ((microtime(true) - $start) * 1000);
$apiKey = $request->attributes->get('api_key');
// Асинхронная запись через очередь, чтобы не замедлять ответ
WriteAuditLog::dispatch([
'client_id' => $apiKey?->client_id,
'api_key_id' => $apiKey?->id,
'method' => $request->method(),
'path' => $request->path(),
'query_params' => $request->query(),
'request_body' => $this->sanitize($request->all()),
'status_code' => $response->getStatusCode(),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'duration_ms' => $duration,
]);
return $response;
}
private function sanitize(array $data): array
{
foreach ($this->sensitiveKeys as $key) {
if (isset($data[$key])) {
$data[$key] = '[REDACTED]';
}
}
return $data;
}
}
Версионирование и обратная совместимость B2B API
B2B-партнёры редко обновляют интеграции оперативно, поэтому версионирование критично. Рекомендуемая стратегия — версия в URL-префиксе (/api/v1/, /api/v2/):
- Храните все версии параллельно минимум 18 месяцев после анонса deprecation.
- Версия в заголовке ответа: добавляйте
X-Api-Version: 1.5.2иDeprecation: trueдля устаревших эндпоинтов. - Changelog через API: эндпоинт
GET /api/changelogс машиночитаемым списком изменений. - Семантическое версионирование контракта: breaking changes только в major-версиях.
// Пример структуры директорий для версионирования
app/
Http/
Controllers/
Api/
V1/
OrderController.php
V2/
OrderController.php // новые поля, но старая схема через трансформер
Мониторинг и алертинг: Prometheus-метрики для API
Для PHP/Laravel подключаем promphp/prometheus_client_php и экспортируем метрики через отдельный эндпоинт:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Prometheus\CollectorRegistry;
use Symfony\Component\HttpFoundation\Response;
class RecordPrometheusMetrics
{
public function __construct(private CollectorRegistry $registry) {}
public function handle(Request $request, Closure $next): Response
{
$start = microtime(true);
$response = $next($request);
$duration = microtime(true) - $start;
$apiKey = $request->attributes->get('api_key');
$clientId = $apiKey?->client_id ?? 'anonymous';
$route = $request->route()?->getName() ?? 'unknown';
// Счётчик запросов
$counter = $this->registry->getOrRegisterCounter(
'api', 'requests_total',
'Total API requests',
['client_id', 'route', 'status']
);
$counter->inc([$clientId, $route, (string) $response->getStatusCode()]);
// Гистограмма времени ответа
$histogram = $this->registry->getOrRegisterHistogram(
'api', 'request_duration_seconds',
'API request duration',
['client_id', 'route'],
[0.01, 0.05, 0.1, 0.3, 0.5, 1, 2, 5]
);
$histogram->observe($duration, [$clientId, $route]);
return $response;
}
}
Ключевые метрики для алертов в Grafana/Alertmanager:
- Доля 4xx/5xx ответов по клиенту выше 5% за 5 минут.
- Время ответа p99 выше 500 мс.
- Резкий рост количества 429 (rate limit) — признак атаки или ошибки в коде партнёра.
- Аномальное число уникальных IP для одного API-ключа — возможная утечка ключа.
Деплой и изоляция в Docker и Kubernetes
Dockerfile для Laravel API
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache \
postgresql-dev \
redis \
&& docker-php-ext-install pdo_pgsql opcache
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-interaction
COPY . .
RUN php artisan config:cache \
&& php artisan route:cache \
&& php artisan view:cache
USER www-data
EXPOSE 9000
Kubernetes-манифест с NetworkPolicy для изоляции
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: b2b-api-isolation
namespace: production
spec:
podSelector:
matchLabels:
app: b2b-api
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
role: ingress-controller
ports:
- protocol: TCP
port: 9000
egress:
- to:
- podSelector:
matchLabels:
app: postgresql
ports:
- protocol: TCP
port: 5432
- to:
- podSelector:
matchLabels:
app: redis
ports:
- protocol: TCP
port: 6379
Дополнительные рекомендации по инфраструктуре:
- Используйте Kubernetes Secrets с шифрованием etcd для хранения connection strings.
- Запускайте API-поды с
readOnlyRootFilesystem: trueи безroot-привилегий. - Разделяйте деплой аудит-воркера и основного API — разные Deployment, разные квоты ресурсов.
- Настройте PodDisruptionBudget, чтобы при обновлениях минимум 2 реплики оставались живы.
Заключение и чеклист безопасности
Построение безопасного B2B REST API на PHP и Laravel — это не единоразовая задача, а непрерывный процесс. Перед выходом в production убедитесь в следующем:
- API-ключи хранятся только в виде SHA-256 хешей, plain-text показывается один раз.
- Каждый ключ имеет минимальный набор scopes — принцип наименьших привилегий.
- Rate limiting работает на уровне клиента (Redis), а не глобально.
- Все запросы пишутся в аудит-лог в PostgreSQL асинхронно, с санацией чувствительных данных.
- Таблица аудита партиционирована и имеет TTL-политику очистки старых записей.
- API версионировано, deprecation-период задокументирован в SLA.
- Prometheus-метрики и алерты настроены для аномалий по каждому клиенту.
- Docker-образ собирается без dev-зависимостей, запускается от non-root пользователя.
- Kubernetes NetworkPolicy ограничивает исходящий трафик только к PostgreSQL и Redis.
- Регулярная ротация API-ключей задокументирована и автоматизирована через API управления.
Следуя этим принципам, вы получите B2B API, готовый к production-нагрузкам 2026 года, соответствующий требованиям compliance и удобный для интеграции партнёрами.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →