Skip to content

Pramnos Security Guide

Security is a core concern in Pramnos Framework v1.2. This guide covers built-in security features and best practices.

CSRF Protection

Both CSRF paths verify with hash_equals(). The synchronizer token (verifyCsrfToken()) always did; the legacy fingerprint check (checkTokenValue(), which the account controllers, the settings form and the scaffolded templates still use) compared with === until 2026-08-31. Neither is now the weaker choice, so a project on the legacy path is not on a worse one.

Session Token Hardening (v1.2)

Change Before After
Session token entropy random_bytes(5) → 40-bit random_bytes(32) → 256-bit
Fingerprint algorithm md5($ua . $ip . $token) hash_hmac('sha256', $ua . $ip, $token)
Existing sessions Silently upgraded on first request

New methods on Session:

Method Description
getCsrfToken(): string Returns/generates the synchronizer CSRF token (256-bit)
verifyCsrfToken(string $submitted): bool Timing-safe comparison via hash_equals()
regenerateCsrfToken(): void Regenerate the CSRF token (call after login/logout)

CsrfMiddleware

Validates the token on POST, PUT, PATCH, DELETE. Passes GET, HEAD, OPTIONS, TRACE through unchecked.

// Global protection for all state-changing routes
$router->addGlobalMiddleware(new CsrfMiddleware());

// Per-route
$router->post('/transfer', fn() => ...)
       ->middleware(new CsrfMiddleware());

Token lookup order per request: 1. $_POST[$fieldName] (default field: _csrf_token) 2. X-CSRF-Token request header

In HTML Forms

<!-- Synchronizer token field -->
<?php echo \Pramnos\Http\Middleware\CsrfMiddleware::tokenField(); ?>
<!-- → <input type="hidden" name="_csrf_token" value="…" /> -->

AJAX / Fetch

<meta name="csrf-token" content="<?php echo \Pramnos\Http\Middleware\CsrfMiddleware::token(); ?>">
fetch('/api/data', {
    method: 'POST',
    headers: { 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]').content }
});

Legacy API (unchanged)

// Existing code — continues to work
$session->checkToken('POST');
$session->getTokenField();

CsrfMiddleware API

Method Description
new CsrfMiddleware(string $fieldName = '_csrf_token') Constructor; custom field name for legacy forms
CsrfMiddleware::token(): string Returns the session CSRF token
CsrfMiddleware::tokenField(string $fieldName = '_csrf_token'): string Returns <input type="hidden"> with HTML-escaped token

Session Security

Three security improvements added transparently:

  1. Session fixation preventionSession::reset() now calls session_regenerate_id(true) on login/logout. An attacker who planted a session ID is immediately locked out.
  2. Strict session ID modesession.use_strict_mode = 1 is set before session_start(). PHP rejects any session ID not generated by itself.
  3. HTTPS detection fixSession::isHttps() now accepts both 'on' (Apache/nginx) and '1' (IIS/CGI).

Usage

The hardening is transparent — existing login/logout code gets protection automatically:

// Session::start() sets strict mode automatically
$session = Session::getInstance();
$session->start();

// Session::reset() regenerates session ID + CSRF token
// Call AFTER setting session data on login/logout
$session->set('userid', $user->userid);
$session->reset();

Session::isHttps()

// Before (fragile — missed IIS/CGI '1' value)
if ($_SERVER['HTTPS'] === 'on') { ... }

// After (handles 'on', '1' consistently)
if (Session::isHttps()) {
    // set Secure cookie, redirect HTTP→HTTPS, etc.
}
// HttpOnly, SameSite=Lax, and Secure (when HTTPS) are set automatically
'session' => [
    'secure'    => true,      // HTTPS only
    'http_only' => true,      // No JavaScript access
    'same_site' => 'Lax',     // CSRF protection
],

Password Security

Hashing

Always hash passwords before storing:

// DO NOT store plain passwords
$plainPassword = $_POST['password'];

// The framework's own call. Equivalent to password_hash($p, PASSWORD_DEFAULT),
// with one place to configure the cost.
$hashedPassword = \Pramnos\Auth\PasswordHash::make($plainPassword);

// Store $hashedPassword in database

PasswordHash::make() is what User, the database auth driver and the 2FA backup codes use. It is a thin wrapper over password_hash($plain, PASSWORD_DEFAULT) — same algorithm, same portable hash format, verified by the same password_verify() — that exists so the cost is configurable in one place instead of four.

The cost, and why you should leave it alone

On PHP 8.5 PASSWORD_DEFAULT is bcrypt at cost 12, about 143 ms per hash. That slowness is the feature: it is what makes an offline attack on a stolen hash expensive.

PRAMNOS_BCRYPT_COST overrides it. A production deployment should leave it unset.

cost  4:   0.71 ms
cost  8:   9.05 ms
cost 12: 142.9 ms   ← the default

The one environment where lowering it is right is a test suite, and the reason is instructive: enabling 2FA hashes ten backup codes, so a single call cost 1.4 s, and this framework's two TwoFactorAuthService integration classes spent 42 s between them inside bcrypt — testing replay protection and storage, none of which is a property of the cost. The framework's tests/bootstrap.php sets 4.

Anything outside bcrypt's range of 4–31, or not a number, is ignored and the default applies: a typo in an environment variable cannot weaken hashing, and cannot raise an error that stops people logging in either.

Password Verification

// Verify against stored hash
if (password_verify($plainPassword, $storedHash)) {
    // Password correct
} else {
    // Password incorrect
}

// Check if hash needs rehashing (algorithm updated)
if (password_needs_rehash($storedHash, PASSWORD_DEFAULT)) {
    $newHash = password_hash($plainPassword, PASSWORD_DEFAULT);
    // Update database with new hash
}

Account security: the switches, and what each one costs

Pramnos\Auth\SecurityPolicy is one place, declared in one block, and every switch is off by default:

// app/app.php
'auth' => [
    'security' => [
        'regenerate_session_on_login'          => true,
        'ip_rate_limit'                        => ['attempts' => 30, 'window' => 900],
        'notify_security_changes'              => true,
        'session_idle_timeout'                 => 3600,
        'session_absolute_timeout'             => 2592000,
        'revoke_sessions_on_password_change'   => true,
        'require_second_factor_from_usertype'  => 90,
        'password_history'                     => 5,
        'totp_replay_cache'                    => true,
        'human_check'                          => ['login' => true, 'register' => true],
        'require_factor_enrolment_from_usertype' => 80,
    ],
],

Off by default is the contract, not caution. This framework is shared by applications that did not ask for any of this, and several of these end sessions, refuse logins or send mail — changing that silently on an upgrade is an incident. It also means each one can be described as a decision with a price, which is the honest way to present a security control.

Switch What it stops What it costs
regenerate_session_on_login session fixation — an id valid before authentication stays valid after it sessions.sid is stale for the rest of that one request; an application keying its own state on the session id must know
ip_rate_limit one address trying one password against ten thousand usernames, which no per-account counter ever sees a shared office or campus NAT shares the counter
notify_security_changes a stolen session changing the address and then the password with the owner never hearing mail, per change, including the routine ones
session_idle_timeout a session left open on a shared screen somebody is signed out while reading
session_absolute_timeout a session that stays valid for a year because it is used daily everybody re-authenticates on a schedule
revoke_sessions_on_password_change the attacker keeping the account after the owner "fixes" it other devices are signed out, which reads as a fault if it was routine hygiene
require_second_factor_from_usertype an administrator with a password and nothing else a step-up the person did not choose; it resolves to a mailed code, so it cannot lock them out
password_history "change it" meaning "type the same one again" somebody who wants their old password back cannot have it, and support cannot give it to them
totp_replay_cache one TOTP code completing two logins inside the same 30-second window needs a cache that can count atomically (Redis, memcached); without one the older guard applies and nothing is refused
human_check a script submitting the sign-in, registration or reset form thousands of times for free the visitor's battery, and a browser with no Web Worker or no crypto.subtle cannot submit the form at all
require_factor_enrolment_from_usertype an administrator satisfying the requirement above with a mailed code for ever every page redirects to the setup screen until they enrol; needs RequireFactorEnrolmentMiddleware registered

Three of them are worth expanding, because their shape matters more than their name.

The rate limit is per address and the lockout is per account, and they are not alternatives. loginlockoutsteps protects one account from being guessed at. It is no defence at all against the attack that actually happens — a list of leaked username/password pairs, one attempt each — because every per-account counter stays at 1. The address limit answers that half: N failures from one address in a window, then that address is refused for the rest of the window. Fixed, not a ladder, so a shared NAT is slowed rather than banned for a day.

Two details of recordFailedAttemptWithin() that are the difference between a limit and a denial of service:

  • The deadline belongs to the window the first failure opened, not to the latest attempt. Refusing until now + window on every failure would let a slow attacker hold an address refused for as long as they cared to keep typing — and the people that punishes are the ones sharing the address, not the attacker, who has a thousand others.
  • A threshold or window below 1 disables the limiter rather than tightening it. attempts >= 0 is true of every attempt, so a mistyped 0 would otherwise refuse the first request from every address on the site, for the length of the window, with nothing naming the setting.

The address limit is checked before the password is. That is the order, and it is the security property rather than an implementation detail: a limiter that verified the credentials first and then refused would still stop brute force and would also be a perfectly good oracle for credential stuffing — the attacker reads the difference between «locked» after a real password and «locked» after a wrong one, or simply the timing. So a limited address learns nothing at all, including whether the password it sent was right.

It is a second counter rather than a wider one, because the two answer different questions: the account lockout stops somebody guessing one password, and the address limit stops somebody trying one password against ten thousand accounts — which no per-account counter can see, since each account records exactly one failure. The configured window and threshold travel with each record, so an installation that sets them gets them; a limiter reading a default window would ignore the configuration it was given.

Notices go to the previous address too. That is the whole reason SecurityChangeNotifier exists rather than a line at each call site: a stolen session's first two moves are to change the address and then the password, and every notice after the first goes to the attacker. The mail to the old address is the only signal the owner gets.

Four details of that, because each is a way to lose the signal while appearing to send it:

  • The old address is mailed through a detached notifiable, not by re-pointing the user object. The user is a live model other code holds, and mutating its address to send one mail is the kind of change that leaks into a save().
  • It is skipped when it matches the current one case-insensitively and after trimming. An address that changed only in capitalisation has not changed, and two identical mails about one event teach the recipient to ignore both.
  • It is skipped when it is not an address. It arrives from whatever the account held before — a legacy row, an import, a column somebody once used for a note — and handing that to the mailer is a bounce at best, and on a transactional provider a reputation charge to the sender.
  • userid 0 and 1 are refused before a user is loaded. A notice «about» the guest or system row is a mail to whatever address those rows carry, reporting a change to an account nobody owns.

And the whole thing is best-effort: a send that fails is logged and swallowed. A notification is never worth failing the change it reports — somebody told «your password could not be updated» because a mail server was down will try again, and the second attempt, on an account whose password did change, is what actually goes wrong.

Timeouts are enforced in Session::staticIsLogged(), not in a middleware, because that function is what every "is somebody signed in" path goes through — the current user, the controllers' guard, the API's session exchange. A timeout in a middleware is a timeout the paths that skip the middleware do not have. A session with no recorded start is treated as starting now, so switching either limit on does not sign out every existing session at once — which is how a security setting gets switched straight back off.

The TOTP replay guard closes a window the old one could not see. last_used on the account stops a code being reused one request after another; it cannot stop two requests inside the same window, because both read the same timestamp and both conclude the code is fresh. That is not theoretical — it is a phished code replayed immediately, or a double-submitted form. Answering it needs a store both requests can see atomically, which is what Cache::increment() is; a count of 1 means this request claimed the code. When no counting cache is available the code is allowed on the older guard rather than refused, because a login that fails when Redis is down is worse than the window.

The two second-factor floors are one decision in two switches. require_second_factor_from_usertype makes a factor a condition of signing in. It cannot lock anybody out, and that is deliberate: an account above the floor with nothing enrolled is asked for a code by email, which every account can satisfy — enrolment happens after signing in, so refusing the mail would be a lockout by design.

Which means that switch alone leaves an administrator holding nothing but a mailbox, and a mailed code is the weakest factor here: it is one mailbox compromise from being no factor at all, and the password reset arrives at the same address. require_factor_enrolment_from_usertype is the other half. Set it to the same number, and register the middleware:

'middleware' => [
    'Pramnos\Http\Middleware\RequireFactorEnrolmentMiddleware',
],

Then an account at or above that usertype has every page redirected to the second-factor setup screen until it holds an authenticator, a passkey, or an adaptor scoring at least FactorEnrolment::MIN_STRENGTH. The mailed code becomes the on-ramp rather than the destination.

Three things make it a wall rather than a trap:

  • The doors out stay open — the setup screens, the passkey endpoints, the sign-in flow, the account area, signing out, the API and the discovery documents. Every one of those is a lockout on its own if it closes.
  • It fails open. No session, no application, a store that will not read: the request goes through. Guessing the other way redirects every administrator in a loop, and the screen that would fix it is one of the ones being redirected.
  • There is a way back from a terminal. auth:twofactor-status --missing lists who the wall will stop before you set the switch; auth:twofactor-reset <user> clears an enrolment so somebody who lost their authenticator can enrol again — they sign in with a mailed code and meet the wall, which is the whole recovery path and needs no secret read out over a phone. Neither command ever prints a secret, a QR URI or a backup code.

Password history is compared with the login's own verifier. A previous password is stored exactly as users.password stored it and checked with PasswordHash::verify() — a second comparison written for this would be a second thing to get wrong. It costs one bcrypt verification per remembered hash, on a password change and nowhere else. And it fails open: with no table, nothing is refused, because the change is something somebody is doing for a reason.

What is deliberately not here: checking passwords against breach corpora. It needs an outbound call per password change to a third party, and that is a decision an application makes with its own privacy notice, not one a framework makes for it.

Secrets at rest: hash what you verify, encrypt what you use

Two kinds of secret live in the database, and they want opposite treatment.

A secret you only ever check — a user's password, a 2FA backup code — is hashed. Nothing ever needs the original back, so nothing should be able to get it back. PasswordHash::make() and password_verify() do this, and the password section above covers it.

A secret you have to use — an SMTP password, a webhook signing key, a TOTP seed — cannot be hashed, because the application needs the actual bytes to authenticate outbound, compute an HMAC, or derive a code. Those are encrypted with Pramnos\Security\Encrypter.

Reaching for encryption on the first kind is a downgrade, not extra safety: a reversible secret plus an APP_KEY that sits in .env on the same host gives an attacker back what a hash never would.

Using the Encrypter

use Pramnos\Security\Encrypter;

$stored = Encrypter::encrypt($apiToken);   // "enc:v1:…", safe for any text column
$token  = Encrypter::decrypt($stored);     // back to the original

// A column that may still hold values written before it was encrypted:
$token = Encrypter::maybeDecrypt($row['api_token']);

NaCl secretbox (XSalsa20-Poly1305) via libsodium, keyed from APP_KEY. It is authenticated, so a value altered in the column fails to open instead of decrypting to something plausible — decrypt() throws rather than guessing.

maybeDecrypt() is what makes adoption free. It returns anything without the enc:v1: marker unchanged, so a column can be read through it from the first deploy: old rows come back as they are, new rows come back decrypted, and the column converts itself as values are rewritten. No migration, no downtime.

Encrypter::isAvailable() reports whether APP_KEY is set, for a screen that would rather warn than fail.

What it protects, and what it does not

The key is in .env, on the same host as the application. So this defends against every way a database is read without the filesystem — a leaked backup, a dump handed to a contractor, SQL injection in an unrelated endpoint, a hosting neighbour, a DBA reading rows they should not. It does not defend against an attacker who owns the host: they read .env and decrypt at leisure.

Worth having. Worth not overstating — "encrypted at rest" in a compliance answer means the first list, never the second.

Rotating APP_KEY

Everything encrypted under the old key becomes unreadable, loudly: decrypt() throws rather than returning nonsense. Re-encrypt before rotating, or accept that those credentials have to be entered again.

What the framework already encrypts

Three credentials the framework stores on your behalf are encrypted with no work from you, and all three are recoverable secrets rather than verifiable ones:

Value Where Why it cannot be hashed
smtp_pass settings SMTP AUTH needs the password
Webhook signing secret oauth2_webhook_endpoints.secret_key it is the HMAC key each delivery is signed with
TOTP seed user_twofactor.secret, twofactor_setup.temp_secret every code is derived from it
Realtime channel key applications.broadcast_secret it signs channel authorizations
Access/refresh tokens, auth codes usertokens.token an administrator screen offers them for copying

And two that are hashed, because the server only ever verifies them:

Value Where
OAuth2 client secret applications.apisecret
2FA backup codes user_twofactor.backup_codes

The client secret is shown once, when it is created or rotated, and cannot be read back afterwards — that is what hashing buys.

Tokens are encrypted rather than hashed, on purpose

A token is verified and never re-read, so hashing is the reflex — and it is what a usertokens.token column would get if nothing else were true. Something else is: administrators reproduce a failing integration by copying a token into curl, and that is a real tool, not a leftover.

So the column is split. token_lookup holds sha256(token) and is what all fifteen authentication lookups match on; token holds the value encrypted, and Token::reveal() is the only way back to it.

$plain = (new Token())->reveal($row['token']);   // '' when APP_KEY cannot open it

Two consequences worth stating.

The digest is unkeyed. A keyed HMAC would be right for a secret somebody could guess; every value here is 256 bits from random_bytes() or a signed JWT, so there is no dictionary to attack. Keying it would instead make APP_KEY load-bearing for authentication — rotating the key would sign everybody out. Unkeyed, a rotation costs the ability to reveal a token and leaves authentication working.

reveal() does not make handing a token out safe. Whoever receives one can act as its owner, and the resulting requests are indistinguishable from the owner's — the action log will record them as theirs. Offer it where an administrator genuinely needs it, and log the fact if that matters to you. A row written before hashing still holds a plaintext secret and converts itself the first time that client authenticates successfully, so there is no migration to run and no window where a client cannot connect.

Every lookup matches token_lookup, and there is a test that says so.

->where('token_lookup', \Pramnos\User\Token::lookup($presented))   // right
->where('token', $presented)                                        // matches nothing

Comparing token cannot match a presented value any more, and every caller in this framework reads "no row" as "not a valid token" — so a missed lookup fails closed, silently, on a path that worked the day before. Two were missed when the column was split, both written as where('ut.token', …): the aliased form, which a grep for where('token' does not see. Oauth::selectTokenRow() made introspection answer {"active": false} for every token the server had issued, and OAuth2Middleware::loadTokenFromDatabase() refused every Bearer request.

TokenAtRestTest::testNoLookupMatchesOnTheTokenColumn() reads src/ and fails on any surviving comparison, alias or not. Add a sixteenth lookup and it will tell you.

The mocked unit tests over introspect() and revoke() stayed green through both, which is the other half of the lesson: a mocked query builder returns the prepared row whatever the WHERE says, so it cannot answer a question about a column.

Nothing about using these changes — getSecret() returns a base32 seed, a delivery signs with the real key, getSetting('smtp_pass') returns a password. Only the rows are different, and rows written before the change keep working until something rewrites them.

Settings that are encrypted automatically

Settings encrypts the values it knows are credentials, listed in Settings::ENCRYPTED_SETTINGS. Currently that is smtp_pass.

Nothing else changes: Settings::getSetting('smtp_pass') returns the plaintext it always did, and setSetting() takes a plaintext. Only the row is different. An existing installation needs no migration — the row converts itself the next time the settings screen is saved — and if APP_KEY is not set the value is stored as before, because a settings screen that refuses to save is worse than the problem it would be avoiding.

XSS Prevention

View Escaping Helpers (v1.2)

A global e() function and View::escape() / View::e() instance methods wrap htmlspecialchars() with the safest flags.

Flags used: ENT_QUOTES | ENT_SUBSTITUTE — escapes both single and double quotes; replaces invalid UTF-8 with U+FFFD.

<!-- Short form — most common -->
<h1><?php echo e($model->title); ?></h1>
<p><?php echo e($model->description); ?></p>
<input name="q" value="<?php echo e($request->get('q')); ?>">

<!-- Via $this in a View template -->
<h1><?php echo $this->e($model->title); ?></h1>
<a href="<?php echo $this->escape($model->url); ?>"><?php echo e($model->label); ?></a>

<!-- Trusted HTML — no escaping needed -->
<?php echo $doc->getContent(); ?>

Escaping Table

Input Output
<script>alert(1)</script> &lt;script&gt;alert(1)&lt;/script&gt;
" onclick="alert(1) &quot; onclick=&quot;alert(1)
it's fine it&#039;s fine
AT&T AT&amp;T
null / false '' (empty string)
42 / 3.14 '42' / '3.14'

API Reference

Symbol Description
e(mixed $value, string $encoding = 'UTF-8'): string Global function — HTML-escape a value
View::escape(mixed $value, string $encoding = 'UTF-8'): string Instance method — delegates to e()
View::e(mixed $value, string $encoding = 'UTF-8'): string Short alias for escape()

Context-Aware Escaping

e() is for HTML character escaping only. For other contexts:

// JavaScript context
$escaped = json_encode($text);

// URL context
$escaped = urlencode($text);

// CSS context — whitelist approach
$escaped = preg_replace('![^a-z0-9-]!i', '', $text);

Note: e() does not filter javascript: URIs. Validate and whitelist URLs at the application level, or use CSP.

SQL Injection Prevention

Use Parameterized Queries

// UNSAFE — never do this
$sql = "SELECT * FROM users WHERE email = '" . $_POST['email'] . "'";
$result = $db->query($sql);

// SAFE — use QueryBuilder
$user = $db->queryBuilder()
    ->from('users')
    ->where('email', $_POST['email'])  // Automatically parameterized
    ->first();

// SAFE — use prepareQuery with printf-style
$sql = $db->prepareQuery("SELECT * FROM users WHERE email = %s", $_POST['email']);
$result = $db->query($sql);

QueryBuilder Escaping

The QueryBuilder automatically handles escaping:

$users = $db->queryBuilder()
    ->from('users')
    ->where('username', 'LIKE', '%' . $search . '%')  // Auto-escaped
    ->get();

Authentication

Login/Logout

// Login
$user = \Pramnos\User\User::authenticate($username, $password);
if ($user) {
    $session->set('userid', $user->userid);
    // Success
} else {
    // Authentication failed
}

// Logout
$session->destroy();

Login Lockout

Prevent brute-force attacks:

$lockout = new \Pramnos\Auth\Loginlockout($user);

// Check if user is locked out
if ($lockout->isLocked()) {
    return "Too many login attempts. Try again in " . $lockout->getRemainingTime() . " seconds";
}

// Record failed attempt
$lockout->recordFailure();

// Clear failures on success
$lockout->clearFailures();

Two-Factor Authentication

Protect accounts with 2FA:

$totp = new \Pramnos\Auth\TOTPHelper($user);

// Generate secret
$secret = $totp->generateSecret();

// Verify code
if ($totp->verify($code)) {
    // Code valid
} else {
    // Code invalid
}

Content Security Policy

CSP Headers

Protect against XSS by restricting script sources:

// In controller or middleware
$response->setHeader('Content-Security-Policy', 
    "default-src 'self'; script-src 'nonce-" . $nonce . "'; style-src 'unsafe-inline'");

// In template
<script nonce="<?php echo $nonce; ?>">
    // Only this script executes
</script>

Nonce Generation

$nonce = bin2hex(random_bytes(16));
// Pass to template and validate on execution

File Upload Security

Validate Uploads

if ($_FILES['avatar']['size'] > 5 * 1024 * 1024) {
    throw new \RuntimeException('File too large');
}

$allowed = ['jpg', 'png', 'gif'];
$ext = pathinfo($_FILES['avatar']['name'], PATHINFO_EXTENSION);

if (!in_array(strtolower($ext), $allowed)) {
    throw new \RuntimeException('File type not allowed');
}

// Verify MIME type
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['avatar']['tmp_name']);

if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif'])) {
    throw new \RuntimeException('Invalid file type');
}

// Move to secure location
move_uploaded_file($_FILES['avatar']['tmp_name'], '/secure/uploads/avatar_' . uniqid() . '.jpg');

Who is the client? Trusted proxies

$_SERVER['REMOTE_ADDR'] is the connecting peer. Behind a reverse proxy, a CDN or a load balancer that peer is the proxy, so every visitor in the world shares one address. A per-IP rate limit becomes a global one and fires for everybody at once; anything binding a session to the address binds every session to the same value.

Use Request::clientIp() rather than reading REMOTE_ADDR directly:

$ip = \Pramnos\Http\Request::clientIp();

It also decides whether the request was HTTPS

Session::isHttps() answers for the connection the browser made, not the one PHP received. Behind a proxy that terminates TLS those differ: $_SERVER['HTTPS'] is empty on the plaintext hop, and the session cookie was issued without its secure flag — so it then travelled on any http:// request to the domain.

X-Forwarded-Proto is consulted, but only when the peer is in trusted_proxies. The header is client-supplied, so believing it unconditionally would let any visitor assert https and be handed a secure cookie over a plaintext connection. With no proxies declared the answer is $_SERVER['HTTPS'] alone, exactly as before.

One more reason the list is worth configuring even when per-IP rate limiting is not in play.

Configure the proxies, or nothing changes

// app.php
'trusted_proxies' => ['private_ranges'],                      // shorthand
'trusted_proxies' => ['cloudflare'],                          // shorthand
'trusted_proxies' => ['10.0.0.0/8', '2001:db8::/32', '192.0.2.7'],

With the list empty — the default — the answer is REMOTE_ADDR, unchanged.

This is not an oversight, it is the point. Reading X-Forwarded-For unconditionally would be worse than doing nothing: the header is written by the client, so an attacker setting a fresh random value on every request gets a fresh rate-limit bucket every time and defeats the limiter completely — while the logs show a healthy spread of addresses and the limiter reports that it is working.

So a forwarding header is believed only when the peer that delivered it is itself a trusted proxy, and the chain is walked from the right — the end the infrastructure appended — taking the first address that is not a trusted hop. The leftmost entry is the client-supplied end and is never trusted.

Forwarded (RFC 7239) and CF-Connecting-IP are understood under the same rule. X-Real-IP is deliberately ignored: it is single-valued, so there is no chain to walk.

Behind Cloudflare?

Set 'trusted_proxies' => ['cloudflare']. Until you do, session and token records store the Cloudflare edge address rather than the visitor's. ClientIpResolver::CLOUDFLARE_RANGES is a snapshot of the published list and does change — pin your own copy if this matters to you.

Human checks on public writes

\Pramnos\Security\HumanCheck is proof-of-work, not a CAPTCHA:

$check     = new HumanCheck(difficultyMs: 300);
$challenge = $check->challenge();     // hand to the page

if (!$check->verify($submitted['challenge'], $submitted['solution'])) {
    // refuse
}

Pair it with scaffolding/assets/js/pf-humancheck.js, which solves the challenge in a Web Worker while the visitor types.

Read the limit before adopting it. Proof-of-work does not stop automated submissions — it prices them. An attacker with a botnet and free CPU still gets through; what changes is that a thousand signups cost real compute instead of nothing. It is the right defence against volume and no defence at all against a targeted attack. Code reading a passed check must not conclude that a human was involved, because nothing here establishes that.

It is single-use (enforced through an atomic counter — a replayed solve is the obvious bypass), HMAC-signed with its own expiry, and costs the visitor battery, which is why difficulty is set in milliseconds of work on a mid-range phone and per call site.

On the bundled auth forms

Account's sign-in, registration and password-reset actions carry the check already — switched off, like every other account-security switch:

'auth' => ['security' => ['human_check' => true]],                  // all three forms
'auth' => ['security' => ['human_check' => ['register' => true]]],  // or name them

The form names are login, register and forgot. true means all three; an array names them one at a time and anything absent stays off.

Three details decide whether this is safe to switch on:

  • The check runs immediately after the CSRF check and before the credentials are read, so a refused submission costs no password verification and no mail.
  • It fails closed. A submission with no challenge, or with one that does not verify, is refused — a missing solution is the normal shape of an automated post. Minting, on the other hand, fails open: if a challenge cannot be created the page still renders, and the verification then refuses the submission, which is the same answer arrived at from the other end.
  • Any browser with JavaScript can solve it. There are four paths and they all produce the same answer:
Hash Where
1 crypto.subtle a Web Worker
2 this framework's own SHA-256 a Web Worker
3 crypto.subtle the main thread
4 this framework's own SHA-256 the main thread, in slices

Paths 2 and 4 exist because crypto.subtle is only available in a secure context — HTTPS, or localhost. A site reached over plain HTTP by hostname or LAN address (a staging box, a colleague's machine, a tablet on the office network) has none of it, and until those paths existed the check could not be solved there at all: the form submitted an empty solution, the server refused it, and the visitor read "your browser must support JavaScript" while using a browser that supported it perfectly well. It happened on a login form.

Hashing in JavaScript is slower for the same difficulty, which is the right trade — a slow sign-in beats a sign-in that cannot happen. On the main thread the search is sliced with setTimeout so the tab keeps responding.

What is left over is a browser with JavaScript switched off, which submits an empty solution and is refused. That one cannot be otherwise: the check is the JavaScript, and letting a request through because it claimed to have none would make the check bypassable by saying so.

The signing key needs no setting up. securitySalt is used when the installation has one; otherwise the class generates 32 random bytes on first use and keeps them in the humancheck_secret setting. It does not write securitySalt itself — that value salts stored passwords, and filling it in would change how every existing password verifies.

The worker wants blob: in worker-src, which the framework's default policy allows. pf-humancheck.js builds its solver from a Blob rather than a published file, so adopting the check is one script tag. Under worker-src 'self' alone the browser refuses the worker — which is no longer fatal: the client falls back to solving on the main thread. It is slower, so a project writing its own policy should still allow it.

The client and the server agree, and that is a test

They have to match byte for byte: the payload with the signature stripped, : between payload and candidate, SHA-256, leading-zero bits, and base-36 for the candidate. Any one of those being wrong looks identical from production — a refused login, blamed on the browser.

So it is not a claim. HumanCheckClientAgreementTest loads scaffolding/assets/js/pf-humancheck.js — the exact bytes a browser is served — under Node, runs it down all four paths with the capabilities of each withheld the way an old browser withholds them, and verifies every answer with HumanCheck::verify(). The pure-JS SHA-256 is compared against a real one over empty input, ASCII, a long string, Greek text and an emoji (surrogate pairs and multi-byte UTF-8, which is where a hand-written encoder goes wrong).

Change the separator in that file by one character and five of its six tests fail. Without Node on the machine the test skips, loudly — run it somewhere with Node before shipping a change to that file.

Do not reimplement the hash to test it. A PHP copy of the algorithm agrees with itself and proves nothing; the only useful assertion is the one that makes the shipped client do the job.

The view side is one line, humanCheckField():

<form method="post" action="…">
    <?php echo \Pramnos\Http\Session::getInstance()->getTokenField(); ?>
    <?php echo humanCheckField($this->humanCheck ?? null); ?>

It returns an empty string when the check is off for that form, so the line is safe on every one of them, and otherwise emits the two hidden fields, marks the enclosing form for pf-humancheck.js, and carries the CSP nonce on the script it needs.

A function rather than a partial, which is what it was first. A partial lives in a view directory and a view directory is per-application: the sign-in page is the one screen no project inherits, so every project had to copy the partial in to use the feature and then owned a copy of the framework's markup for ever.

The nonce is not optional. A project with a strict Content-Security-Policy drops an un-nonced inline script silently — no error a person sees — so no solution is ever computed and the check then refuses every submission. That is a public form nobody can send, presenting as the check doing its job. Both script tags carry it: the inline one that hands the challenge to the form, and the src one that loads the worker.

The field id is derived from the token, so two forms with a check on one page — a sign-in beside a registration — do not collide. Without that the script would find the first form's input from both, and the second would submit an empty solution.

The challenge is encoded with JSON_HEX_TAG, because it lands inside a <script> element: </script> closes a script tag wherever it appears in one, since HTML looks for those characters and does not know it is inside a JavaScript string. Quoting is no defence there. The real token is hex.int.int.hmac and cannot contain a <, so nothing reachable depended on it — but this is a global helper with an array parameter, and a view may call it with its own.

When the human check itself breaks

The two halves of it fail in opposite directions, on purpose, and the combination is worth knowing before an incident rather than during one.

What broke What happens Why that way round
Minting a challenge (challenge() raises) the form renders without one an exception here would take the sign-in page down, which is a worse outage than a check nobody can solve
Verifying a submission (verify() raises) the submission is refused a check that accepted when verification broke would be bypassable by breaking verification
A submission with no challenge or no solution refused, without asking the service otherwise omitting the fields — the first thing anything automating a form does — would be enough to skip the check
The form is not gated by human_check passes, and mints nothing the early return has to come before the service is touched, or turning the feature off would still break when the service does

Put together: with the service down, the form renders and every submission is refused. The page is up and nobody can sign in. That is fail-closed, which is the defensible choice for a security control, but it is not what "the page renders without one" on its own suggests — so if you enable human_check on the login form, the check is on the critical path for signing in and its availability is your sign-in availability.

Two consequences for an operator:

  • Enable it on register and forgot before login. Those are the forms the check is really for — a thousand free registrations or reset mails — and neither is on the path of somebody who already has an account.
  • A sign-in outage with Δεν ολοκληρώθηκε ο έλεγχος ασφαλείας on the page is this, not a credential problem. The log line is HumanCheck challenge failed for login: … or HumanCheck verification failed for login: …, in the auth channel.

Forced second-factor enrolment fails open

RequireFactorEnrolmentMiddleware walks a privileged account to the setup screen until it has enrolled a second factor. Its decision has five ways of saying "not this request", and one of them is worth knowing before you rely on the wall:

Condition Answer
require_factor_enrolment_from_usertype is 0 not gated — the feature is off by default, so upgrading the framework cannot lock an installation out
nobody is signed in not gated — there is nothing to enrol, and the sign-in flow must not sit behind a screen that needs signing in
the path is on the allow-list not gated, without consulting the enrolment service
there is no current user object not gated
the enrolment service says so gated: redirect, and $_SESSION['factor_enrolment_required'] is set for the screen to explain itself
the decision raises not gated, and a line in the auth log

That last row is deliberate and it is the opposite of the human check on the sign-in form, which fails closed. The difference is what each failure costs. A broken human check refuses new submissions; a broken enrolment check would redirect every page of the site to a setup screen, and if the allow-list were ever wrong, the setup screen too. Locking an installation out of itself because a lookup failed is worse than a privileged account going one more request without a second factor.

So: this wall is not a containment boundary. It gets accounts enrolled; it is not something to rely on for keeping an unenrolled administrator out of a screen. If a screen must refuse an account without a second factor, check that on the screen.

The allow-list short-circuiting before the service is consulted matters for more than speed: the setup screen is itself a gated path, and a decision that ran first would send it to itself for ever. Paths are compared on the first segment, not as a substring — logo.png is not logout.

A TOTP code is single-use only if you ask for it, and only best-effort

A six-digit code verifies for its 30-second window plus the drift accepted either side — about ninety seconds — and within that window it verifies every time it is presented. Anybody who sees one submission can replay it: a proxy, a shared screen, a phishing page that forwards what it was given.

TwoFactorAuthService makes the first presentation the only one, by claiming the code in a counting cache. Two things to know before relying on it:

It is off unless you turn it on.

'auth' => ['security' => ['cache_totp_replays' => true]],

Without that, claimCode() is never called and a code is replayable for its whole window.

With it on, it stands down rather than refusing. Three separate ways:

Condition Result
the cache has no atomic counter (a file cache, no cache at all) the claim is allowed
the counter answers false — the server is unreachable mid-request allowed
anything raises allowed, and a line in the auth log

Deliberate: refusing every second factor while Redis is down is a larger failure than a ninety-second replay window. But it means the protection is best-effort, which is not what "single-use" sounds like — so it needs a counting cache to be worth anything. Redis or Memcached; a file cache cannot count atomically, and a read-modify-write would lose claims exactly when it matters, since two requests reading 0 would both write 1 and both believe they were first.

Two details that are right and easy to get wrong if this is ever reimplemented: the key contains a hash of the code and never the code, so a cache dump or a Redis MONITOR does not hand out live second factors; and the claim carries the account id, because six digits and a 30-second window make two accounts producing the same code at the same moment unlikely but possible — and a key without the account would sign one of them out of their own login.

A URL is on your site only if the next character is a boundary

Anywhere the framework decides whether a URL belongs to this installation — a Referer it will turn into a link, a returnUrl it will redirect to — the check cannot be a plain prefix match:

// Wrong, and it is an open redirect.
if (str_starts_with($url, $base)) { … }

For a base of https://example.com, the URL https://example.com.evil.test/phish starts with it. An attacker registers a host whose name begins with yours, sends a signed-in administrator to a page that remembers where they came from, and that page renders the attacker's URL as its own «Back» link — an open redirect with your site's appearance vouching for it. Nothing in the markup looks wrong.

What follows the base has to be a boundary — /, ?, #, or the end of the string:

protected static function isOnThisSite(string $url, string $base): bool
{
    if ($url === '' || $base === '') {
        return false;
    }

    if ($url === $base) {
        return true;
    }

    if (!str_starts_with($url, $base)) {
        return false;
    }

    return in_array($url[strlen($base)] ?? '', ['/', '?', '#'], true);
}

A trailing slash alone is not enough, and the reason is worth knowing before you simplify it: /adminer?db=x is a page at /adminer with no slash after it. The same comparison is used to ask "is this URL inside that section", so a rule demanding a slash stops recognising a section addressed with a query string.

Two more rules go with it, both of which the DevPanel's Back button follows:

  • check on the way out as well as on the way in. A value kept in the session outlives the request that validated it — a changed site URL, or a session restored from elsewhere, leaves something in there that was once ours and no longer is.
  • escape it. It arrived in a header and it is going into an href.

Fetching a URL somebody else chose

A URL a visitor typed is not a URL the server may request, and the gap between those two is where server-side request forgery lives. The server sits inside a network the visitor cannot reach: a cloud provider's metadata endpoint on 169.254.169.254 serving credentials to anything that asks, an unauthenticated admin panel on loopback, the database on a private address, a neighbour service that trusts whatever arrives from inside the subnet. file_get_contents($url) on a caller-supplied address turns the application into a proxy into all of that, and the response usually goes straight back to whoever supplied the address.

$reason = null;
$status = 0;
$body = \Pramnos\Security\OutboundUrl::fetch($url, 5 * 1024 * 1024, $reason, 10, 0, $status);

if ($body === false) {
    // $reason says why. It never names the resolved address.
}

if ($status < 200 || $status > 299) {
    // A body arrived, but it is not the thing you asked for.
}

Check the status, not only the bytes. ignore_errors => true is deliberate — a caller that wants to read a 404's body should be able to — so a body arriving is not the same as a request succeeding. «Check the content» answers this for most bodies and fails on the case that matters: a CDN answering 404 with a placeholder image returns bytes that are a valid PNG, so every content check passes and somebody's grey «image not found» square is stored as the thing that was requested. Which is worse than storing nothing — nothing is visible as a gap, and a placeholder looks like a result.

The status is also the only place the difference between this address is permanently wrong (404, 410) and this server had a bad minute (a timeout) lives, and that difference is what decides whether you forget an address or retry it tomorrow.

fetch() is the thing to reach for. isPublic() is available on its own for a URL you are going to store rather than request now — a webhook target, a feed address in a settings screen — but prefer the combined call for an actual fetch, and the next section says why.

The check and the request have to be the same operation

Between an isPublic() that passed and a file_get_contents() that follows sits a second DNS lookup. A hostile resolver answers with a public address the first time and 127.0.0.1 the second, and the whole check was theatre. So fetch() dials the address it approved and puts the name in Host:, which makes «what did we check» and «what did we connect to» the same answer. The certificate is still verified against the name, via peer_name and SNI.

Redirects are followed only when asked, and every hop is checked

A 302 is a second URL, chosen by the server being fetched. The stream wrapper follows it without asking anybody, so an address that passed the check redirects to the metadata endpoint and the fetch proceeds — which is not hypothetical: an importer that checked its catalogue's host once and left the wrapper's following on was measured taking exactly that hop, with the second address receiving the connection.

$body = \Pramnos\Security\OutboundUrl::fetch($url, $maxBytes, $reason, 10, maxRedirects: 3);

0 (the default) follows none. Above that, each Location is resolved against the address that sent it and passed through isPublic() before it is dialled, and a refusal on any hop fails the whole fetch with a $reason saying which.

A redirect that is not followed is a failure, not an empty success. ignore_errors => true is what lets a caller read a 404 body, and it also meant a 302 came back as a successful fetch of an empty string — with no status and no Location anywhere in the return, so a caller could neither act on it nor know it had happened.

Refusing redirects outright is usually not open to you, which is why this is not simply off. An address that has sat in a catalogue for years is very often an http:// that now redirects to https://, or a path a CDN has since moved; refusing those is safe and useless.

Driving your own loop

If you need to do something between hops — count them differently, log each one, stop at a host you recognise — the decision is available on its own:

$hop = \Pramnos\Security\OutboundUrl::nextHop($currentUrl, $responseHeaders, $reason);
// string → the next checked address · null → not a redirect · false → refused, $reason says why

null and false are different answers and a loop turns on the difference: null means «this is the response», false means «stop, and do not use what you have». A falsy check collapses them and turns every ordinary 200 into a failure.

statusOf($responseHeaders) is there for the same reason, and has the same trap as the chain above: a chain the wrapper followed itself leaves several status lines in one block, and the one describing the response in hand is the last. Reading the first classifies the final 200 of a redirect chain as a redirect.

resolveLocation($from, $location) is the part worth not writing again. Four shapes arrive in the wild: an absolute URL; //host/path, which inherits the scheme and not the host — read as a path it turns somebody else's host into a directory on yours; /path; and a bare relative path, which is relative to the directory of the current path, so /a/b + c is /a/c and not /a/b/c. .. and . are collapsed, because the address that gets checked has to be the string that gets dialled.

What each guard is actually for

Guard The thing it stops
scheme allowlist (http, https) file:///etc/passwd and php://filter/…/resource=index.php are well-formed URLs. A denylist of «not file://» misses the second.
no credentials in the URL http://expected.example@10.0.0.1/ reads as a URL for expected.example — to a person skimming a log, and to any check that looks at the string instead of parse_url()'s host.
every resolved address, both families A name with one public and one private A record passes a check that looks at the first answer, then fails whichever one the fetch happens to connect to. A private-only AAAA passes an A-only check outright.
no records is a refusal A loop that checks each resolved address passes trivially when the list is empty, so «does not resolve» becomes «no address failed».
a byte cap, applied mid-stream A URL that answers with an endless stream otherwise costs the process its memory.
the reason never names an address The refusal message is logged and often shown. «That host resolves to an address inside this network» says enough; the address itself is the network map this check exists to keep.

What it does not do

It does not vouch for the content. Bytes fetched from a public address are still somebody else's bytes: read the type from the bytes rather than from the URL, keep the extension you store consistent with what you read, and do not put fetched markup — SVG, HTML — on your own origin, where its script is same-origin. MediaObject::addRemoteImage() is worth reading as a worked example: it uses fetch(), caps the body, reads the mime with finfo from the buffer, and refuses SVG for exactly that reason.

Dependency Security

Keep Dependencies Updated

# Check for security vulnerabilities
composer audit

# Update dependencies
composer update

# Require security patches
composer require symfony/security --security-advisories

Security Headers

// In base controller or middleware
$response->setHeader('X-Content-Type-Options', 'nosniff');
$response->setHeader('X-Frame-Options', 'SAMEORIGIN');
$response->setHeader('X-XSS-Protection', '1; mode=block');
$response->setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->setHeader('Permissions-Policy', 'geolocation=(), microphone=()');

Reference

For complete technical details, see the inline documentation in the framework source.

Related Guides: - Pramnos_Authentication_Guide.md — Login lockout, 2FA/TOTP, OAuth2 - Pramnos_Framework_Guide.md — Middleware pipeline, CORS, exception handler - Pramnos_Authorization_Guide.md — Policy engine, gates, access control