devinci-it / cerberus-web
A framework-neutral PHP password gateway: protect any app (or selected routes) behind a shared-password login. Ships a standalone front-controller gate and a PSR-15 middleware.
Requires
- php: >=8.1
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- phpunit/phpunit: ^10.5
Suggests
- nyholm/psr7: A PSR-7/PSR-17 implementation, needed to use the PSR-15 middleware adapter with a slim/mezzio stack
- psr/log: Required only to route audit events to an existing PSR-3 logger via Audit\Psr3AuditLogger
This package is auto-updated.
Last update: 2026-07-25 19:53:34 UTC
README
A framework-neutral PHP password gate. Put a whole app — or a chosen set of routes — behind a single shared-password login, with no user table and no framework lock-in.
It ships two adapters over one decision engine:
- Standalone gate — one line at the top of a front controller. Fits a hand-rolled MVC app.
- PSR-15 middleware — drop into a Slim / Mezzio / Laminas pipeline.
The engine itself never touches superglobals or PSR interfaces, so the two adapters stay thin and you can add your own.
New here? Follow the step-by-step Quick Start. A runnable example app lives in
examples/standalone/.
What it is (and what it is not)
It is a gatekeeper: a shared password that unlocks access, remembered in the session. Good for staging sites, internal tools, client previews, "coming soon" pages, or wrapping a legacy app you do not want to rewrite auth for.
It is not a user-authentication system. There are no accounts, roles, or per-user identity — only "does this visitor know the password." If you need real accounts, this is the wrong tool. You can issue several passwords (see Multiple passwords), but that is coarse access control, not identity.
Security posture is stated plainly in Security notes. Read that section before shipping — in particular the throttle caveat and the requirement to run behind TLS.
Requirements
- PHP 8.1+
- For the PSR-15 adapter only: a PSR-7/PSR-17 implementation
(e.g.
nyholm/psr7) — your framework almost certainly already provides one.
The PSR interface packages (psr/http-message, psr/http-server-middleware,
psr/http-server-handler) are pulled in as dependencies. They are
interface-only with zero runtime weight, which is why the PSR-15 adapter works
out of the box while the standalone path stays framework-free.
Installation
Once published to Packagist:
composer require devinci-it/cerberus-web
Until then (or for a private fork), point Composer at the repository directly by
adding this to the consuming app's composer.json:
{
"repositories": [
{ "type": "vcs", "url": "https://github.com/devinci-it/cerberus-web" }
],
"require": {
"devinci-it/cerberus-web": "dev-main"
}
}
Then composer update devinci-it/cerberus-web.
Quick start (about 5 minutes)
1. Generate a password hash
vendor/bin/gateway hash # → prompts for a password, prints a bcrypt/argon2 digest
Passwords are never stored in plaintext. The gate only ever sees a
password_hash() digest.
2. Provide the hash
Prefer an environment variable so the secret stays out of version control:
export GATEWAY_PASSWORD_HASH='$2y$10$...the digest from step 1...'
3. Publish the config
vendor/bin/gateway publish config
# → writes config/gateway.php (reads GATEWAY_PASSWORD_HASH by default)
4. Gate your app
At the very top of your front controller (e.g. public/index.php), before any
output and before routing:
<?php require __DIR__ . '/../vendor/autoload.php'; use DevinciIT\CerberusWeb\Gateway; // If the visitor is not authenticated, this emits the login page (or a // redirect) and exits. If they are, it returns and your app runs normally. Gateway::protect(__DIR__ . '/../config/gateway.php'); // ... your existing router / bootstrap continues here ...
That is the whole integration for a classic MVC app. There is an equivalent
one-line helper, gateway_protect(...), if you prefer functions.
Standalone usage in detail
Gateway::protect() is the shortcut. If you want to inspect the decision before
acting (logging, custom responses), drive the gate yourself:
use DevinciIT\CerberusWeb\Gateway; use DevinciIT\CerberusWeb\Http\StandaloneGate; $gateway = Gateway::fromConfigFile(__DIR__ . '/../config/gateway.php'); $gate = new StandaloneGate($gateway); $result = $gate->run(); // evaluate the current request if (!$result->isAllow()) { // e.g. log the challenge, add headers, etc. $gate->send($result); // emit status + headers + body exit; } // authenticated — continue
$result is a GateResult with ->decision (an enum: Allow, Challenge,
Redirect, Locked), ->statusCode, ->headers, ->body, and
->redirectTo.
The gate handles three special paths for you: the login page (GET renders the form, POST processes the attempt) and the logout path. You do not add routes for these in your app — the gate intercepts them.
PSR-15 usage (Slim example)
use DevinciIT\CerberusWeb\Config; use DevinciIT\CerberusWeb\Gateway; use DevinciIT\CerberusWeb\Http\PsrGatewayMiddleware; use Nyholm\Psr7\Factory\Psr17Factory; $config = Config::fromArray(require __DIR__ . '/../config/gateway.php'); $gateway = new Gateway($config); $factory = new Psr17Factory(); // any PSR-17 factory $app->add(new PsrGatewayMiddleware($gateway, $factory, $factory));
Add it near the top of the pipeline so it runs before your route handlers. On
Allow it delegates to the next handler untouched; otherwise it returns a
PSR-7 response built from the GateResult.
The middleware reads the parsed body via ServerRequestInterface::getParsedBody(),
so make sure a body-parsing middleware runs before it (Slim's
addBodyParsingMiddleware() does this) — otherwise the login POST cannot see the
submitted password.
Route gatekeeping
Two modes, set via mode in config:
| Mode | Behavior |
|---|---|
protect_all (default) |
Everything is gated except paths in except. |
protect_listed |
Only paths in only are gated; everything else is open. |
except is honored in both modes — it is the always-open list. Put your
asset directories and health checks there so the login page can load its own
styles and monitors keep working.
Path patterns
Patterns in only and except support three styles:
| Style | Example | Matches |
|---|---|---|
| Exact | /health |
only /health |
| Glob (single segment) | /assets/* |
/assets/app.css, not /assets/img/logo.png |
| Glob (cross segment) | /assets/** |
/assets/img/logo.png too |
| Regex | #^/api/v\d+/# |
any pattern wrapped in #...#, used verbatim |
Matching is on the path only (query string stripped) and is case-sensitive. A
trailing slash is ignored, so /admin and /admin/ are equivalent.
Example — gate the admin area only, leave the public site open:
'mode' => Config::MODE_PROTECT_LISTED, 'only' => ['/admin', '/admin/**'],
Configuration reference
Every key from the published config/gateway.php:
| Key | Default | Purpose |
|---|---|---|
hashes |
(required) | One password_hash() digest, or an array of them. A single string is accepted. |
mode |
protect_all |
protect_all or protect_listed. |
only |
[] |
Paths to gate in protect_listed mode. |
except |
[] |
Always-open paths (both modes). |
login_path |
/gateway |
Where the login form lives and posts to. |
logout_path |
/gateway/logout |
Hitting this clears the session and redirects to login. |
redirect_after_login |
/ |
Fallback destination when no intended URL was captured. |
use_intended_url |
true |
Send the visitor back to the page they first requested. |
idle_timeout |
0 |
Seconds of inactivity before re-auth. 0 disables. |
absolute_timeout |
0 |
Max session age in seconds before re-auth. 0 disables. |
max_attempts |
5 |
Failed attempts before lockout. |
lock_seconds |
300 |
Lockout duration. |
decay_seconds |
900 |
Window over which failed attempts are counted. |
session_name |
pwgw_session |
Session cookie name (kept separate from your app's). |
cookie_params |
[] |
Passed to session_set_cookie_params(). Set secure => true on HTTPS. |
title / heading / message |
— | Login page copy. |
view_path |
null |
Absolute path to a custom login template. |
Helper functions
Autoloaded (no import needed):
gateway_protect($config); // === Gateway::protect($config) $hash = gateway_hash('secret'); // === Hasher::make('secret')
$config accepts a Config object, a config array, or a path to a config file.
CLI reference
vendor/bin/gateway:
| Command | Does |
|---|---|
hash [password] |
Print a password_hash() digest. Prompts (hidden) if no argument. |
verify <hash> |
Prompt for a password and report MATCH / NO MATCH. |
publish [dir] |
Copy the default config to <dir>/gateway.php (won't overwrite). |
help |
Usage. |
Multiple passwords
Set several hashes to issue distinct passwords (e.g. one per contractor) without a database:
'hashes' => [ getenv('GATEWAY_PASSWORD_ADMIN'), getenv('GATEWAY_PASSWORD_VENDOR'), ],
Any one of them unlocks access. Note this is not identity — the gate does not record which password was used. If you need to know who logged in, you need real per-user auth, not this.
Extension points
Everything swappable is an interface in src/Contracts. Pass your own
implementations to the Gateway constructor:
$gateway = new Gateway( config: $config, session: $myRedisSessionStore, // SessionStoreInterface verifier: $myDbPasswordVerifier, // PasswordVerifierInterface throttle: $myIpThrottle, // ThrottleInterface );
SessionStoreInterface— where auth state lives. Default:NativeSessionStore($_SESSION, namespaced so it never collides with your app). Implement it for Redis, a DB, or a signed cookie.PasswordVerifierInterface— how a submitted password is checked. Default:Hasher(static hash list). Implement it to pull hashes from a DB.ThrottleInterface— brute-force limiting. Default:SessionThrottle(see the caveat below). Implement it against a persistent, IP-keyed store for real protection.
Custom login view
Point view_path at your own template. It receives these variables:
$title, $heading, $message, $error (string|null), $csrfToken,
$csrfField (a ready-made hidden input — echo it raw), $action (the login
path), $fieldName (the password input name), and $lockedUntil (int|null).
The form must POST to $action and include $csrfField and an input named
$fieldName, or logins will not go through. Escape everything else yourself
(the bundled view uses htmlspecialchars).
Publishing to Packagist
- Push the package to a public GitHub repo (the
nameincomposer.json,devinci-it/cerberus-web, should matchvendor/package). - Tag a release using semver:
git tag v0.1.0 && git push --tags. Packagist reads versions from tags. - At https://packagist.org, "Submit" and paste the repo URL.
- Set up the GitHub App integration (or the webhook Packagist suggests) so new tags auto-update the package. Without it, Packagist only refreshes on a manual "Update".
After that, composer require devinci-it/cerberus-web works for everyone,
and you can drop the repositories block from consuming apps.
Auditing
The gateway can record every security-relevant event: login success and failure, CSRF failure, lockout, logout, and session expiry (and, optionally, challenges — unauthenticated hits on protected paths). It's off by default; enable it with a path and you get JSON Lines you can grep, tail, or feed into a pipeline.
'audit' => [ 'enabled' => true, 'path' => __DIR__ . '/../storage/logs/cerberus-audit.log', 'log_ip' => true, 'log_user_agent' => true, 'log_challenges' => false, // true also records unauth hits on protected paths (noisy) ],
Sample output (one JSON object per line):
{"ts":"2026-07-25T18:04:12Z","event":"login.failed","method":"POST","path":"/gateway","ip":"203.0.113.5","ua":"Mozilla/5.0","sid":"da4ec3358a10c9b0"}
{"ts":"2026-07-25T18:04:12Z","event":"login.succeeded","method":"POST","path":"/gateway","ip":"203.0.113.5","ua":"Mozilla/5.0","sid":"da4ec3358a10c9b0"}
{"ts":"2026-07-25T18:04:12Z","event":"login.locked_out","method":"POST","path":"/gateway","ip":"198.51.100.9","ua":"curl/8.4","sid":"7c351c3ca3e9548e","ctx_retry_after":300}
Event types: login.succeeded, login.failed, login.csrf_failed,
login.locked_out, session.logout, session.expired, access.challenged.
What is and isn't logged, and why:
- Never logged: the password, the CSRF token, or the password hash.
- Session id → fingerprint.
sidis a truncated SHA-256 of the session id, not the id itself. You can correlate an attacker's attempts across a session without the log becoming a session-hijacking tool if it leaks. - IP is
REMOTE_ADDRonly.X-Forwarded-Foris spoofable and is not trusted. Behind a reverse proxy or load balancer,REMOTE_ADDRis the proxy; to log the real client, resolve it yourself (from a header your proxy sets and overwrites) and constructRequestContextwith the resolvedclientIp, or configure the proxy to rewriteREMOTE_ADDR. Turn IP logging off with'log_ip' => false. - Failure handling. The file logger never throws — a failing audit sink must
not take your app down. If it can't write, it falls back to PHP's
error_log. - Rotation is yours. It only appends; wire up
logrotateor equivalent.
Sending events to an existing logger
If your app already runs a PSR-3 logger (Monolog, etc.), route gateway events
into it instead of a file. psr/log is a suggested dependency; install it, then
pass the adapter to the Gateway:
use DevinciIT\CerberusWeb\Audit\Psr3AuditLogger; $gateway = new Gateway($config, audit: new Psr3AuditLogger($yourPsr3Logger));
Failures and lockouts log at warning, everything else at info.
A custom sink
Implement AuditLoggerInterface (one method, log(AuditEvent $event): void)
for anything else — a database table, syslog, an HTTP forwarder. Keep the
"never throw" contract.
Security notes
Read these before putting this in front of anything that matters.
- Run behind TLS. A shared password sent over plain HTTP is exposed on the
wire. Set
cookie_params => ['secure' => true, 'httponly' => true, 'samesite' => 'Lax']so the session cookie is HTTPS-only. - The bundled throttle is a speed bump, not a wall.
SessionThrottlekeys off the session, so an attacker who discards the cookie between attempts is never locked out. It stops casual guessing and honest fat-fingering, nothing more. For anything internet-facing, implementThrottleInterfaceagainst a persistent, IP-keyed store — or put a WAF / fail2ban in front. - This is one shared secret. Anyone with the password has full access, and there is no per-user revocation short of rotating the hash (which logs everyone out). That is the intended trade-off for a gate; if it is not acceptable, use real auth.
- CSRF and session fixation are handled. The login form carries a
per-session CSRF token checked with
hash_equals(), and the session ID is regenerated on successful login. - Rotating the password means generating a new hash and updating config/env.
Existing sessions stay valid until they expire (set
absolute_timeoutif you want rotation to force re-auth).
Testing
composer install vendor/bin/phpunit
The suite in tests/GatewayTest.php runs the full request lifecycle against an
in-memory session store: challenge, exempt paths, CSRF rejection, wrong/right
password, intended-URL redirect, session regeneration, logout, throttle lockout,
protect_listed mode, and timeout expiry.
License
MIT. See LICENSE.