Security Gateway
The SecurityGateway is a pre-bootstrap security checkpoint that runs before any module loads into memory. A denied request never touches module code — zero cost for rejections. Layers run in order, and the first denial short-circuits all remaining layers.
Overview
The gateway enforces a layered security model:
- Run each layer in order — cheapest checks first (e.g., CSRF token before JWT verification)
- First denial wins — stop and return a 401/403
- Optional identity attachment — layers can resolve an authenticated identity
- Isolated failures — a layer crash does NOT affect other layers (no throws allowed)
The kernel ships exactly ONE layer: CsrfTokenLayer. IP filtering and rate limiting are NOT kernel layers — they're opt-in route filters from plugins ('shield', 'throttle').
SecurityLayerContract
Every security layer implements this interface:
<?php
declare(strict_types=1);
namespace AlfacodeTeam\PhpServicePlatform\Kernel\Security\Contracts;
use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request;
use AlfacodeTeam\PhpServicePlatform\Kernel\Security\SecurityVerdict;
interface SecurityLayerContract
{
/**
* Check the request against this security concern.
* NEVER throw — return a verdict instead.
*/
public function check(Request $request): SecurityVerdict;
}Critical rule: NEVER throw. Return a SecurityVerdict::deny() instead. Exceptions leave the application in a partially-initialized state.
SecurityVerdict
A verdict is immutable and carries:
SecurityVerdict::allow($request); // pass, optionally with identity
SecurityVerdict::allowWithIdentity($identity); // attach an identity
SecurityVerdict::deny(int $statusCode, string $reason); // HTTP status + message$verdict = $layer->check($request);
if ($verdict->isDenied()) {
return $verdict->statusCode(); // 401 / 403 / etc.
return $verdict->reason(); // 'Token invalid' / 'IP not allowed'
}
$identity = $verdict->identity(); // ?IdentityIdentity
Identity is an immutable value object representing an authenticated user:
final readonly class Identity
{
public function __construct(
public readonly string $userId, // user ID or '' (guest)
public readonly string $tenantId, // tenant/organization ID
public readonly array $roles, // ['admin', 'editor']
public readonly array $permissions, // ['post.create', 'post.delete']
public readonly string $tokenType, // 'jwt' | 'api_key' | 'session'
public readonly string $username = '',
public readonly string $email = '',
public readonly string $fullName = '', // from tenant user_profiles
public readonly ?string $avatarUrl = null,
) {}
public function hasRole(string $role): bool { /* ... */ }
public function hasPermission(string $permission): bool { /* ... */ }
public function isGuest(): bool { return empty($this->userId); }
}Test helpers
Identity::asUser('user-1', 'tenant-abc', roles: ['user'], permissions: []);
Identity::asAdmin('tenant-abc'); // full admin (userId defaults to 'admin-user', permissions to ['*'])
Identity::asAdmin('tenant-abc', 'custom-admin-id', ['post.edit', 'post.delete']); // custom admin
Identity::guest(); // empty identityKey principle: Identity is a hint, not authority
The kernel carries the tenantId on the identity and nothing more. It does NOT resolve tenants, own a tenant registry, or know what a tenant database is — that's the Tenancy plugin's job.
The tenant ID is a HINT. Whatever set it (a signed JWT claim, a cookie, a host label), authorization still keys on (userId, tenantId, role/permission) re-checked against the store that owns memberships. A signature proves the value wasn't tampered in transit; it doesn't prove the seat still exists.
// An identity claims to be user-1 in tenant-abc
// But you MUST verify in the database that user-1 is still a member of tenant-abc
// before trusting that identity for authorizationWriting a Custom Security Layer
A layer checks the request and returns a verdict:
<?php
declare(strict_types=1);
namespace MyPlugin\Security;
use AlfacodeTeam\PhpServicePlatform\Kernel\Http\Request;
use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Contracts\SecurityLayerContract;
use AlfacodeTeam\PhpServicePlatform\Kernel\Security\{Identity, SecurityVerdict};
final class JwtVerificationLayer implements SecurityLayerContract
{
public function __construct(
private readonly string $jwtSecret,
) {}
public function check(Request $request): SecurityVerdict
{
$token = $request->bearerToken();
if ($token === null) {
// No token present — allow, carry no identity
return SecurityVerdict::allow($request);
}
try {
// Pseudo-code: decode and verify JWT
$claims = $this->verifyJwt($token);
} catch (\Exception $e) {
// Token invalid — deny
return SecurityVerdict::deny(401, 'Token invalid: ' . $e->getMessage());
}
// Token valid — create an identity and attach it
$identity = new Identity(
userId: $claims['sub'],
tenantId: $claims['tenant_id'],
roles: $claims['roles'] ?? [],
permissions: $claims['permissions'] ?? [],
tokenType: 'jwt',
username: $claims['username'] ?? '',
email: $claims['email'] ?? '',
);
return SecurityVerdict::allowWithIdentity($identity);
}
private function verifyJwt(string $token): array
{
// Your JWT library here — e.g., firebase/php-jwt
// Verify signature, expiry, etc.
// Throw on failure
return [
'sub' => 'user-123',
'tenant_id' => 'tenant-abc',
'roles' => ['admin'],
'permissions' => ['*'],
'username' => 'alice',
'email' => 'alice@example.com',
];
}
}Registering a Layer
In the application bootstrap, pass layers to Kernel::withSecurity(). The boot requires at least one: an empty list fails with "No security layers configured". The generated bootstrap registers CsrfTokenLayer (with /api exempt), which satisfies it.
use AlfacodeTeam\PhpServicePlatform\Kernel\Security\Layers\CsrfTokenLayer;
$kernel = Kernel::configure()
->withSecurity([
// Kernel's built-in CSRF layer
new CsrfTokenLayer(
secret: env('APP_KEY'),
headerName: 'X-CSRF-Token',
formField: '_csrf_token',
bindCookie: 'hkm_session',
lifetime: 43200,
exemptPaths: ['/api/webhooks'],
),
// Custom JWT layer from an Auth plugin
new JwtVerificationLayer(
jwtSecret: env('JWT_SECRET'),
),
])
// ...
->build();Layers run in the order declared. Order matters: put cheap checks (CSRF) before expensive ones (JWT verify).
How the Gateway Works
final class SecurityGateway
{
public function inspect(Request $request): SecurityVerdict
{
foreach ($this->layers as $i => $layer) {
$verdict = $layer->check($request);
// First deny short-circuits
if ($verdict->isDenied()) {
return $verdict;
}
// Carry the identity forward (if resolved)
$identity = $verdict->identity();
if ($identity !== null && $i < count($this->layers) - 1) {
// Not the last layer — attach so next layer can see it
$request = $request->withIdentity($identity);
}
}
return SecurityVerdict::allow($request);
}
}SecurityStage and the Pipeline
SecurityStage runs the gateway in the HTTP pipeline:
// In HttpPipeline::buildStages()
new SecurityStage($this->gateway),
...$this->resolveHook('after.security'),If denied, it returns a 401/403 response immediately (no routing, no modules).
If allowed and an identity was attached, the request carries it for the rest of the pipeline.
Request-Scoped Rebinding
If your security layer needs to rebind a port per-tenant (e.g., a different database connection per tenant), do it in the request-scoped ModuleContainer, never the app-lifetime CoreContainer:
// ✗ WRONG — would leak across requests in Swoole
$core->instance(DatabasePort::class, $tenantDb);
// ✓ Correct — scoped to this request, discarded after
// (Done in a hook or a module's register(), not in SecurityGateway)
$container->instance(DatabasePort::class, $tenantDb);Authorization vs Authentication
SecurityGateway handles authentication — is the request from someone we know?
Services handle authorization — does that person have permission for this action?
// SecurityGateway / Auth layer
// Q: "Are you logged in?"
if ($token === null || !$this->verifyToken($token)) {
return SecurityVerdict::deny(401, 'You must log in');
}
// Service layer (InvoiceService)
// Q: "Do you have permission to DELETE this invoice?"
if ($this->identity->userId !== $invoice->ownerId && !$this->identity->hasPermission('invoice:delete')) {
throw new SecurityException('You cannot delete this invoice');
}Common Patterns
JWT + Role-Based Access Control
public function check(Request $request): SecurityVerdict
{
$token = $request->bearerToken();
if ($token === null) {
return SecurityVerdict::allow($request); // Allow guests (services check permissions)
}
try {
$claims = $this->verifyJwt($token);
} catch (\Exception) {
return SecurityVerdict::deny(401, 'Invalid token');
}
$identity = new Identity(
userId: $claims['sub'],
tenantId: $claims['tenant_id'],
roles: $claims['roles'],
permissions: $claims['permissions'],
tokenType: 'jwt',
);
return SecurityVerdict::allowWithIdentity($identity);
}API Key Verification
public function check(Request $request): SecurityVerdict
{
$header = $request->header('X-API-Key');
if ($header === null) {
return SecurityVerdict::allow($request);
}
$key = $this->db->queryOne(
'SELECT * FROM api_keys WHERE token = ? AND revoked_at IS NULL',
[hash('sha256', $header)]
);
if ($key === null) {
return SecurityVerdict::deny(401, 'API key invalid');
}
$identity = new Identity(
userId: 'api-' . $key['app_id'],
tenantId: $key['tenant_id'],
roles: ['api'],
permissions: $key['scopes'], // ['invoices.read', 'invoices.write']
tokenType: 'api_key',
);
return SecurityVerdict::allowWithIdentity($identity);
}Multi-tenant routing
The gateway alone doesn't select which database to use — that's business logic. But a layer can read the tenant hint and let a module's register() do the rebinding:
// In boot() hook (runs after SecurityStage, before modules)
$http->hook('after.security', TenantDatabaseStage::class, priority: 15);
final class TenantDatabaseStage implements HttpStageContract
{
public function __construct(private readonly TenantResolver $resolver) {}
public function handle(Request $request, callable $next): Response
{
$identity = $request->identity();
$tenantId = $identity?->tenantId ?? $request->attribute('route_tenant');
$tenant = $this->resolver->find($tenantId);
if ($tenant === null) {
return Response::notFound('Tenant not found');
}
$request = $request->withAttribute('tenant', $tenant);
return $next($request);
}
}Then in the module's register(), rebind the database for this tenant:
public function register(ModuleContainer $container): void
{
$container->bind(DatabasePort::class, fn($c) => {
$tenant = $c->make(Request::class)->attribute('tenant');
return new TenantAwareDatabase($tenant);
});
}Common Mistakes
Never throw from a security layer
// ✗ WRONG
public function check(Request $request): SecurityVerdict
{
$token = $request->bearerToken();
$claims = json_decode($token, true); // can throw JSON errors
if ($claims === null) {
throw new \Exception('Invalid JWT'); // never!
}
// ...
}
// ✓ Correct
public function check(Request $request): SecurityVerdict
{
$token = $request->bearerToken();
try {
$claims = json_decode($token, true);
if ($claims === null) {
throw new \Exception('Invalid JSON');
}
// verify claims...
} catch (\Exception $e) {
return SecurityVerdict::deny(401, 'Token invalid');
}
// ...
}Exceptions crash the gateway and partially initialize the kernel.
Don't trust the tenantId without verification
// ✗ WRONG — identity claims to be in tenant-abc, but we don't verify it
$invoice = $this->db->queryOne(
'SELECT * FROM invoices WHERE id = ? AND tenant_id = ?',
[$id, $request->identity()->tenantId]
);
// ✓ Correct — verify the tenant membership is real
$tenant = $this->resolver->findForUser($request->identity()->userId);
if ($tenant?->id !== $request->identity()->tenantId) {
throw new SecurityException('Tenant mismatch');
}The tenantId is a hint. A database membership lookup is the source of truth.
Layer order matters
// ✓ Put cheap checks first
->withSecurity([
new CsrfTokenLayer(), // 1 timing-safe hash (microseconds)
new JwtVerificationLayer(), // JWT verify (milliseconds)
])
// A denied CSRF token exits immediately; no JWT work needed.