<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>Session Security | FirewallSync</title><link>https://firewallsync.com/tags/session-security/</link><description>Practical cybersecurity writing for engineering teams that ship fast.</description><language>en-us</language><image><url>https://firewallsync.com/icon-512.png</url><title>FirewallSync</title><link>https://firewallsync.com/</link></image><lastBuildDate>Wed, 28 Oct 2026 04:45:00 +0000</lastBuildDate><atom:link href="https://firewallsync.com/tags/session-security/index.xml" rel="self" type="application/rss+xml"/><item><title>PHP session_regenerate_id: Stop Session Fixation Right</title><link>https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/</link><guid>https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/</guid><pubDate>Wed, 28 Oct 2026 04:45:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>Calling session_regenerate_id() at login is the start of PHP session security, not the whole of it. This guide shows the fixation attack strict mode does not stop, a tested helper for timestamp-based rotation, idle and absolute timeouts, and the grace-period shortcut that quietly undoes the fix.</description><enclosure url="https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/cover_hu2986149460493088631.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/cover_hu2986149460493088631.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/php-session-regenerate-id-stop-session-fixation/cover_hu2986149460493088631.jpg" alt="Two antique brass skeleton keys on a white background, one pointing up and one down, each tied with a thin pale pink ribbon" width="1200" height="630"></p><p>To stop session fixation in PHP, call <code>session_regenerate_id()</code>, or the timestamp-based rotation shown below, whenever a user&rsquo;s privilege changes. Do it before you store the authenticated user in <code>$_SESSION</code>. Don&rsquo;t destroy the old session on the spot. Mark it with a timestamp and refuse it after a short grace period, and enforce your own idle and absolute timeouts, because PHP&rsquo;s garbage collector is not a timeout. That is what PHP&rsquo;s own manual asks for. Below is a helper that follows it, tested against a fixation attack, plus the one shortcut in the manual&rsquo;s example that brings the attack back if you use it at login.</p>
<p>Every behaviour here was run on PHP 8.3.6 CLI with the built-in web server (<code>php -S</code>) and curl, using the default <code>files</code> save handler. The cookie flags and <code>php.ini</code> settings that go with this code are covered in our <a href="/posts/php-ini-security-hardening-production-settings/">php.ini security hardening guide</a>. This article is about the session lifecycle in your application code.</p>
<h2 id="the-attack-strict-mode-doesnt-stop">The attack strict mode doesn&rsquo;t stop</h2>
<p><code>session.use_strict_mode=1</code> makes PHP reject a session ID it never issued. That stops an attacker inventing an ID such as <code>PHPSESSID=123456</code>. But the <a href="https://www.php.net/manual/en/features.session.security.management.php">PHP manual</a> is explicit about the gap: &ldquo;an attacker can force users to use an initialized session ID which has been created by an attacker.&rdquo; The attacker visits your site, receives a real ID, and plants it in the victim&rsquo;s browser. The manual describes two routes: setting a cookie that the browser&rsquo;s precedence rules let override yours (for example from another subdomain), and JavaScript injection. If your login code keeps that ID, the attacker now holds an authenticated session.</p>
<p>We reproduced it with a minimal app whose login does only <code>$_SESSION['uid'] = 42;</code>, run with strict mode on:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ PHPFLAGS<span style="color:#f92672">=</span><span style="color:#e6db74">&#34;-d session.use_strict_mode=1&#34;</span> ./test.sh naive <span style="color:#ae81ff">8090</span>
</span></span><span style="display:flex;"><span>attacker ID: pmq468mni64u18nhukmh7vqe1v
</span></span><span style="display:flex;"><span>logged in
</span></span><span style="display:flex;"><span>ID after login: unchanged
</span></span><span style="display:flex;"><span>attacker replays old ID: account page for uid 42
</span></span></code></pre></div><p>The test script fetches <code>/</code> to get a real ID, sends <code>/login</code> with that ID as the victim, then replays the ID as the attacker.</p>
<h2 id="what-phps-manual-requires">What PHP&rsquo;s manual requires</h2>
<p>The session security chapter is unusually prescriptive. The rules that matter for application code:</p>
<ul>
<li><strong>Rotate on privilege change, before writing auth state.</strong> &ldquo;Session IDs must be regenerated when user privileges are elevated, such as after authenticating. session_regenerate_id must be called prior to setting the authentication information to $_SESSION.&rdquo; Rotate again on any later privilege change, such as entering an admin area or completing step-up authentication. The <a href="https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html">OWASP Session Management Cheat Sheet</a> makes the same point: renew the ID &ldquo;after any privilege level change&rdquo;.</li>
<li><strong>Don&rsquo;t delete active sessions immediately.</strong> &ldquo;session_regenerate_id(true); and session_destroy must never be called together for an active session.&rdquo; Deleting right away loses sessions when requests overlap or the new cookie never arrives. It also removes your chance to notice that someone used an old ID.</li>
<li><strong>Use timestamps instead.</strong> Set &ldquo;a short-term expiration time (timestamp) in $_SESSION&rdquo; on the old session and refuse it later: &ldquo;a few seconds later for stable networks&hellip; and a few minutes later for unstable networks such as cell phones or Wi-Fi.&rdquo;</li>
<li><strong>Don&rsquo;t rely on <code>session.gc_maxlifetime</code> for expiry.</strong> &ldquo;Attackers may access a victim&rsquo;s session ID periodically to prevent its expiration and keep exploiting it.&rdquo; The setting only controls when data &ldquo;will be seen as &lsquo;garbage&rsquo; and potentially cleaned up&rdquo; (default 1440 seconds).</li>
<li><strong>Treat obsolete-ID access as a signal.</strong> When an expired ID is used, the manual recommends removing &ldquo;authenticated status from all of the user&rsquo;s sessions&rdquo; and forcing re-authentication.</li>
</ul>
<p>The <a href="https://www.php.net/manual/en/function.session-regenerate-id.php"><code>session_regenerate_id()</code> page</a> has two example implementations. Both are labelled &ldquo;not fully working code, but an example!&rdquo;</p>
<h2 id="a-tested-rotation-helper">A tested rotation helper</h2>
<p><code>session_guard.php</code> follows the manual&rsquo;s pattern, with two corrections we found in testing (explained after the code). It targets PHP 8.1 or later (<code>session_create_id()</code> exists from 7.1, and the cookie-options array for <code>setcookie()</code> from 7.3).</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#f92672">&lt;?</span><span style="color:#a6e22e">php</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// session_guard.php: timestamp-based session ID rotation, adapted from the
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// pattern in the PHP manual&#39;s session_regenerate_id() page.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">declare</span>(<span style="color:#a6e22e">strict_types</span><span style="color:#f92672">=</span><span style="color:#ae81ff">1</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_GRACE&#39;</span>)    <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_GRACE&#39;</span>, <span style="color:#ae81ff">60</span>);     <span style="color:#75715e">// old ID usable after rotation (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_IDLE&#39;</span>)     <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_IDLE&#39;</span>, <span style="color:#ae81ff">1800</span>);    <span style="color:#75715e">// idle timeout (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_ABSOLUTE&#39;</span>) <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_ABSOLUTE&#39;</span>, <span style="color:#ae81ff">28800</span>); <span style="color:#75715e">// absolute timeout (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">defined</span>(<span style="color:#e6db74">&#39;SESSION_ROTATE&#39;</span>)   <span style="color:#f92672">||</span> <span style="color:#a6e22e">define</span>(<span style="color:#e6db74">&#39;SESSION_ROTATE&#39;</span>, <span style="color:#ae81ff">900</span>);   <span style="color:#75715e">// periodic rotation (s)
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">final</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ObsoleteSessionAccess</span> <span style="color:#66d9ef">extends</span> <span style="color:#a6e22e">RuntimeException</span> {}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">secure_session_start</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ini_set</span>(<span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>, <span style="color:#e6db74">&#39;1&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>    $now <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 1. Old ID after rotation: allow a short grace period, then refuse.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isset</span>($_SESSION[<span style="color:#e6db74">&#39;destroyed&#39;</span>])) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($_SESSION[<span style="color:#e6db74">&#39;destroyed&#39;</span>] <span style="color:#f92672">&lt;</span> $now <span style="color:#f92672">-</span> <span style="color:#a6e22e">SESSION_GRACE</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">ObsoleteSessionAccess</span>(<span style="color:#e6db74">&#39;obsolete session ID used&#39;</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isset</span>($_SESSION[<span style="color:#e6db74">&#39;new_session_id&#39;</span>])) {
</span></span><span style="display:flex;"><span>            <span style="color:#75715e">// Cookie for the new ID was probably lost: move the client over.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>            <span style="color:#a6e22e">session_commit</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">session_id</span>($_SESSION[<span style="color:#e6db74">&#39;new_session_id&#39;</span>]);
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 2. Idle and absolute timeouts, enforced in application code.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">isset</span>($_SESSION[<span style="color:#e6db74">&#39;uid&#39;</span>])) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($now <span style="color:#f92672">-</span> ($_SESSION[<span style="color:#e6db74">&#39;last_seen&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">SESSION_IDLE</span>
</span></span><span style="display:flex;"><span>            <span style="color:#f92672">||</span> $now <span style="color:#f92672">-</span> ($_SESSION[<span style="color:#e6db74">&#39;created&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">SESSION_ABSOLUTE</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">secure_logout</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        $_SESSION[<span style="color:#e6db74">&#39;last_seen&#39;</span>] <span style="color:#f92672">=</span> $now;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> ($now <span style="color:#f92672">-</span> ($_SESSION[<span style="color:#e6db74">&#39;rotated&#39;</span>] <span style="color:#f92672">??</span> <span style="color:#ae81ff">0</span>) <span style="color:#f92672">&gt;</span> <span style="color:#a6e22e">SESSION_ROTATE</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">secure_regenerate</span>(<span style="color:#66d9ef">true</span>);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// $forward: let a client still holding the old ID be moved to the new one
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// during the grace period (lost Set-Cookie on a flaky network). Never forward
</span></span></span><span style="display:flex;"><span><span style="color:#75715e">// on a privilege change: whoever holds the old ID would be handed the new one.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">secure_regenerate</span>(<span style="color:#a6e22e">bool</span> $forward)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    $data <span style="color:#f92672">=</span> $_SESSION;                      <span style="color:#75715e">// session_start() below loads an empty session
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    $new <span style="color:#f92672">=</span> <span style="color:#a6e22e">session_create_id</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ($forward) {
</span></span><span style="display:flex;"><span>        $_SESSION[<span style="color:#e6db74">&#39;new_session_id&#39;</span>] <span style="color:#f92672">=</span> $new;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>    $_SESSION[<span style="color:#e6db74">&#39;destroyed&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_commit</span>();                       <span style="color:#75715e">// old session keeps the markers
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_id</span>($new);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ini_set</span>(<span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>, <span style="color:#e6db74">&#39;0&#39;</span>); <span style="color:#75715e">// let session_start() accept $new
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">ini_set</span>(<span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>, <span style="color:#e6db74">&#39;1&#39;</span>);
</span></span><span style="display:flex;"><span>    $_SESSION <span style="color:#f92672">=</span> $data;
</span></span><span style="display:flex;"><span>    $_SESSION[<span style="color:#e6db74">&#39;rotated&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</span>();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">secure_login</span>(<span style="color:#a6e22e">int</span> $uid)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">secure_regenerate</span>(<span style="color:#66d9ef">false</span>);               <span style="color:#75715e">// rotate BEFORE marking authenticated
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    $_SESSION[<span style="color:#e6db74">&#39;uid&#39;</span>] <span style="color:#f92672">=</span> $uid;
</span></span><span style="display:flex;"><span>    $_SESSION[<span style="color:#e6db74">&#39;created&#39;</span>] <span style="color:#f92672">=</span> $_SESSION[<span style="color:#e6db74">&#39;last_seen&#39;</span>] <span style="color:#f92672">=</span> <span style="color:#a6e22e">time</span>();
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">function</span> <span style="color:#a6e22e">secure_logout</span>()<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    $_SESSION <span style="color:#f92672">=</span> [];
</span></span><span style="display:flex;"><span>    $p <span style="color:#f92672">=</span> <span style="color:#a6e22e">session_get_cookie_params</span>();
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">setcookie</span>(<span style="color:#a6e22e">session_name</span>(), <span style="color:#e6db74">&#39;&#39;</span>, [
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;expires&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">time</span>() <span style="color:#f92672">-</span> <span style="color:#ae81ff">3600</span>, <span style="color:#e6db74">&#39;path&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;path&#39;</span>], <span style="color:#e6db74">&#39;domain&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;domain&#39;</span>],
</span></span><span style="display:flex;"><span>        <span style="color:#e6db74">&#39;secure&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;secure&#39;</span>], <span style="color:#e6db74">&#39;httponly&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;httponly&#39;</span>], <span style="color:#e6db74">&#39;samesite&#39;</span> <span style="color:#f92672">=&gt;</span> $p[<span style="color:#e6db74">&#39;samesite&#39;</span>] <span style="color:#f92672">?:</span> <span style="color:#e6db74">&#39;Lax&#39;</span>,
</span></span><span style="display:flex;"><span>    ]);
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">session_destroy</span>();
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Wire it in by calling <code>secure_session_start()</code> instead of <code>session_start()</code>, <code>secure_login($userId)</code> after you have verified the password and any second factor, and <code>secure_logout()</code> from your logout handler. On <code>ObsoleteSessionAccess</code>, return 401 and log the event.</p>
<h3 id="what-we-tested">What we tested</h3>
<p>Against the same attack, with the helper in place:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ ./test.sh guard <span style="color:#ae81ff">8086</span>
</span></span><span style="display:flex;"><span>attacker ID: jt6upaht8b1i3hcuuk743hivm5
</span></span><span style="display:flex;"><span>logged in
</span></span><span style="display:flex;"><span>ID after login: 24qohqtgd4trsgfuov1tjn20nm
</span></span><span style="display:flex;"><span>attacker replays old ID: anonymous
</span></span></code></pre></div><p>Periodic rotation, with rotation forced on every request and a 2-second grace period:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ ./test2.sh
</span></span><span style="display:flex;"><span>account page for uid 42
</span></span><span style="display:flex;"><span>login ID 10d0f50p49pto2ocoffotrbsv3 rotated to r4428iae8369vin9533ubpb292
</span></span><span style="display:flex;"><span>old ID inside grace: account page for uid 42
</span></span><span style="display:flex;"><span>old ID after grace:  401 obsolete session
</span></span><span style="display:flex;"><span>logout with current ID: logged out
</span></span><span style="display:flex;"><span>current ID after logout: anonymous
</span></span></code></pre></div><p>With a 1-second idle timeout, a request made 3 seconds after login came back anonymous with a new session cookie, and the logged-in session&rsquo;s file was gone from <code>session.save_path</code>. The pre-login session stayed on disk, marked as destroyed, until garbage collection, as the manual intends.</p>
<h3 id="correction-1-never-forward-an-old-id-after-login">Correction 1: never forward an old ID after login</h3>
<p>The manual&rsquo;s second example deals with lost cookies: if the old ID comes back during the grace period, it switches the client to the new ID it stored in the old session. That is reasonable for periodic rotation, where the person holding the old ID is almost always the legitimate user. At login it is a hole. The attacker holds the old, pre-login ID, so forwarding hands them the new, authenticated one.</p>
<p>Our first draft forwarded in both cases, and the fixation test failed: the attacker&rsquo;s replay returned <code>account page for uid 42</code>. The manual hints at this in a comment (&ldquo;Do not try to set session ID again if you would like to remove authentication flag&rdquo;). The helper makes it explicit: <code>secure_regenerate(false)</code> at login, <code>secure_regenerate(true)</code> only for timed rotation. The trade-off is that a user whose login response is lost has to log in again.</p>
<h3 id="correction-2-carry-the-session-data-across">Correction 2: carry the session data across</h3>
<p>In the manual&rsquo;s example, the new session is started with <code>session_id($new); session_start();</code>. In our test on PHP 8.3.6, that started an empty <code>$_SESSION</code>: the authenticated user vanished on the first periodic rotation. The helper copies the data before committing the old session and restores it after starting the new one. At login this also keeps pre-login state, such as a cart.</p>
<h2 id="other-session-rules-worth-enforcing">Other session rules worth enforcing</h2>
<ul>
<li><strong>Timeouts.</strong> OWASP gives common idle timeouts of &ldquo;2-5 minutes for high-value applications and 15-30 minutes for low risk applications&rdquo;. Every session should also have an absolute timeout &ldquo;regardless of session activity&rdquo;. The helper&rsquo;s defaults (30 minutes idle, 8 hours absolute) are examples; set your own.</li>
<li><strong>Locking.</strong> PHP locks session data per request, which attackers can abuse to tie up workers. The manual suggests <code>session_start(['read_and_close' =&gt; true])</code> for requests that only read the session, and <code>session_commit()</code> as soon as you have finished writing.</li>
<li><strong>Remember-me is not a long session.</strong> The manual says not to &ldquo;use long life session IDs for auto-login&rdquo;. Use a separate, single-use random token in its own cookie, and replace it each time it is used.</li>
<li><strong>Track active sessions</strong> if you need &ldquo;log out everywhere&rdquo; or the manual&rsquo;s response to obsolete-ID access. PHP doesn&rsquo;t do this for you. The manual suggests a user-ID-prefixed ID from <code>session_create_id()</code>, which &ldquo;is vital&rdquo; to pair with strict mode. Don&rsquo;t use a confidential value as the prefix.</li>
<li><strong>Sessions don&rsquo;t stop CSRF.</strong> &ldquo;Sessions and authentication do not protect against CSRF attacks.&rdquo; See our <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF defences comparison</a>.</li>
<li><strong>Session files are an attack surface.</strong> October&rsquo;s PHP2Shell attempts tried to plant code through session files. See the <a href="/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/">PHP2Shell patch-and-hunt guide</a>.</li>
</ul>
<h2 id="common-mistakes">Common mistakes</h2>
<table>
  <thead>
      <tr>
          <th>Mistake</th>
          <th>Why it fails</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Setting <code>$_SESSION['uid']</code> before rotating</td>
          <td>The pre-login ID, which an attacker may hold, briefly carries the authenticated state. The manual says to rotate first</td>
      </tr>
      <tr>
          <td><code>session_regenerate_id(true)</code> on every request</td>
          <td>Concurrent requests lose their session, and you can no longer detect old-ID reuse</td>
      </tr>
      <tr>
          <td>Relying on <code>gc_maxlifetime</code> for logout</td>
          <td>Garbage collection is probabilistic, and activity on a stolen ID keeps it alive</td>
      </tr>
      <tr>
          <td>Forwarding old IDs after login</td>
          <td>Gives the attacker the new authenticated ID (our test)</td>
      </tr>
      <tr>
          <td>Rotating only at login</td>
          <td>A stolen post-login ID stays valid until logout or timeout. Add periodic rotation for sensitive apps</td>
      </tr>
  </tbody>
</table>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>The helper is a tested example, not a drop-in library. It assumes PHP&rsquo;s default <code>files</code> handler. A custom or Redis save handler must implement session ID validation for strict mode to work, as the manual notes for user-defined handlers. It does not implement the cross-session &ldquo;remove authentication from all of the user&rsquo;s sessions&rdquo; response, which needs an active-session registry. If you use a framework, prefer its session layer to this helper, and check that it rotates the ID at login and enforces timeouts. Our tests ran on a single PHP process. We did not load-test concurrent requests during rotation.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Strict mode does not stop initialized attacker IDs; rotate before setting auth; timestamps instead of immediate deletion; gc_maxlifetime not to be relied on; locking; auto-login; CSRF</td>
          <td><a href="https://www.php.net/manual/en/features.session.security.management.php">PHP manual: Session Management Basics</a> (doc-en <code>reference/session/security.xml</code>)</td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Example rotation code, &ldquo;not fully working code&rdquo;, lost-cookie forwarding</td>
          <td><a href="https://www.php.net/manual/en/function.session-regenerate-id.php">PHP manual: session_regenerate_id</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>gc_maxlifetime definition and 1440 default</td>
          <td><a href="https://www.php.net/manual/en/session.configuration.php">PHP manual: session runtime configuration</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Renew on privilege change; idle and absolute timeout ranges</td>
          <td><a href="https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html">OWASP Session Management Cheat Sheet</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Fixation succeeds without rotation (strict mode on); helper blocks it; forwarding at login reopens it; new session starts empty</td>
          <td>Our tests, PHP 8.3.6 CLI, <code>php -S</code></td>
          <td>Verified (our test)</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item><item><title>php.ini Security Hardening: Production Settings That Matter</title><link>https://firewallsync.com/posts/php-ini-security-hardening-production-settings/</link><guid>https://firewallsync.com/posts/php-ini-security-hardening-production-settings/</guid><pubDate>Tue, 27 Oct 2026 04:55:00 +0000</pubDate><dc:creator>Craig Leslie</dc:creator><category>AppSec &amp; APIs</category><description>PHP's own php.ini-production template still leaves several security settings at permissive defaults, including strict session mode, cookie flags and the X-Powered-By header. This guide lists the settings worth changing, shows what each one does with tested examples, explains where to set them so applications can't undo them, and is honest about the two directives that are not security boundaries.</description><enclosure url="https://firewallsync.com/posts/php-ini-security-hardening-production-settings/cover_hu4086639176484663441.jpg" type="image/jpeg" length="0"/><media:content url="https://firewallsync.com/posts/php-ini-security-hardening-production-settings/cover_hu4086639176484663441.jpg" medium="image" type="image/jpeg" width="1200" height="630"/><content:encoded><![CDATA[<p><img src="https://firewallsync.com/posts/php-ini-security-hardening-production-settings/cover_hu4086639176484663441.jpg" alt="A vintage control panel labelled Radiation, with rows of analogue meters, trip and reset lamps, and rotary dials" width="1200" height="630"></p><p>To harden PHP for production, start from <code>php.ini-production</code>, then change six settings it leaves permissive: turn off <code>expose_php</code>, turn on <code>session.use_strict_mode</code>, set the session cookie&rsquo;s <code>HttpOnly</code>, <code>Secure</code> and <code>SameSite</code> flags, and turn off <code>allow_url_fopen</code> if your code never reads URLs as files. Apply them in <code>php.ini</code> or with <code>php_admin_value</code> in the PHP-FPM pool so applications cannot undo them, and do not treat <code>disable_functions</code> or <code>open_basedir</code> as a sandbox.</p>
<p>Everything below was checked against PHP 8.4.27&rsquo;s <code>php.ini-production</code> and the PHP manual, and every behaviour we describe was run on PHP 8.3 CLI (Ubuntu 24.04 package). Where a setting behaves differently under PHP-FPM or Apache, we say so.</p>
<h2 id="the-baseline">The baseline</h2>
<table>
  <thead>
      <tr>
          <th>Setting</th>
          <th>Recommended</th>
          <th>Built-in default</th>
          <th>php.ini-production</th>
          <th>Why</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>expose_php</code></td>
          <td><code>Off</code></td>
          <td>On</td>
          <td><strong>On</strong></td>
          <td>Removes the <code>X-Powered-By: PHP/x.y.z</code> header</td>
      </tr>
      <tr>
          <td><code>display_errors</code></td>
          <td><code>Off</code></td>
          <td>On</td>
          <td>Off</td>
          <td>Errors can leak paths, queries and credentials</td>
      </tr>
      <tr>
          <td><code>display_startup_errors</code></td>
          <td><code>Off</code></td>
          <td>On</td>
          <td>Off</td>
          <td>Same, for startup</td>
      </tr>
      <tr>
          <td><code>log_errors</code></td>
          <td><code>On</code></td>
          <td>Off</td>
          <td>On</td>
          <td>You still need the errors, in a log</td>
      </tr>
      <tr>
          <td><code>allow_url_include</code></td>
          <td><code>Off</code></td>
          <td>Off</td>
          <td>Off</td>
          <td>Stops <code>include 'http://...'</code>; deprecated since 7.4</td>
      </tr>
      <tr>
          <td><code>allow_url_fopen</code></td>
          <td><code>Off</code> if unused</td>
          <td>On</td>
          <td><strong>On</strong></td>
          <td>Limits URL reads via file functions</td>
      </tr>
      <tr>
          <td><code>enable_dl</code></td>
          <td><code>Off</code></td>
          <td>On</td>
          <td>Off</td>
          <td>Stops runtime extension loading</td>
      </tr>
      <tr>
          <td><code>session.use_strict_mode</code></td>
          <td><code>1</code></td>
          <td>0</td>
          <td><strong>0</strong></td>
          <td>Rejects attacker-chosen session IDs</td>
      </tr>
      <tr>
          <td><code>session.use_only_cookies</code></td>
          <td><code>1</code></td>
          <td>1</td>
          <td>1</td>
          <td>No session IDs in URLs</td>
      </tr>
      <tr>
          <td><code>session.cookie_httponly</code></td>
          <td><code>1</code></td>
          <td>0</td>
          <td><strong>unset</strong></td>
          <td>Keeps the session cookie away from JavaScript</td>
      </tr>
      <tr>
          <td><code>session.cookie_secure</code></td>
          <td><code>1</code></td>
          <td>0</td>
          <td><strong>unset</strong></td>
          <td>Session cookie only over HTTPS</td>
      </tr>
      <tr>
          <td><code>session.cookie_samesite</code></td>
          <td><code>Lax</code></td>
          <td>unset</td>
          <td><strong>unset</strong></td>
          <td>Cookie not sent on most cross-site requests</td>
      </tr>
      <tr>
          <td><code>session.use_trans_sid</code></td>
          <td><code>0</code></td>
          <td>0</td>
          <td>0</td>
          <td>No transparent session IDs in links</td>
      </tr>
  </tbody>
</table>
<p>&ldquo;Built-in default&rdquo; is what PHP uses with no <code>php.ini</code> at all (<code>php -n</code>). Bold marks the six settings where the production template differs from the hardened value. We measured both columns with the audit script at the end of this article.</p>
<h2 id="where-to-set-them-so-they-stick">Where to set them so they stick</h2>
<p>PHP settings come from several layers, and the later ones can override the earlier ones for directives that allow it: <code>php.ini</code> and its scan directory, web server or FPM pool configuration, <code>.user.ini</code> files in the document root, and <code>ini_set()</code> in code. Each directive has a changeable mode. <code>INI_SYSTEM</code> settings such as <code>disable_functions</code>, <code>allow_url_fopen</code> and <code>allow_url_include</code> can only be set in <code>php.ini</code> or server configuration.</p>
<p>For PHP-FPM, use <code>php_admin_value</code> and <code>php_admin_flag</code> for anything security-relevant. The PHP-FPM documentation says: &ldquo;Settings defined with php_admin_value and php_admin_flag cannot be overridden with ini_set.&rdquo; Plain <code>php_value</code> and <code>php_flag</code> can be.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-ini" data-lang="ini"><span style="display:flex;"><span><span style="color:#75715e">; /etc/php/8.3/fpm/pool.d/app.conf (path varies by distribution)</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">[app]</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">user</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">app</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">group</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">app</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">listen</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">/run/php/app.sock</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_flag[expose_php]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">off</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_flag[display_errors]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">off</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_flag[log_errors]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">on</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_value[error_log]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">/var/log/php/app-error.log</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_flag[allow_url_fopen]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">off</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_value[session.use_strict_mode]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">1</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_value[session.cookie_httponly]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">1</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_value[session.cookie_secure]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">1</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">php_admin_value[session.cookie_samesite]</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">Lax</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">; Only execute .php files (the default also allows .phar)</span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">security.limit_extensions</span> <span style="color:#f92672">=</span> <span style="color:#e6db74">.php</span>
</span></span></code></pre></div><p>This is illustrative: we validated the directive names and values against the PHP-FPM documentation and our CLI tests, but did not run this pool file. Restart the pool after editing. For Apache with mod_php, the equivalent is <code>php_admin_value</code> and <code>php_admin_flag</code> in the virtual host.</p>
<p>You can see runtime locking in action. With <code>disable_functions</code> set in configuration, <code>ini_set('disable_functions', '')</code> returns <code>false</code> and changes nothing. With <code>open_basedir</code> set, an attempt to widen it to <code>/</code> also returns <code>false</code>.</p>
<h2 id="stop-advertising-your-php-version">Stop advertising your PHP version</h2>
<p><code>php.ini-production</code> ships with <code>expose_php = On</code>. Its comment says the header &ldquo;is no security threat in any way&rdquo;, and that is fair: hiding the version does not fix anything. But the header tells every scanner which PHP release you run, which turns mass scanning for a new CVE into a simple header match. We ran PHP&rsquo;s built-in server with each value:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ curl -sI http://127.0.0.1:8099/index.php | grep -i x-powered   <span style="color:#75715e"># expose_php=1</span>
</span></span><span style="display:flex;"><span>X-Powered-By: PHP/8.3.6
</span></span><span style="display:flex;"><span>$ curl -sI http://127.0.0.1:8099/index.php | grep -i x-powered   <span style="color:#75715e"># expose_php=0</span>
</span></span><span style="display:flex;"><span>$
</span></span></code></pre></div><p>Frameworks and reverse proxies can add their own version headers, so check the response at the edge too. Our guide to <a href="/posts/http-security-headers-hsts-nosniff-referrer-policy-and-permissions-policy-explained/">HTTP security headers</a> covers the headers you do want to send.</p>
<h2 id="log-errors-never-display-them">Log errors, never display them</h2>
<p>The production template sets <code>display_errors = Off</code> and <code>log_errors = On</code>, and its comment explains why: displayed errors could leak &ldquo;sensitive information &hellip; such as database usernames and passwords or worse.&rdquo; The built-in defaults are the opposite, so a server that runs PHP without loading a <code>php.ini</code> (a misconfigured container is the usual case) will print errors to users. Point <code>error_log</code> at a file or <code>stderr</code> that your logging pipeline collects.</p>
<h2 id="turn-off-url-includes-and-if-you-can-url-file-reads">Turn off URL includes and, if you can, URL file reads</h2>
<p><code>allow_url_include</code> lets <code>include</code> and <code>require</code> load code from a URL. It is off by default and the PHP manual marks it &ldquo;Deprecated as of PHP 7.4.0&rdquo;; enabling it now prints a deprecation notice at startup. Keep it off.</p>
<p><code>allow_url_fopen</code> is broader: it lets <code>file_get_contents()</code>, <code>fopen()</code> and similar functions open <code>http://</code> and <code>ftp://</code> URLs. If your application only makes HTTP requests through cURL or an HTTP client library, turning it off removes one way for user input passed to a file function to become an outbound request. It does not stop server-side request forgery through your HTTP client; see our <a href="/posts/ssrf-explained-how-server-side-request-forgery-works-and-how-to-prevent-it/">SSRF guide</a> for that. Test before you change it: some libraries and package managers rely on it.</p>
<h2 id="session-settings">Session settings</h2>
<p>PHP&rsquo;s session defaults favour compatibility. Four changes matter.</p>
<h3 id="strict-mode">Strict mode</h3>
<p>The PHP manual is blunt: &ldquo;Enabling session.use_strict_mode is mandatory for general session security. All sites are advised to enable this.&rdquo; Without it, PHP accepts any session ID the browser sends, even one the server never issued, which enables session fixation. We simulated a browser sending an attacker-chosen <code>PHPSESSID</code> cookie:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#f92672">&lt;?</span><span style="color:#a6e22e">php</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// strict.php: run with `php -d session.use_strict_mode=0|1 strict.php`
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span><span style="color:#a6e22e">ini_set</span>(<span style="color:#e6db74">&#39;session.save_path&#39;</span>, <span style="color:#66d9ef">__DIR__</span>);
</span></span><span style="display:flex;"><span>$_COOKIE[<span style="color:#a6e22e">session_name</span>()] <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;attackerchosenid123456&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">session_start</span>();
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">printf</span>(<span style="color:#e6db74">&#34;use_strict_mode=%s -&gt; session_id=%s</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>, <span style="color:#a6e22e">ini_get</span>(<span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>), <span style="color:#a6e22e">session_id</span>());
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>$ php -d session.use_strict_mode<span style="color:#f92672">=</span><span style="color:#ae81ff">0</span> strict.php
</span></span><span style="display:flex;"><span>use_strict_mode=0 -&gt; session_id=attackerchosenid123456
</span></span><span style="display:flex;"><span>$ php -d session.use_strict_mode<span style="color:#f92672">=</span><span style="color:#ae81ff">1</span> strict.php
</span></span><span style="display:flex;"><span>use_strict_mode=1 -&gt; session_id=3e4393b3059589b68078c6a9f5c4fe34
</span></span></code></pre></div><p>Strict mode does not replace calling <code>session_regenerate_id(true)</code> after login; do both.</p>
<h3 id="cookie-flags">Cookie flags</h3>
<ul>
<li><code>session.cookie_httponly = 1</code> makes the cookie inaccessible to JavaScript. The manual says it &ldquo;can effectively help to reduce identity theft through XSS attacks&rdquo;. It does not stop an XSS payload from making requests as the user.</li>
<li><code>session.cookie_secure = 1</code> sends the cookie only over HTTPS. The manual notes that with it on, &ldquo;sessions only work with HTTPS connections&rdquo;. If TLS terminates at a load balancer, that means the browser&rsquo;s connection: the flag is a cookie attribute the browser enforces, so it works as long as users reach you over HTTPS.</li>
<li><code>session.cookie_samesite = Lax</code> stops the browser sending the session cookie on most cross-site subrequests and form posts. It reduces CSRF exposure but does not remove the need for tokens; we compare the options in <a href="/posts/csrf-protection-samesite-cookies-tokens-and-fetch-metadata-compared/">CSRF protection: SameSite, tokens and Fetch Metadata</a>.</li>
</ul>
<p>These cookie protections address token theft through the browser. They do nothing about a cookie stolen from the endpoint itself, which our explainer on <a href="/posts/what-is-token-theft-and-why-mfa-doesnt-always-stop-it/">token theft and MFA</a> covers.</p>
<h2 id="request-size-and-resource-limits">Request size and resource limits</h2>
<p><code>post_max_size</code> (8M in both defaults), <code>upload_max_filesize</code> (2M), <code>max_input_vars</code> (1000), <code>memory_limit</code> (128M) and <code>max_execution_time</code> (30 seconds) are mainly availability controls. Set them to what your application needs, not higher, and enforce body size limits at the web server or proxy as well, because those apply before PHP parses anything.</p>
<p>These limits briefly became security-critical in October 2026. PHP&rsquo;s stopgap advice for <a href="/posts/php2shell-cve-2026-109618-cve-2026-109619-patch-and-hunt/">PHP2Shell (CVE-2026-109618 and CVE-2026-109619)</a> included a low <code>post_max_size</code>, <code>memory_limit=-1</code> set non-overridably and <code>session.upload_progress.enabled=0</code>. Those were emergency measures for unpatched servers and PHP said they &ldquo;do not make an installation safe&rdquo;. Once patched, keep a finite <code>memory_limit</code> unless you have a container or cgroup limit doing that job. Note that <code>session.upload_progress.enabled</code> is on by default and in the production template; if your application does not show upload progress bars, turning it off removes a feature you do not use.</p>
<h2 id="what-not-to-rely-on">What not to rely on</h2>
<p>Two directives look like sandboxes and are not.</p>
<p><strong><code>disable_functions</code></strong> removes named internal functions. With <code>system</code>, <code>exec</code> and <code>shell_exec</code> disabled, calling <code>system()</code> raises <code>Error: Call to undefined function system()</code>, which defeats the laziest webshells. But the PHP manual says plainly: &ldquo;This directive can be circumvented and should not be considered a sufficient security measure for shared hosting environments.&rdquo; It only covers internal functions you thought to list, and in PHP 8 a disabled function&rsquo;s name can be redefined by userland code.</p>
<p><strong><code>open_basedir</code></strong> limits which paths PHP&rsquo;s file functions can open, and the manual says symlinks are resolved so they cannot be used to escape it. It is still not an isolation boundary. The same manual notes that &ldquo;With dynamic loading, it&rsquo;s possible to ignore all open_basedir restrictions&rdquo;, which is one reason to keep <code>enable_dl</code> off, and the restriction only applies to PHP&rsquo;s own file access, not to anything a spawned process does. It also disables PHP&rsquo;s realpath cache, which has a performance cost.</p>
<p>If you host more than one application, or code you do not fully trust, isolate them at the operating system level: a separate PHP-FPM pool per application running as its own Unix user, with file permissions that stop one user reading another&rsquo;s files, or separate containers. That is the boundary; the two directives above are defence in depth inside it.</p>
<h2 id="audit-what-is-actually-running">Audit what is actually running</h2>
<p>The settings that matter are the ones the serving process loaded, which may not be the <code>php.ini</code> you edited. The CLI and FPM usually read different files (on Debian and Ubuntu, <code>/etc/php/&lt;version&gt;/cli/</code> and <code>/etc/php/&lt;version&gt;/fpm/</code>). This script prints the effective values against the baseline above. Run it under the SAPI you care about: from the command line for the CLI, or temporarily as a web request for FPM or mod_php (then delete it, since it reveals configuration).</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#f92672">&lt;?</span><span style="color:#a6e22e">php</span>
</span></span><span style="display:flex;"><span><span style="color:#75715e">// ini-audit.php: compare effective settings with a hardened baseline.
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>$baseline <span style="color:#f92672">=</span> [
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;expose_php&#39;</span>               <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;display_errors&#39;</span>           <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;display_startup_errors&#39;</span>   <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;log_errors&#39;</span>               <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;allow_url_include&#39;</span>        <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;allow_url_fopen&#39;</span>          <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,   <span style="color:#75715e">// only if the app never opens URLs with file functions
</span></span></span><span style="display:flex;"><span><span style="color:#75715e"></span>    <span style="color:#e6db74">&#39;enable_dl&#39;</span>                <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.use_strict_mode&#39;</span>  <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.use_only_cookies&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.cookie_httponly&#39;</span>  <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.cookie_secure&#39;</span>    <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.cookie_samesite&#39;</span>  <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;Lax&#39;</span>,
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;session.use_trans_sid&#39;</span>    <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>,
</span></span><span style="display:flex;"><span>];
</span></span><span style="display:flex;"><span>$norm <span style="color:#f92672">=</span> <span style="color:#a6e22e">fn</span>($v) <span style="color:#f92672">=&gt;</span> <span style="color:#a6e22e">match</span> (<span style="color:#a6e22e">strtolower</span>((<span style="color:#a6e22e">string</span>)$v)) {
</span></span><span style="display:flex;"><span>    <span style="color:#e6db74">&#39;on&#39;</span>, <span style="color:#e6db74">&#39;true&#39;</span>, <span style="color:#e6db74">&#39;yes&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;1&#39;</span>, <span style="color:#e6db74">&#39;off&#39;</span>, <span style="color:#e6db74">&#39;false&#39;</span>, <span style="color:#e6db74">&#39;no&#39;</span>, <span style="color:#e6db74">&#39;&#39;</span> <span style="color:#f92672">=&gt;</span> <span style="color:#e6db74">&#39;0&#39;</span>, <span style="color:#66d9ef">default</span> <span style="color:#f92672">=&gt;</span> (<span style="color:#a6e22e">string</span>)$v };
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$fail <span style="color:#f92672">=</span> <span style="color:#ae81ff">0</span>;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">foreach</span> ($baseline <span style="color:#66d9ef">as</span> $key <span style="color:#f92672">=&gt;</span> $want) {
</span></span><span style="display:flex;"><span>    $have <span style="color:#f92672">=</span> <span style="color:#a6e22e">ini_get</span>($key);
</span></span><span style="display:flex;"><span>    $ok <span style="color:#f92672">=</span> ($key <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;session.cookie_samesite&#39;</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">?</span> <span style="color:#a6e22e">in_array</span>($have, [<span style="color:#e6db74">&#39;Lax&#39;</span>, <span style="color:#e6db74">&#39;Strict&#39;</span>], <span style="color:#66d9ef">true</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#f92672">:</span> $norm($have) <span style="color:#f92672">===</span> $want;
</span></span><span style="display:flex;"><span>    $fail <span style="color:#f92672">+=</span> $ok <span style="color:#f92672">?</span> <span style="color:#ae81ff">0</span> <span style="color:#f92672">:</span> <span style="color:#ae81ff">1</span>;
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">printf</span>(<span style="color:#e6db74">&#34;%-26s %-6s want %-5s %s</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>, $key, <span style="color:#a6e22e">var_export</span>($have, <span style="color:#66d9ef">true</span>), $want, $ok <span style="color:#f92672">?</span> <span style="color:#e6db74">&#39;ok&#39;</span> <span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;CHECK&#39;</span>);
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">printf</span>(<span style="color:#e6db74">&#34;</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">%d setting(s) differ from the baseline (SAPI: %s, ini: %s)</span><span style="color:#ae81ff">\n</span><span style="color:#e6db74">&#34;</span>,
</span></span><span style="display:flex;"><span>    $fail, <span style="color:#a6e22e">PHP_SAPI</span>, <span style="color:#a6e22e">php_ini_loaded_file</span>() <span style="color:#f92672">?:</span> <span style="color:#e6db74">&#39;none&#39;</span>);
</span></span></code></pre></div><p>Run against PHP 8.4.27&rsquo;s <code>php.ini-production</code> with <code>php -c php.ini-production ini-audit.php</code> on PHP 8.3 CLI, it reported:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-console" data-lang="console"><span style="display:flex;"><span>expose_php                 &#39;1&#39;    want 0     CHECK
</span></span><span style="display:flex;"><span>display_errors             &#39;&#39;     want 0     ok
</span></span><span style="display:flex;"><span>display_startup_errors     &#39;&#39;     want 0     ok
</span></span><span style="display:flex;"><span>log_errors                 &#39;1&#39;    want 1     ok
</span></span><span style="display:flex;"><span>allow_url_include          &#39;&#39;     want 0     ok
</span></span><span style="display:flex;"><span>allow_url_fopen            &#39;1&#39;    want 0     CHECK
</span></span><span style="display:flex;"><span>enable_dl                  &#39;&#39;     want 0     ok
</span></span><span style="display:flex;"><span>session.use_strict_mode    &#39;0&#39;    want 1     CHECK
</span></span><span style="display:flex;"><span>session.use_only_cookies   &#39;1&#39;    want 1     ok
</span></span><span style="display:flex;"><span>session.cookie_httponly    &#39;&#39;     want 1     CHECK
</span></span><span style="display:flex;"><span>session.cookie_secure      &#39;0&#39;    want 1     CHECK
</span></span><span style="display:flex;"><span>session.cookie_samesite    &#39;&#39;     want Lax   CHECK
</span></span><span style="display:flex;"><span>session.use_trans_sid      &#39;0&#39;    want 0     ok
</span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010">
</span></span></span><span style="display:flex;"><span><span style="color:#960050;background-color:#1e0010"></span>6 setting(s) differ from the baseline (SAPI: cli, ini: php.ini-production)
</span></span></code></pre></div><p>With no <code>php.ini</code> at all (<code>php -n</code>), 10 of the 13 differed. With the hardened values applied, none did.</p>
<h2 id="limits-of-this-article">Limits of this article</h2>
<p>This covers PHP&rsquo;s own configuration, not your web server, framework or application code. We tested behaviour on PHP 8.3 CLI and read defaults from PHP 8.4.27&rsquo;s template; check the manual for your version, since defaults and deprecations change between releases. The PHP-FPM pool example was not run. The baseline is a starting point for a typical web application: turning off <code>allow_url_fopen</code> in particular can break code that depends on it, so test in staging first.</p>
<h2 id="sources-and-verification">Sources and verification</h2>
<table>
  <thead>
      <tr>
          <th>Important claim</th>
          <th>Source</th>
          <th>Status</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>php.ini-production values (expose_php On, use_strict_mode 0, cookie flags unset, display_errors Off, log_errors On) and comments quoted</td>
          <td><a href="https://github.com/php/php-src/blob/php-8.4.27/php.ini-production">php.ini-production at php-8.4.27</a></td>
          <td>Verified (and measured with the audit script)</td>
      </tr>
      <tr>
          <td>Built-in defaults</td>
          <td><code>php -n</code> on PHP 8.3.6 (Ubuntu 24.04 package), October 27, 2026</td>
          <td>Verified (tested)</td>
      </tr>
      <tr>
          <td>use_strict_mode is &ldquo;mandatory for general session security&rdquo;; cookie_httponly and cookie_secure behaviour</td>
          <td><a href="https://www.php.net/manual/en/session.configuration.php">PHP manual: session runtime configuration</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>disable_functions &ldquo;can be circumvented&rdquo;; PHP 8 redefinition; open_basedir symlink and dynamic-loading notes; realpath cache disabled</td>
          <td><a href="https://www.php.net/manual/en/ini.core.php">PHP manual: core php.ini directives</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>allow_url_include deprecated as of 7.4.0</td>
          <td><a href="https://www.php.net/manual/en/filesystem.configuration.php">PHP manual: filesystem runtime configuration</a></td>
          <td>Verified (and tested: deprecation notice)</td>
      </tr>
      <tr>
          <td>php_admin_value cannot be overridden with ini_set; security.limit_extensions default .php .phar</td>
          <td><a href="https://www.php.net/manual/en/install.fpm.configuration.php">PHP-FPM configuration</a></td>
          <td>Verified</td>
      </tr>
      <tr>
          <td>Strict mode rejects an attacker-chosen ID; disable_functions error; X-Powered-By header; ini_set refusing to loosen settings</td>
          <td>Our tests on PHP 8.3 CLI and built-in server</td>
          <td>Verified (tested)</td>
      </tr>
      <tr>
          <td>PHP2Shell stopgaps and &ldquo;do not make an installation safe&rdquo;</td>
          <td><a href="https://www.php.net/archive/2026.php">PHP security advisory, October 21, 2026</a></td>
          <td>Verified</td>
      </tr>
  </tbody>
</table>
]]></content:encoded></item></channel></rss>