Middleware and Authentication
Middleware is the preferred way to enforce cross-cutting policies in ZealPHP. The framework embraces PSR-15 (Psr\Http\Server\MiddlewareInterface) and runs every request through a configurable stack before handing it to the routing engine. This guide shows how to register middleware, build authentication flows, and combine them with the file-based routing model.
Middleware Pipeline Overview
App::init()seeds the pipeline withResponseMiddleware, which performs route matching and response emission.- Custom middleware added with
App::addMiddleware()is stored untilApp::run()executes; at that point ZealPHP reverses the wait-stack before feeding it to theStackHandler(whoseadd()prepends). The net effect: the first middleware you register is the outermost — first to process the request, last to process the response.ResponseMiddlewarealways runs innermost. SessionManagerorCoSessionManagerwraps the entire stack to guarantee that sessions are opened before middleware runs and closed afterward.
use ZealPHP\App;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class TimingMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$start = microtime(true);
$response = $handler->handle($request);
$duration = round((microtime(true) - $start) * 1000, 2);
return $response->withHeader('X-Response-Time', "{$duration}ms");
}
}
$app = App::init();
$app->addMiddleware(new TimingMiddleware());
$app->run();
Authentication Middleware Pattern
Create middleware that inspects the request, validates credentials, and either forwards the request or terminates it with an error response.
use ZealPHP\G;
use OpenSwoole\Core\Psr\Response;
class SessionAuthMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$g = G::instance();
$session = $g->session ?? [];
if (empty($session['user_id'])) {
$body = json_encode(['error' => 'unauthorized'], JSON_PRETTY_PRINT);
return (new Response($body, 403))->withHeader('Content-Type', 'application/json');
}
return $handler->handle($request)->withHeader('X-User-Id', (string)$session['user_id']);
}
}
Session availability caveat.
G::instance()->sessionis populated per request only when a session lifecycle is active. Insuperglobals(true)modeSessionManageropens the session before middleware runs, so$g->sessionis available here. Insuperglobals(false)(coroutine) modeCoSessionManageronly opens an existing session (when aPHPSESSIDcookie is present) — first-time visitors have no session yet. For a robust auth guard, prefer theApp::authChecker()hook (wired once at boot, called inside the handler), or useG::instance()->memo['user']to pass a resolved user object from middleware to handlers when session state is uncertain.
Register the middleware before calling run():
$app = App::init();
$app->addMiddleware(new SessionAuthMiddleware());
$app->run();
Targeting Specific Routes
If only a subset of endpoints requires authentication, register the middleware conditionally:
$app->addMiddleware(new class implements MiddlewareInterface {
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$path = $request->getUri()->getPath();
if (str_starts_with($path, '/api/private')) {
// perform auth checks
}
return $handler->handle($request);
}
});
Alternatively, mount authenticated routes in a dedicated namespace handled by a custom route file under route/, then call into ZealAPI manually once credentials are verified.
Combining Middleware with File-based APIs
Middleware runs before route selection, so you can rely on it inside api/* closures:
// After SessionAuthMiddleware runs (superglobals(true) mode — session is open)
$profile = function () {
$session = ZealPHP\G::instance()->session;
return ['user_id' => $session['user_id']];
};
In
superglobals(false)(coroutine) mode the session is only open for returning visitors (those with aPHPSESSIDcookie). For mode-agnostic auth state, useG::instance()->memo['user']— written by your auth middleware, readable by any handler regardless of session lifecycle.
For token-based APIs, parse headers using the PSR request:
class BearerAuthMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$auth = $request->getHeaderLine('Authorization');
if (!preg_match('/^Bearer\s+(?<token>.+)$/', $auth, $matches)) {
return (new Response('Missing bearer token', 401));
}
if (!token_is_valid($matches['token'])) {
return (new Response('Invalid token', 403));
}
// Stash the validated token where handlers can read it.
// G::instance()->memo is always available (per-request, mode-agnostic).
// Writing to G::instance()->session works only when a session lifecycle
// is active — reliable in superglobals(true) mode; in coroutine mode
// prefer memo or use App::authChecker() to keep auth state out of the
// session entirely.
ZealPHP\G::instance()->memo['auth_token'] = $matches['token'];
return $handler->handle($request);
}
}
Middleware Ordering
The first-added middleware executes first (outermost) — it processes the request first and the response last. ZealPHP reverses the addMiddleware wait-stack before feeding it to StackHandler (whose add() prepends), so the net result is first-registered = outermost. ResponseMiddleware always runs innermost. A typical registration order:
- Security – Authentication, authorisation, CSRF.
- Request Shaping – Input sanitisation, locale negotiation.
- Telemetry – Logging, tracing, metrics.
- ResponseMiddleware – Built-in terminal middleware that invokes route handlers.
If you need to guarantee that a middleware executes after routing (for example, to post-process responses), attach it to the response returned by $handler->handle() rather than registering it later.
Integrating with External Identity Providers
Inside middleware you have full access to the PSR request:
- Read cookies and headers.
- Perform asynchronous validation using
go()(when superglobals are disabled) orcoproc()to spawn a non-blocking background process when superglobals are enabled. - Populate
G::instance()->sessionwith auth state, or stash a resolved user object onG::instance()->memo(e.g.G::instance()->memo['user'] = $user;).
Handlers receive a ZealPHP\HTTP\Request wrapper — not the PSR ServerRequestInterface that flows through the middleware stack — so PSR withAttribute()/getAttribute() never reaches them. Use G::instance()->memo['user'], G::instance()->session['user_id'], or any other key on G::instance() to pass auth context from middleware to handlers.
Testing Middleware
While ZealPHP does not yet ship a testing harness, you can instantiate middleware classes directly and feed them mocked ServerRequestInterface objects. The repository’s examples demonstrate how to wrap OpenSwoole requests; reuse them in unit tests.
Built-in Middleware Classes
ZealPHP ships parity middleware for Apache/nginx behaviours. The framework's per-middleware reference table lives in template/pages/middleware.php (the website's /middleware page). The following entries cover several additional classes — all opt-in, all PSR-15 — that complete the parity surface.
RequestIdMiddleware
Request-correlation, not an Apache/nginx parity item — the kind of edge concern you'd add at the proxy, expressed as an in-process middleware your handlers can also read. It assigns every request a correlation id and echoes it on the response (default header X-Request-Id), so a single request can be traced across logs, downstream services, and the client.
- With
trustInbound: true(the default), an id already set by an upstream proxy is propagated; passtrustInbound: falseto always mint a fresh one. - A minted id is
bin2hex(random_bytes(16))— 32 hex chars, collision-safe. - The id is written to the per-request memo, so handlers read it with
RequestContext::once('request_id', fn() => null)/RequestContext::has('request_id').
Stateless and coroutine-safe: the id lives in $g (request context), never on the shared middleware instance, so one instance serves every concurrent request.
use ZealPHP\Middleware\RequestIdMiddleware;
use ZealPHP\RequestContext;
// Global — every request gets a correlation id
$app->addMiddleware(new RequestIdMiddleware());
// Read it inside a handler
$app->route('/api/job', function () {
return ['job' => 'queued', 'request_id' => RequestContext::once('request_id', fn() => null)];
});
// Custom header, always mint a fresh id (ignore inbound)
$app->addMiddleware(new RequestIdMiddleware('X-Correlation-Id', trustInbound: false));
ContentEncodingMiddleware
Apache mod_mime AddEncoding parity. Sets the response Content-Encoding header from the request URL's dot-separated file suffixes — archive.tar.gz with the map below yields Content-Encoding: x-gzip, and a doubly-encoded data.gz.gz yields gzip, gzip (order preserved, duplicates intentionally kept). The middleware is additive: it never overrides a Content-Encoding the handler (or a compression middleware that actually encoded the body) already set.
use ZealPHP\Middleware\ContentEncodingMiddleware;
$app->addMiddleware(new ContentEncodingMiddleware([
'gz' => 'gzip',
'br' => 'br',
'bz2' => 'bzip2',
]));
ContentLanguageMiddleware
Apache mod_mime AddLanguage parity. Sets the response Content-Language header from the request URL's dot-separated suffixes — page.en.html yields Content-Language: en. Multiple language suffixes accumulate in order and are emitted comma-joined (RFC 9110 §8.5 allows a list). The middleware only sets the header when the response doesn't already declare one.
use ZealPHP\Middleware\ContentLanguageMiddleware;
$app->addMiddleware(new ContentLanguageMiddleware([
'en' => 'en',
'fr' => 'fr',
'de' => 'de',
]));
MergeSlashesMiddleware
Apache MergeSlashes On / nginx merge_slashes parity. Collapses runs of consecutive slashes in the request path to a single slash before routing, so /a//b///c matches the same route as /a/b/c. This is an internal rewrite (no redirect) — it mutates $g->server['REQUEST_URI'], which the router reads. The query string is left untouched. Register it ahead of route-dependent middleware.
use ZealPHP\Middleware\MergeSlashesMiddleware;
$app->addMiddleware(new MergeSlashesMiddleware());
// Now: /api//users///42 routes the same as /api/users/42
RequestHeaderMiddleware
Apache mod_headers RequestHeader parity. Manipulates the request headers the application sees before handlers run. Headers are written into $g->server using the mod_php CGI convention (HTTP_<NAME>, uppercased, dashes → underscores), so apache_request_headers(), getallheaders(), and $g->server['HTTP_*'] all reflect the change. Operations: set (replace/create), append / add (comma-joined append or create), unset.
use ZealPHP\Middleware\RequestHeaderMiddleware;
$app->addMiddleware(new RequestHeaderMiddleware([
['op' => 'set', 'name' => 'X-Forwarded-Proto', 'value' => 'https'],
['op' => 'append', 'name' => 'X-Trace', 'value' => 'edge'],
['op' => 'unset', 'name' => 'X-Debug'],
]));
ReturnMiddleware
nginx return directive parity. Unconditionally returns a fixed response — the route handler never runs. For 3xx statuses the second argument is treated as the redirect target (Location); for any other status it is the response body. Pair with ScopedMiddleware to limit it to a path (the nginx location { return ... } shape).
use ZealPHP\Middleware\ReturnMiddleware;
use ZealPHP\Middleware\ScopedMiddleware;
// Outright block a path
$app->addMiddleware(ScopedMiddleware::location(new ReturnMiddleware(403), '/blocked'));
// Permanent redirect from /old → /new
$app->addMiddleware(ScopedMiddleware::match(new ReturnMiddleware(301, '/new'), '#^/old$#'));
// Health-check stub
$app->addMiddleware(ScopedMiddleware::location(new ReturnMiddleware(200, 'pong'), '/ping'));
ScopedMiddleware
Apply another middleware only to matching request paths — the Apache-container equivalent for middleware. Two factory methods:
ScopedMiddleware::location($inner, '/admin')—<Location "/admin">: literal URL-path prefix (matches/admin,/admin/x, and — like Apache —/administrator; use a trailing slash or a regex for segment precision).ScopedMiddleware::match($inner, '#^/api/#')—<LocationMatch>/<FilesMatch>: PCRE pattern against the path.
Outside the scope the inner middleware is skipped entirely.
use ZealPHP\Middleware\ScopedMiddleware;
use ZealPHP\Middleware\BasicAuthMiddleware;
use ZealPHP\Middleware\BlockPhpExtMiddleware;
$app->addMiddleware(ScopedMiddleware::location(
new BasicAuthMiddleware(realm: 'Admin', htpasswd: __DIR__ . '/.htpasswd'),
'/admin'
));
$app->addMiddleware(ScopedMiddleware::match(new BlockPhpExtMiddleware(), '#\.php$#'));
SetEnvIfMiddleware
Apache mod_setenvif parity. Sets request "environment" variables (into $g->server, where mod_php code reads them as $_SERVER) when an attribute of the request matches a regex. The classic use is tagging bots, internal IPs, or URL areas so downstream middleware / handlers can branch on a simple flag. Attribute names mirror Apache: the special tokens Remote_Addr, Remote_Host, Server_Addr, Request_Method, Request_Protocol, Request_URI; any other name is treated as a request header (so User-Agent gives BrowserMatch behaviour).
use ZealPHP\Middleware\SetEnvIfMiddleware;
$app->addMiddleware(new SetEnvIfMiddleware([
['attr' => 'User-Agent', 'regex' => '#bot#i', 'set' => ['IS_BOT' => '1']],
['attr' => 'Request_URI', 'regex' => '#^/admin#', 'set' => ['ADMIN_AREA' => '1']],
['attr' => 'Remote_Addr', 'regex' => '#^10\.#', 'set' => ['INTERNAL' => '1']],
]));
Per-route Middleware
Global middleware (App::addMiddleware()) wraps every request. When a policy belongs to a handful of routes — auth on /admin, a rate limit on one endpoint, a correlation id on a job API — attach it per route instead.
The reference point for this model is Hyperf (a Swoole application server with #[Middleware] on routes and per-coroutine context), not Traefik. Traefik is an L7 edge reverse-proxy that forwards to backend services and never runs your code; ZealPHP per-route middleware competes with Slim / Laravel / Hyperf route middleware. ZealPHP borrows Traefik's vocabulary — named middleware, ordered chains — on top of Hyperf's coroutine runtime model.
The differentiator: ZealPHP middleware runs inside the request lifecycle. It can read and write $g, touch the session, run a Store/Redis query, spawn go() coroutines, and short-circuit with real application logic — none of which an edge proxy can do. Because per-route middleware runs after route matching, path-rewriters (Traefik StripPrefix / AddPrefix / ReplacePath) must stay global / pre-match; auth, headers, rate-limit, redirect, IP allow-list, and compression are clean per-route fits.
The middleware: route option
Every route registrar — route(), nsRoute(), nsPathRoute(), patternRoute() — accepts a middleware: list of MiddlewareInterface instances and/or alias strings. It is purely additive and backward-compatible: routes without middleware: are byte-for-byte unchanged (a zero-cost fast path).
use ZealPHP\Middleware\IpAccessMiddleware;
$app->route('/admin/users',
fn() => User::all(),
methods: ['GET'],
middleware: ['auth', 'request-id', new IpAccessMiddleware(['allow' => ['10.0.0.0/8']])],
);
There are two ways to declare middleware on a route, and they combine: the array-option form (['middleware' => [...]]) runs first (outermost), then the named-arg middleware: entries.
$app->route('/reports',
['middleware' => ['audit-log']], // array option → outermost
fn() => Report::all(),
middleware: ['request-id'], // named arg → inner of the two
);
Named aliases — App::middlewareAlias()
Register a short name once and reference it from any route by string. Pass a ready instance (reused as-is) or a factory callable that returns a MiddlewareInterface. Factories run once at App::run() (boot, single-coroutine); the resulting instance is shared across every request that uses the alias. A parameterised reference like 'throttle:120' calls the factory with the comma-split args (fn('120')) — the Laravel 'throttle:60,1' shape.
use ZealPHP\Middleware\{BasicAuthMiddleware, IpAccessMiddleware, RateLimitMiddleware};
App::middlewareAlias('auth', fn() => new BasicAuthMiddleware(htpasswdFile: __DIR__ . '/.htpasswd'));
App::middlewareAlias('admin-only', new IpAccessMiddleware(['allow' => ['10.0.0.0/8']]));
App::middlewareAlias('throttle', fn($n = '60') => new RateLimitMiddleware(limit: (int)$n));
$app->route('/api/heavy', fn() => Heavy::run(), middleware: ['throttle:120']);
Stateless contract: one alias instance serves every concurrent coroutine, so middleware objects must hold no per-request state. Put request-scoped data in $g (the request context / memo), never on the middleware instance — exactly how RequestIdMiddleware stashes its id in $g->memo['request_id'].
Route groups — $app->group()
Share a prefix and a middleware chain across a block of routes. The signature is group(string $prefix, array|callable $middleware = [], ?callable $registrar = null) — the middleware may be omitted (group('/admin', fn($g) => ...)). The callback receives a ZealPHP\RouteGroup whose route()/nsRoute()/nsPathRoute()/patternRoute()/group() mirror App's, prepending the prefix and prepending the group's shared middleware. Group middleware wraps outside each route's own middleware, which wraps outside the handler. Groups nest.
$app->group('/admin', ['auth', 'admin-only'], function ($g) {
$g->route('/users', fn() => User::all());
$g->group('/audit', ['audit-log'], function ($g) { // → /admin/audit/recent
$g->route('/recent', fn() => Audit::recent());
});
});
Note: patternRoute() inside a group does not auto-apply the prefix (a raw regex is ambiguous to prefix) — but the group's shared middleware still applies.
Path-scoped middleware (App::when())
App::when() scopes a middleware chain to a URL path rather than to a specific route registration. It is the central, declarative counterpart to the per-route middleware: option: instead of repeating middleware: ['auth'] on every route under /admin, declare it once by path.
App::when(string $pathPrefixOrRegex, MiddlewareInterface|string|array $middleware): void
The design decision behind it: there is no separate "API middleware." API endpoints (api/**/*.php) are just routes reached by /api/... URLs, and they flow through the same global pipeline as every other route. So one path-scoped verb covers everything — ordinary routes and the file-based API — with no second registry to learn.
Scope syntax
The first argument selects which paths the chain applies to:
| Form | Example | Matches |
|---|---|---|
| Literal path prefix (default) | '/admin' |
/admin and /admin/anything — but not /administrators |
PCRE (string starts with #) |
'#^/api/v\d+/#' |
any path the regex matches |
| Everything | '/' or '' |
every request |
Prefix matching is segment-safe: /admin matches the /admin segment and anything below it, but never a longer word that merely starts with the same letters (/administrators is not matched). Use a regex when you need finer control.
What the chain accepts
The $middleware argument is the same shape the route middleware: option accepts — and it reuses the same App::middlewareAlias() registry:
- a ready
MiddlewareInterfaceinstance, - a registered alias string (including a parameterised reference like
'throttle:120'), - or a list mixing both.
use ZealPHP\Middleware\IpAccessMiddleware;
// Alias chain, scoped to an entire path prefix:
App::when('/admin', ['auth', 'admin-only']);
// A live instance is fine too:
App::when('/internal', new IpAccessMiddleware(['allow' => ['10.0.0.0/8']]));
// Regex scope + a parameterised alias:
App::when('#^/api/v\d+/#', ['throttle:120']);
Because the file-based API is reached through /api/..., the same verb guards it — no API-specific API:
// Every api/secured/*.php endpoint (e.g. GET /api/secured/list) is guarded:
App::when('/api/secured', ['api-secured']);
// Short-circuit a whole API namespace with a fixed response:
use ZealPHP\Middleware\ReturnMiddleware;
App::middlewareAlias('block', new ReturnMiddleware(403));
App::when('/api/blocked', ['block']); // GET /api/blocked/secret → 403, handler never runs
A sibling namespace with no App::when() declaration is untouched — that is the scoping proof: GET /api/secured/list carries the guard's header, GET /api/open/list does not.
API in-file $middleware — co-located per-file guards
An api/**/*.php file may declare an in-file $middleware list, read the same way the dispatcher reads $get / $post:
// api/secured/profile.php
$middleware = ['request-id']; // co-located guard, closest to the handler
$get = function () {
// handler reads the id the in-file middleware stamped:
return ['request_id' => ZealPHP\RequestContext::once('request_id', fn () => null)];
};
In-file $middleware runs innermost — after any App::when() scope that covers the file, closest to the handler. It is resolved and memoized per file and reuses the same alias registry. Use it for a guard that belongs to exactly one endpoint and reads best next to its handler.
Ordering — App::when() is its own band
App::when() inserts a new band into the pipeline, between the global stack and the route's own middleware:
global addMiddleware → App::when (registration order) → route middleware: / api in-file $middleware → handler
App::when()composes in registration order — the firstApp::when()you register is the outermost (it processes the request first and the response last), exactly like the global stack.- A route's own
middleware:(or an API file's in-file$middleware) runs inside everyApp::when()scope that matches, closest to the handler. - The response unwinds in reverse, and any middleware that returns without calling the handler (a 403, a redirect) short-circuits the chain before the handler runs.
Where it runs in the request lifecycle
App::when() middleware runs inside ResponseMiddleware::process() — after path normalization and after OPTIONS / CORS-preflight handling, wrapping route match + dispatch. The preflight ordering is deliberate: a when() auth guard never blocks a CORS preflight, because the preflight short-circuits before the when() band is entered.
Resolution and the stateless contract
Alias-to-instance resolution happens once at App::run() (boot, single-coroutine). At request time, App::when() is a cheap, memoized path scan — read-only after boot, therefore coroutine-safe. As with every other ZealPHP middleware band, the resolved instance is shared across concurrent requests, so the chain must be stateless: keep per-request state in $g (RequestContext), never on the middleware object.
A non-API example — App::when() is not API-only
Path scoping applies to ordinary routes just as well as to /api/*:
App::middlewareAlias('demo-header', /* … stamps X-Demo-Route … */);
App::when('/demo/scoped', ['demo-header']);
$app->route('/demo/scoped/test', fn () => 'scoped'); // response carries X-Demo-Route
Ordering
One rule, pinned crisply: first-registered (or first-listed) is outermost — it processes the request first and the response last.
global → App::when (registration order) → group → route / api in-file → handler
Within each band, the first entry you add or list is the outer wrap; the response unwinds in reverse. A middleware that returns without calling the handler (a 403, a redirect) short-circuits the chain before the handler runs. This is consistent with the global stack: OpenSwoole's StackHandler::add() prepends, and the array_reverse at run() means the first middleware you add is outermost — the first to run. (Earlier revisions of this doc said "last added runs first"; that was wrong for the outermost/innermost framing — the first you add is the first to process the request.)
Coroutine-safety status
Per-route middleware rides on ZealPHP's coroutine-safety substrate, so what is safe depends on what each middleware touches:
| Status | Middleware / pattern |
|---|---|
| Coroutine-safe now | RateLimitMiddleware + ConcurrencyLimitMiddleware (backed by Store / Counter shared memory) |
| Feasible now | ForwardAuth, request-level CircuitBreaker, Retry — on hooked backends (the ZealPHP\HTTP coroutine client, Store, the pooled Redis client) |
| Blocked | DB-backed auth/session middleware — waits on the per-coroutine DB connection pool. pdo_pgsql still blocks the worker (needs a native Postgres coroutine client) |
Visualizing the chains
$app->describeRoutes() returns the whole picture and works before and after run():
$map = $app->describeRoutes();
// [
// 'global' => ['CorsMiddleware', 'ETagMiddleware', 'ResponseMiddleware (router)'],
// 'aliases' => ['auth', 'admin-only', 'throttle', 'request-id'],
// 'when' => [ ['scope' => '/internal', 'middleware' => ['IpAccessMiddleware']] ],
// 'routes' => [
// ['methods' => ['GET'], 'path' => '/admin/users',
// 'middleware' => ['auth', 'request-id', 'IpAccessMiddleware'],
// 'handler' => 'Closure', 'backend' => null], // 'backend' = per-route CGI backend (or null)
// ],
// ]
The global chain is in execution order, ending with ResponseMiddleware (router); each middleware name is the resolved instance's class short-name, or the alias string before resolution. The demo exposes this live at GET /demo/middleware/visualize, and the /middleware page renders it inline as a Traefik-style ordered-chain view (the Live middleware visualizer section).
ZealAPI Auth Hooks
For file-based API handlers under api/, ZealPHP ships first-class authentication integration points rather than requiring manual session checks inside every closure. Wire the three callbacks once at boot and every api/* handler gains access to the same auth logic:
use ZealPHP\App;
use ZealPHP\G;
// Register before App::run()
App::authChecker(fn() => !empty(G::instance()->session['user_id']));
App::adminChecker(fn() => !empty(G::instance()->session['is_admin']));
App::usernameProvider(fn() => G::instance()->session['username'] ?? null);
Inside any API closure, the $this context is the ZealAPI instance, so:
// api/orders/create.php
$post = function () {
if (!$this->requirePostAuth()) {
return; // 403 already sent
}
// $this->isAuthenticated(), $this->isAdmin(), $this->getUsername() all available
return ['status' => 'created'];
};
All three hooks default to null (fail-closed): without App::authChecker(), isAuthenticated() returns false and requirePostAuth() rejects every request. See template/pages/api.php on the live site for the full auth-hooks reference.
Session Fixation Defence (#244)
Session fixation has two halves, and ZealPHP closes both:
-
Planting an id — handled by the framework.
App::$session_strict_mode(default on, security-first) givessession.use_strict_mode=1parity: a client-suppliedPHPSESSID(cookie or query param) whose backing store loads an empty session is treated as unrecognised — both session managers mint a fresh server-generated id and switch the client to it. So an attacker can't pre-plant a known id and have it promoted to the victim's authenticated session. A well-formed id that resolves to a non-empty stored session is preserved unchanged. Opt out (e.g. multi-node without shared/sticky session storage — see the caveat below) withApp::sessionStrictMode(false). -
Reusing a pre-auth id — your responsibility, one call. The framework can't know when your app changes a privilege level, so on login / logout / role change call
session_regenerate_id(true)(the framework's coroutine-safe override — deletes the old session and emits a freshSet-Cookie):// After verifying credentials, BEFORE writing the authenticated identity: session_regenerate_id(true); $_SESSION['user_id'] = $user->id; // or G::instance()->session['user_id'] in coroutine modeStrict mode blocks planting an id; regenerate-on-auth blocks reusing a pre-auth id. Use both.
Multi-node caveat. The empty-session signal is only meaningful when the id's store is visible to the node serving the request — single-node (
TableSessionHandler, the coroutine-mode default) or sharedRedis/Tiered-backed sessions. A multi-node deployment using the per-serverTableSessionHandlerwithout sticky load-balancing or shared storage is already broken (sessions don't persist cross-node); with strict mode on it will also rotate the id on every cross-node hop. Such setups should use Redis-backed sessions orApp::sessionStrictMode(false).
Future Directions
standards-and-roadmap.md tracks planned improvements such as:
- A higher-level
Authfacade built on top of the existingApp::authChecker()/App::adminChecker()/App::usernameProvider()hooks (sessions, JWT, API keys in one call).
Note: middleware groups and route-scoped stacks now ship — see Per-route Middleware above (middleware: route option, App::middlewareAlias(), $app->group()). CSRF protection (CsrfMiddleware), CORS (CorsMiddleware), and rate limiting (RateLimitMiddleware) are already shipped as built-in PSR-15 middleware — see template/pages/middleware.php on the live site.
Contributions in these areas are welcome—align proposals with the PSR-15 contract to keep interoperability intact.