CSRF Protection for Small PHP Applications - Require More Than a Session Cookie
A settings form can require a valid login and still accept a request the user never meant to send. The session cookie tells the server which browser session is making the request, but it does not necessarily tell the server which page caused that browser to submit it. How can a small PHP application tell its own form apart from a forged request?
This is the problem behind Cross-Site Request Forgery, usually shortened to CSRF. It matters most when a browser automatically carries credentials, such as a PHP session cookie, and an endpoint changes something: an email address, a password, a post, a permission, or a billing setting. The goal here is narrower than “solve web security.” It is to build one explicit proof into state-changing requests, then surround it with controls that fail safely.
The cookie authenticates a session, not the request's origin
Imagine that a user is signed in to account.example. In another tab, a page controlled by someone else submits a form to https://account.example/profile/email. Browsers have long allowed HTML forms to submit across origins. Depending on the cookie's attributes and the request context, the browser may attach the user's session cookie. If the receiving endpoint checks only that cookie and predictable form fields, it can mistake the forged request for an intended one.
MDN describes three conditions in the classic case: the request changes server state, cookies are the only evidence used to identify the user, and the remaining request parameters are predictable. The attacker does not need to read the response for the unwanted action to matter.
This distinction is easy to miss because authentication did work. The application correctly recognized the session and then answered the wrong question. “Who is signed in?” is not the same as “Did this request come through a flow that our application issued?”
First, inventory every state-changing route
A token helper protects nothing if one forgotten endpoint bypasses it. Start with a list of routes that create, update, delete, publish, upload, invite, log in, log out, or trigger another side effect. Include old handlers, bulk actions, and JavaScript endpoints, not only visible forms.
State changes should not hide behind GET. RFC 9110 defines GET, HEAD, OPTIONS, and TRACE as safe methods, meaning their defined semantics are essentially read-only. A URL such as /post/delete?id=42 is not repaired by calling it an action link. Crawlers, previews, prefetchers, and cross-site elements can follow URLs. Use an unsafe method such as POST for a change, then validate CSRF protection on the server.
POST is not itself a defense. A hostile page can submit an ordinary cross-origin HTML form using POST. The method expresses the nature of the operation; another mechanism must establish that the request belongs to a legitimate application flow.
Use a synchronizer token for a session-based form
For a stateful application, the OWASP CSRF Prevention Cheat Sheet recommends the synchronizer token pattern. The server creates a secret unpredictable value, stores it in the user's session, and places a copy in the legitimate form. On submission, the application requires both values to match.
The session cookie travels according to browser cookie rules. The hidden token travels because the application deliberately put it in the page. A different site can attempt to submit the same field name, but the same-origin policy normally prevents it from reading the legitimate page to learn the value. The token therefore adds evidence that the request passed through content generated for this session.
Generate and render the token
The following native-PHP example uses one token for the session. It assumes secure session configuration and session_start() have already run in a shared bootstrap.
function csrfToken(): string
{
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
$token = htmlspecialchars(
csrfToken(),
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
The PHP manual documents random_bytes() as a source of cryptographically secure random bytes. Raw bytes can contain characters unsuitable for direct display, so bin2hex() turns them into a transport-friendly string. Thirty-two bytes is a reasonable example, not a rule that every application standard has mandated.
Render the encoded value inside every protected form:
<form method="post" action="/profile/email">
<input type="hidden" name="csrf_token" value="<?= $token ?>">
<input type="email" name="email" required>
<button type="submit">Update email</button>
</form>
Output encoding remains appropriate even though this value was generated by the server. It keeps the HTML boundary explicit and prevents a future change in token representation from quietly creating a markup problem.
Reject before performing the action
Validation belongs before database writes, email sends, or any other side effect:
function requireValidCsrfToken(): void
{
$known = $_SESSION['csrf_token'] ?? null;
$submitted = $_POST['csrf_token'] ?? null;
if (!is_string($known)
|| !is_string($submitted)
|| !hash_equals($known, $submitted)
) {
http_response_code(403);
exit('Invalid request token');
}
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Allow: POST');
http_response_code(405);
exit('Method not allowed');
}
requireValidCsrfToken();
// Authorize the user, validate the new email, then update it.
The type checks make absence fail closed and prevent unexpected array input from reaching a string function. The PHP manual describes hash_equals() as timing-safe and requires the known server value as its first argument and the user-supplied value as its second. Do not log either token when validation fails.
A valid token is still not authorization. The handler must separately verify that this user may edit this profile and that the submitted data is acceptable. CSRF validation answers one request-integrity question; it should not become a shortcut around access control.
Choose a token lifecycle deliberately
OWASP allows a token per request or per session. Per-request rotation narrows reuse but can break back-button flows and multiple open tabs. A per-session token is simpler for a small application and remains useful when it is secret, unpredictable, consistently checked, and replaced with the session's trust boundary.
Create a fresh token after login or another major session transition, especially if an anonymous session already existed. Remove it when the session is destroyed. If a user can hold several independent login sessions, each session should have its own value. These choices make the token follow the authenticated session rather than a permanent user record.
Do not put a synchronizer token in a URL. URLs can appear in browser history, access logs, analytics, copied messages, and referrer information. Use a hidden form field, a JSON body, or a custom request header as appropriate. A hidden field is not expected to be invisible to the user; its security property is that an unrelated origin cannot normally read the protected page.
Pages containing tokens also need a considered cache policy. A shared cache must not serve one user's token-bearing form to another user. That is part of protecting authenticated pages generally, but CSRF tokens make the consequence especially obvious.
Add cookie and request-context defenses without trusting one flag
A session cookie should normally use Secure, HttpOnly, a narrow host/path scope, and an intentional SameSite value. MDN calls SameSite=Lax or Strict a partial CSRF defense. Strict blocks more cross-site cookie attachment but can disrupt legitimate arrival from another site; Lax preserves more navigation behavior and is weaker.
Same-site is also broader than same-origin. Sibling subdomains can be same-site even though they are different origins, so a design that treats every subdomain as equally trusted should say so explicitly. Keep SameSite as defense in depth rather than deleting token checks because a cookie has one promising attribute.
Modern browsers can send Fetch Metadata headers. In particular, Sec-Fetch-Site describes a request as same-origin, same-site, cross-site, or none. A server can reject an unsafe request marked cross-site before it reaches business logic. However, the cited W3C Fetch Metadata document is a Working Draft, and real applications may encounter older clients, intermediaries, intentional cross-origin flows, or missing headers. Roll out such a policy with observation, explicit exceptions, and a fallback rather than assuming the header is always present.
Forms, JSON APIs, and webhooks do not share one rule
For JavaScript clients, a token can travel in a custom header such as X-CSRF-Token. Custom headers and non-simple content types cause browsers to apply same-origin and CORS checks, which can be useful. But permissive credentialed CORS can reopen the boundary, so “it uses JSON” is not a complete security argument.
Not every POST endpoint should use a browser-session CSRF token. A webhook comes from another server and needs its own authentication, often a provider-defined signature. A public API using an explicit bearer credential that browsers do not attach automatically has a different threat model. Conversely, an API that authenticates with cookies still needs a CSRF design even if its response is JSON. Choose the control from how credentials travel, not from the file extension or framework label.
Test the rejected paths, not only the happy path
A safe test plan can verify behavior without targeting anyone else's system:
- Submit a protected form with its valid session token and confirm the intended action succeeds.
- Remove the token, replace it with a random value, and submit an array instead of a string; each request should fail before any side effect.
- Send GET to every state-changing route and confirm it returns a method error rather than changing data.
- Sign out and replay a token from the ended test session; it should no longer be accepted.
- Open two forms in separate tabs and verify that the chosen rotation policy behaves as documented.
- Inspect response cookies and confirm their Secure, HttpOnly, SameSite, host, and path settings match the design.
- If Fetch Metadata filtering is enabled, observe legitimate clients first and test every documented cross-origin exception.
Log the route, outcome, and a non-sensitive reason such as “missing token” or “mismatch,” but not the token itself. A rejection may be an attack, an expired page, a stale tab, or an implementation bug. Logs support investigation; they do not prove intent on their own.
Know what CSRF protection does not fix
Cross-site scripting can undermine CSRF defenses because script running in the trusted origin may read a token or issue requests through the application's own context. Token validation therefore does not replace output encoding, a sound Content Security Policy, or other XSS controls. It also does not repair broken authorization, a stolen session, unsafe password recovery, or an operation that should require fresh authentication.
For a particularly consequential action, requiring the current password, a WebAuthn assertion, or another explicit user interaction may be appropriate. The correct threshold depends on the harm the action can cause. A blog preference and deletion of an account do not deserve identical friction.
Require two independent pieces of evidence
A small PHP application does not need to pretend that one token makes every request trustworthy. It needs a coherent boundary: unsafe actions do not use GET, session-authenticated forms carry an unpredictable server-side token, handlers reject missing or mismatched values before side effects, and authorization still runs afterward.
Cookie restrictions and Fetch Metadata can make that boundary stronger, but their limitations should remain visible. The useful question is not “Have we enabled CSRF protection?” It is “Can every route that changes state explain which independent evidence it requires, and have we tested the request when that evidence is absent?”
