Building a Secure Internal REST API for B2B: Keys, Scopes, and Audit Logging in PHP
Introduction: B2B API Specifics and How It Differs from Consumer APIs
A B2B API is not a public interface for end users — it's an infrastructural contract between companies. Security requirements are fundamentally different: keys live for months, partners have varying trust levels, and every request must be reproducible and auditable. Design mistakes are costly — they result in direct data loss or SLA violations.
Key differences from consumer-facing APIs:
- No browser sessions — only machine-to-machine authentication.
- Long-lived credentials — API keys instead of short-lived tokens.
- Granular permissions — one partner reads orders, another can create them.
- Mandatory audit logging — legal and compliance requirements demand that every action be recorded.
- Versioning — backward compatibility is critical; partners don't update integrations on demand.
Authentication Architecture: API Keys vs OAuth2 vs mTLS
Three approaches are relevant for B2B integrations in 2026. The right choice depends on your security requirements and the complexity of your partner's infrastructure.
API Keys
The simplest option: a static secret passed in the X-Api-Key header. Easy to implement and sufficient for most internal B2B integrations. The downside is that a key cannot be revoked instantly without a database lookup, so storing only the hash is essential.
OAuth2 Client Credentials
Suitable if the partner already works within the OAuth2 ecosystem or if short-lived access tokens are required. It adds complexity — you need an authorization server (e.g., Laravel Passport or a dedicated service). Recommended when you have dozens of partners and need federated permissions.
mTLS (Mutual TLS Authentication)
Maximum security: both client and server present certificates. Used in fintech and healthcare. Deployment complexity is high — PKI infrastructure, certificate rotation, and partner-side support are all required.
For most B2B SaaS products in 2026, the optimal choice is API keys with hashing + scopes + rate limiting. Use OAuth2 Client Credentials when you have more than 50 partners or need delegated permissions.
Implementing an API Key System in Laravel
Database Schema
Let's create tables for clients and their keys in PostgreSQL:
-- B2B clients (partners) table
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 keys table
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 hash of the key
key_prefix VARCHAR(8) NOT NULL, -- first 8 characters for identification
name VARCHAR(255), -- key label ("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);
Generating and Storing Keys in Laravel
Never store a key in plain text. Generate a cryptographically strong key, share it with the client once, and store only the hash:
<?php
namespace App\Services;
use App\Models\ApiKey;
use Illuminate\Support\Str;
class ApiKeyService
{
/**
* Generates a new API key for a client.
* Returns the plain-text key ONLY once.
*/
public function generate(string $clientId, array $scopes, string $name = '', ?\DateTimeInterface $expiresAt = null): array
{
// 32 bytes = 256 bits of entropy, 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, // shown ONCE only
'key_prefix' => $prefix,
'scopes' => $scopes,
'expires_at' => $expiresAt,
];
}
/**
* Verifies a key and returns the database record.
*/
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();
}
}
Authentication 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);
}
// Update last_used_at asynchronously via queue to avoid blocking the response
dispatch(fn() => $apiKey->update(['last_used_at' => now()]))->afterResponse();
// Store data in the request for downstream layers
$request->attributes->set('api_key', $apiKey);
$request->attributes->set('api_client', $apiKey->client);
return $next($request);
}
}
Scope Model: Granular Access Control
Scopes are a set of strings describing access permissions. Naming convention: resource:action. Examples for a B2B platform:
orders:read— read ordersorders:write— create and update ordersinvoices:read— read invoicesproducts:*— all actions on productswebhooks:manage— manage webhooks
Scope Validation Middleware
<?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 support: orders:* covers orders:read, orders:write
[$resource] = explode(':', $required);
return in_array($resource . ':*', $granted, true)
|| in_array('*', $granted, true);
}
}
Route Registration
// 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');
});
Per-Client Rate Limiting with Redis
In B2B it's important to isolate quotas per client — one partner must not impact another. We use Redis with a sliding window algorithm.
Custom Rate Limiter Implementation
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;
use Symfony\Component\HttpFoundation\Response;
class ClientRateLimiter
{
// Default limits; can be stored per-client in the database
private const DEFAULT_LIMIT = 1000; // requests
private const WINDOW_SECONDS = 60; // per 60 seconds
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 via 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,
]);
}
}
Audit Log: Recording All Requests and Changes in PostgreSQL
Audit Table Schema
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, no secrets
status_code SMALLINT NOT NULL,
ip_address INET,
user_agent TEXT,
duration_ms INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Monthly partitioning for scalability
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;
Audit Logging 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');
// Asynchronous write via queue to avoid slowing down the response
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;
}
}
Versioning and Backward Compatibility in B2B APIs
B2B partners rarely update their integrations quickly, making versioning critical. The recommended strategy is a version in the URL prefix (/api/v1/, /api/v2/):
- Maintain all versions in parallel for at least 18 months after announcing deprecation.
- Version in the response header: add
X-Api-Version: 1.5.2andDeprecation: truefor deprecated endpoints. - Changelog via API: a
GET /api/changelogendpoint with a machine-readable list of changes. - Semantic versioning of the contract: breaking changes only in major versions.
// Example directory structure for versioning
app/
Http/
Controllers/
Api/
V1/
OrderController.php
V2/
OrderController.php // new fields, but old schema via transformer
Monitoring and Alerting: Prometheus Metrics for the API
For PHP/Laravel, integrate promphp/prometheus_client_php and expose metrics via a dedicated endpoint:
<?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';
// Request counter
$counter = $this->registry->getOrRegisterCounter(
'api', 'requests_total',
'Total API requests',
['client_id', 'route', 'status']
);
$counter->inc([$clientId, $route, (string) $response->getStatusCode()]);
// Response time histogram
$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;
}
}
Key metrics for alerts in Grafana/Alertmanager:
- 4xx/5xx response rate per client exceeding 5% over 5 minutes.
- p99 response time above 500 ms.
- A sudden spike in 429 responses (rate limit) — a sign of an attack or a bug in the partner's code.
- An abnormal number of unique IPs for a single API key — a possible key leak.
Deployment and Isolation with Docker and Kubernetes
Dockerfile for 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 Manifest with NetworkPolicy for Isolation
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
Additional infrastructure recommendations:
- Use Kubernetes Secrets with etcd encryption to store connection strings.
- Run API pods with
readOnlyRootFilesystem: trueand withoutrootprivileges. - Separate the audit worker and the main API deployment — different Deployments, different resource quotas.
- Configure a PodDisruptionBudget to ensure at least 2 replicas remain alive during updates.
Conclusion and Security Checklist
Building a secure B2B REST API in PHP and Laravel is not a one-time task — it's a continuous process. Before going to production, make sure the following is in place:
- API keys are stored only as SHA-256 hashes; plain text is shown exactly once.
- Each key has the minimum required set of scopes — the principle of least privilege.
- Rate limiting operates at the client level (Redis), not globally.
- All requests are written to the PostgreSQL audit log asynchronously, with sensitive data sanitized.
- The audit table is partitioned and has a TTL policy for purging old records.
- The API is versioned, and the deprecation period is documented in the SLA.
- Prometheus metrics and alerts are configured for anomalies per client.
- The Docker image is built without dev dependencies and runs as a non-root user.
- Kubernetes NetworkPolicy restricts outbound traffic to PostgreSQL and Redis only.
- Regular API key rotation is documented and automated via a management API.
By following these principles, you'll have a B2B API ready for production workloads in 2026, compliant with regulatory requirements, and easy for partners to integrate with.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →