Construcción de una API REST interna segura para B2B: claves, scopes y auditoría en PHP
Introducción: particularidades de una API B2B y sus diferencias con una API de usuario
Una API B2B no es una interfaz pública para usuarios finales, sino un contrato de infraestructura entre empresas. Los requisitos de seguridad son distintos: las claves duran meses, los socios tienen diferentes niveles de confianza, y cada solicitud debe ser reproducible y auditable. Un error de diseño tiene un coste elevado: pérdida directa de datos o incumplimiento del SLA.
Diferencias principales respecto a una API de usuario:
- Sin sesiones de navegador: solo autenticación machine-to-machine.
- Credenciales de larga duración: claves API en lugar de tokens de corta vida.
- Permisos granulares: un socio puede leer pedidos, otro puede crearlos.
- Auditoría obligatoria: requisitos legales y de compliance exigen registrar cada acción.
- Versionado: la compatibilidad hacia atrás es crítica; un socio no actualiza a demanda.
Arquitectura de autenticación: claves API vs OAuth2 vs mTLS
Para integraciones B2B en 2026 existen tres enfoques vigentes. La elección depende de los requisitos de seguridad y de la complejidad de la infraestructura del socio.
Claves API
La opción más sencilla: un secreto estático se transmite en el encabezado X-Api-Key. Fácil de implementar y suficiente para la mayoría de las integraciones B2B internas. La desventaja es que la clave no puede revocarse de forma instantánea sin acceder a la base de datos, por lo que es importante almacenar solo el hash.
OAuth2 Client Credentials
Adecuado si el socio ya trabaja con el ecosistema OAuth2 o si se necesitan access tokens de corta duración. Añade complejidad: se requiere un servidor de autorización (por ejemplo, Laravel Passport o un servicio independiente). Se recomienda cuando hay decenas de socios y se necesita federación de permisos.
mTLS (autenticación TLS mutua)
Máxima seguridad: el cliente y el servidor presentan certificados. Se utiliza en fintech y salud. La complejidad de despliegue es alta: PKI, rotación de certificados y soporte por parte del socio.
Para la mayoría de los productos SaaS B2B, la opción óptima en 2026 es claves API con hash + scopes + rate limiting. OAuth2 Client Credentials, si hay más de 50 socios o se requiere delegación de permisos.
Implementación del sistema de claves API en Laravel
Esquema de base de datos
Creamos las tablas para clientes y sus claves en PostgreSQL:
-- Tabla de clientes B2B (socios)
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()
);
-- Tabla de claves 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, -- hash SHA-256 de la clave
key_prefix VARCHAR(8) NOT NULL, -- primeros 8 caracteres para identificación
name VARCHAR(255), -- etiqueta de la clave ("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);
Generación y almacenamiento de claves en Laravel
Nunca almacene la clave en texto plano. Generamos una clave criptográficamente segura, se la entregamos al cliente una sola vez y guardamos únicamente el hash:
<?php
namespace App\Services;
use App\Models\ApiKey;
use Illuminate\Support\Str;
class ApiKeyService
{
/**
* Genera una nueva clave API para el cliente.
* Devuelve la clave en texto plano SOLO una vez.
*/
public function generate(string $clientId, array $scopes, string $name = '', ?\DateTimeInterface $expiresAt = null): array
{
// 32 bytes = 256 bits de entropía, codificado en base64url
$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, // se muestra UNA sola vez
'key_prefix' => $prefix,
'scopes' => $scopes,
'expires_at' => $expiresAt,
];
}
/**
* Verifica la clave y devuelve el registro de la BD.
*/
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 de autenticación
<?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);
}
// Actualizamos last_used_at de forma asíncrona mediante una cola, sin bloquear la respuesta
dispatch(fn() => $apiKey->update(['last_used_at' => now()]))->afterResponse();
// Almacenamos los datos en el request para las capas siguientes
$request->attributes->set('api_key', $apiKey);
$request->attributes->set('api_client', $apiKey->client);
return $next($request);
}
}
Modelo de scopes: control de acceso granular
Los scopes son un conjunto de cadenas que describen los permisos de acceso. Convención de nomenclatura: recurso:acción. Ejemplos para una plataforma B2B:
orders:read: lectura de pedidosorders:write: creación y actualización de pedidosinvoices:read: lectura de facturasproducts:*: todas las acciones sobre productoswebhooks:manage: gestión de webhooks
Middleware de verificación de 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;
}
// Soporte de wildcard: orders:* cubre orders:read, orders:write
[$resource] = explode(':', $required);
return in_array($resource . ':*', $granted, true)
|| in_array('*', $granted, true);
}
}
Registro en rutas
// 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 a nivel de cliente con Redis
En B2B es fundamental aislar las cuotas por cliente: un socio no debe afectar a otro. Utilizamos Redis con el algoritmo de ventana deslizante (sliding window).
Implementación de un rate limiter personalizado
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;
class ClientRateLimiter
{
// Límites por defecto; pueden almacenarse por cliente en la BD
private const DEFAULT_LIMIT = 1000; // solicitudes
private const WINDOW_SECONDS = 60; // en 60 segundos
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 mediante 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,
]);
}
}
Registro de auditoría: almacenamiento de todas las solicitudes y cambios en PostgreSQL
Esquema de la tabla de auditoría
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, -- sanitizado, sin secretos
status_code SMALLINT NOT NULL,
ip_address INET,
user_agent TEXT,
duration_ms INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Particionamiento por meses para escalabilidad
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 para escritura en el registro de auditoría
<?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');
// Escritura asíncrona mediante cola para no ralentizar la respuesta
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;
}
}
Versionado y compatibilidad hacia atrás de la API B2B
Los socios B2B raramente actualizan sus integraciones con rapidez, por lo que el versionado es crítico. La estrategia recomendada es incluir la versión en el prefijo de la URL (/api/v1/, /api/v2/):
- Mantenga todas las versiones en paralelo durante al menos 18 meses tras el anuncio de deprecación.
- Versión en el encabezado de respuesta: añada
X-Api-Version: 1.5.2yDeprecation: truepara los endpoints obsoletos. - Changelog a través de la API: endpoint
GET /api/changelogcon una lista de cambios legible por máquina. - Versionado semántico del contrato: los cambios que rompen la compatibilidad solo en versiones major.
// Ejemplo de estructura de directorios para el versionado
app/
Http/
Controllers/
Api/
V1/
OrderController.php
V2/
OrderController.php // nuevos campos, pero esquema antiguo mediante transformador
Monitorización y alertas: métricas Prometheus para la API
Para PHP/Laravel integramos promphp/prometheus_client_php y exportamos métricas a través de un endpoint dedicado:
<?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';
// Contador de solicitudes
$counter = $this->registry->getOrRegisterCounter(
'api', 'requests_total',
'Total API requests',
['client_id', 'route', 'status']
);
$counter->inc([$clientId, $route, (string) $response->getStatusCode()]);
// Histograma de tiempo de respuesta
$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;
}
}
Métricas clave para alertas en Grafana/Alertmanager:
- Proporción de respuestas 4xx/5xx por cliente superior al 5% en 5 minutos.
- Tiempo de respuesta p99 superior a 500 ms.
- Aumento brusco del número de 429 (rate limit): indicador de ataque o error en el código del socio.
- Número anómalo de IPs únicas para una misma clave API: posible filtración de la clave.
Despliegue y aislamiento en Docker y Kubernetes
Dockerfile para la API Laravel
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
Manifiesto de Kubernetes con NetworkPolicy para aislamiento
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
Recomendaciones adicionales de infraestructura:
- Utilice Kubernetes Secrets con cifrado etcd para almacenar las cadenas de conexión.
- Ejecute los pods de la API con
readOnlyRootFilesystem: truey sin privilegios deroot. - Separe el despliegue del worker de auditoría y de la API principal: distintos Deployments y distintas cuotas de recursos.
- Configure PodDisruptionBudget para garantizar que al menos 2 réplicas permanezcan activas durante las actualizaciones.
Conclusión y checklist de seguridad
Construir una API REST B2B segura en PHP y Laravel no es una tarea puntual, sino un proceso continuo. Antes de pasar a producción, verifique lo siguiente:
- Las claves API se almacenan únicamente como hashes SHA-256; el texto plano se muestra una sola vez.
- Cada clave tiene el conjunto mínimo de scopes: principio de mínimo privilegio.
- El rate limiting funciona a nivel de cliente (Redis), no de forma global.
- Todas las solicitudes se registran en el log de auditoría en PostgreSQL de forma asíncrona, con sanitización de datos sensibles.
- La tabla de auditoría está particionada y dispone de una política TTL para eliminar registros antiguos.
- La API está versionada y el período de deprecación está documentado en el SLA.
- Las métricas de Prometheus y las alertas están configuradas para detectar anomalías por cliente.
- La imagen Docker se construye sin dependencias de desarrollo y se ejecuta con un usuario no root.
- La NetworkPolicy de Kubernetes restringe el tráfico saliente únicamente hacia PostgreSQL y Redis.
- La rotación periódica de claves API está documentada y automatizada a través de la API de gestión.
Siguiendo estos principios, obtendrá una API B2B lista para cargas de producción en 2026, conforme a los requisitos de compliance y cómoda de integrar para los socios.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →