111. How do you protect a PHP form from cross-site request forgery?
Describe unpredictable per-session or per-request tokens, server-side validation, SameSite cookies, origin checks, token rotation, and why GET requests must not change state.
Generate an unpredictable CSRF token, store the expected value in the user's server-side session, include it in each state-changing form, and validate it with hash_equals(). Reject failures, use secure SameSite cookies and optional origin checks, rotate tokens when appropriate, and never change state through GET.
See the Code while reading this explanation.
This question asks how to stop a harmful website from making a signed-in person unknowingly change information or perform an action. The main protection is to place a secret, hard-to-guess value in the real form and confirm it before accepting the change. Sign-in information alone is not enough because the person's browser may send it automatically. The application should reject unexpected submissions, avoid changing data through ordinary links, use extra browser protections, record failures without exposing secrets, and test that a fake submission cannot succeed.
Useful clarifying questions:
- Is this a server-rendered form, a cookie-authenticated API, or both?
- Should tokens remain valid for a session or be single-use?
- Does the application intentionally accept requests from other origins?
Cross-site request forgery, or CSRF, exploits the fact that a browser automatically attaches cookies to requests. Authentication identifies the signed-in session, but it does not prove that the user intended the action. Authorization must still confirm that the authenticated user is allowed to perform the requested operation.
For every cookie-authenticated request that changes state, normally POST, PUT, PATCH, or DELETE, I generate a cryptographically unpredictable token with random_bytes(). I store the expected value in the user's server-side session and place a copy in the legitimate form as a hidden field. For JavaScript requests, the client can send the token in a custom request header instead. On submission, the server requires the expected HTTP method, confirms that both tokens are non-empty strings, and compares them with hash_equals(), passing the trusted server-side value first. If validation fails, the application performs no state change and returns a generic 403 response.
A per-session token is simple and effective for many PHP applications. A per-request or single-use token reduces replay opportunities but requires more state and may break multiple tabs, retries, browser navigation, or forms that remain open for a long time. A practical design can rotate the token after authentication changes or sensitive operations. When strict single-use behavior is required, the server can retain a small bounded set of valid recently issued tokens and remove each token after successful use.
The session cookie should use Secure so it is sent only over HTTPS, HttpOnly so client-side scripts cannot read it, and an appropriate SameSite policy. SameSite=Lax is a practical default for many applications. SameSite=Strict provides stronger cross-site restrictions but can disrupt legitimate navigation or sign-in flows. SameSite=None is needed for some intentional cross-site uses and must be combined with Secure. SameSite is defense in depth and does not replace token validation.
An Origin check can provide another layer for state-changing requests. The server should compare the complete normalized scheme, host, and effective port against an exact allowlist. If Origin is absent, a carefully parsed Referer header may be checked as a fallback. The application must define how to handle requests with neither header because privacy tools, proxies, and some clients may omit them. The CSRF token remains the primary control.
GET and HEAD must remain safe and read-only. They must not create, update, delete, approve, purchase, log out, or otherwise change server-side state. This prevents links, images, crawlers, browser prefetching, and similar behavior from triggering an action. The server must enforce the method rule rather than relying on the user interface.
CSRF protection is mainly required when browsers automatically attach authentication credentials, especially cookies. An API that uses an Authorization header manually added by trusted client code is generally not exposed to classic form-based CSRF in the same way, although cross-origin policy, token storage, authorization, and other threats still require separate review.
A valid CSRF token does not replace input validation or authorization. After the CSRF check succeeds, the application must still validate submitted values and confirm that the signed-in user may perform the exact operation. Output encoding, parameterized queries, session security, and other controls address different threats and are not substitutes for CSRF protection.
Failures must be safe. The application should stop before any write occurs, return a generic error, and log only limited metadata such as the route, time, request identifier, failure category, and an internal user identifier when appropriate. It must not log the submitted token, expected token, session identifier, cookie values, passwords, or other secrets. Repeated failures can be monitored, while recognizing that expired forms, session expiry, and token rotation can also cause legitimate failures.
I would verify the protection with automated integration tests. The tests should cover a valid token, a missing token, a modified token, a token copied from another session, a stale or already-used token, an incorrect HTTP method, an untrusted Origin, an invalid Referer fallback, requests with neither origin header under the documented policy, multiple open tabs, expired sessions, and every state-changing route. I would also confirm that a request containing only the user's cookie cannot change state.
- Require HTTPS and configure PHP sessions to use cookies only, strict session handling, Secure, HttpOnly, and an appropriate SameSite value.
- Generate a CSRF token with random_bytes() and store the expected value in server-side session state.
- Include the token in every state-changing HTML form as a hidden field, or in a custom header for JavaScript requests.
- Require POST, PUT, PATCH, or DELETE for state changes and reject unsupported methods.
- Read the submitted token and verify that both values are non-empty strings.
- Compare the submitted token with the trusted server-side token using hash_equals().
- Optionally validate Origin or a parsed Referer against an exact trusted-origin allowlist.
- On failure, perform no write, return HTTP 403, and log only non-secret metadata.
- After successful CSRF validation, perform normal input validation and authorization before changing data.
- Rotate tokens after authentication changes or sensitive operations according to the chosen usability policy.
- Test every state-changing route with valid, missing, invalid, cross-session, stale, replayed, wrong-method, and cross-origin cases.
<?php
declare(strict_types=1);
const TRUSTED_ORIGINS = ['https://example.com'];
if (PHP_SAPI === 'cli') {
fwrite(STDERR, "Run this example through a web server.\n");
exit(1);
}
$isHttps = isset($_SERVER['HTTPS'])
&& $_SERVER['HTTPS'] !== ''
&& $_SERVER['HTTPS'] !== 'off';
if (!$isHttps) {
http_response_code(500);
header('Content-Type: text/plain; charset=UTF-8');
echo 'HTTPS is required.';
exit;
}
ini_set('session.use_only_cookies', '1');
ini_set('session.use_strict_mode', '1');
ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', '1');
ini_set('session.cookie_samesite', 'Lax');
session_start();
function failRequest(int $status, string $publicMessage, string $reason): never
{
try {
$requestId = bin2hex(random_bytes(8));
} catch (Throwable) {
$requestId = 'unavailable';
}
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
error_log(sprintf(
'Request rejected: reason=%s request_id=%s path=%s',
$reason,
$requestId,
is_string($path) ? $path : '/'
));
http_response_code($status);
header('Content-Type: text/plain; charset=UTF-8');
echo $publicMessage;
exit;
}
function normalizedOriginFromUrl(string $url): ?string
{
$parts = parse_url($url);
if (!is_array($parts) || !isset($parts['scheme'], $parts['host'])) {
return null;
}
$scheme = strtolower((string) $parts['scheme']);
$host = strtolower((string) $parts['host']);
if ($scheme !== 'https' && $scheme !== 'http') {
return null;
}
$port = isset($parts['port']) ? (int) $parts['port'] : null;
$defaultPort = $scheme === 'https' ? 443 : 80;
$portSuffix = $port !== null && $port !== $defaultPort ? ':' . $port : '';
return $scheme . '://' . $host . $portSuffix;
}
function hasAllowedRequestOrigin(array $trustedOrigins): bool
{
$originHeader = $_SERVER['HTTP_ORIGIN'] ?? '';
if (is_string($originHeader) && $originHeader !== '') {
$origin = normalizedOriginFromUrl($originHeader);
return $origin !== null && in_array($origin, $trustedOrigins, true);
}
$refererHeader = $_SERVER['HTTP_REFERER'] ?? '';
if (is_string($refererHeader) && $refererHeader !== '') {
$origin = normalizedOriginFromUrl($refererHeader);
return $origin !== null && in_array($origin, $trustedOrigins, true);
}
// This example allows missing headers because the CSRF token is primary.
// A stricter application may reject them under a documented policy.
return true;
}
if (!isset($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
try {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} catch (Throwable) {
failRequest(500, 'The request could not be completed.', 'token_generation_failed');
}
}
$method = $_SERVER['REQUEST_METHOD'] ?? '';
if ($method === 'POST') {
if (!hasAllowedRequestOrigin(TRUSTED_ORIGINS)) {
failRequest(403, 'The request could not be completed.', 'untrusted_origin');
}
$expectedToken = $_SESSION['csrf_token'] ?? '';
$submittedToken = $_POST['csrf_token'] ?? '';
if (!is_string($expectedToken) || !is_string($submittedToken)) {
failRequest(403, 'The request could not be completed.', 'invalid_token_type');
}
if ($expectedToken === '' || $submittedToken === '') {
failRequest(403, 'The request could not be completed.', 'missing_token');
}
if (!hash_equals($expectedToken, $submittedToken)) {
failRequest(403, 'The request could not be completed.', 'token_mismatch');
}
$displayName = $_POST['display_name'] ?? '';
if (!is_string($displayName) || trim($displayName) === '') {
failRequest(422, 'A valid display name is required.', 'invalid_input');
}
// Confirm that the authenticated user may perform this exact operation.
// Persist the validated change only after authorization succeeds.
try {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} catch (Throwable) {
failRequest(500, 'The request could not be completed.', 'token_rotation_failed');
}
header('Content-Type: text/plain; charset=UTF-8');
echo 'Form submitted successfully.';
exit;
}
if ($method !== 'GET') {
header('Allow: GET, POST');
failRequest(405, 'Method not allowed.', 'unsupported_method');
}
$escapedToken = htmlspecialchars(
$_SESSION['csrf_token'],
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Secure PHP Form</title>
</head>
<body>
<form method="post" action="">
<input type="hidden" name="csrf_token" value="<?= $escapedToken ?>">
<label>
Display name
<input type="text" name="display_name" required maxlength="100">
</label>
<button type="submit">Save</button>
</form>
</body>
</html>The interviewer wants to verify that the candidate understands how a malicious site can cause a signed-in browser to send an unwanted request. A strong answer distinguishes authentication from user intent, uses server-validated unpredictable tokens, treats SameSite cookies and origin checks as additional layers, covers every state-changing endpoint, fails safely, and explains verification and token-lifecycle tradeoffs.
Common mistakes include treating authentication as proof of user intent; relying only on SameSite, CAPTCHA, JavaScript, a custom field name, or a predictable hidden value; validating a token without a trusted server-side expected value; storing both token copies only in client-controlled data; using GET for state changes; protecting the visible form but not its processing endpoint; omitting alternate state-changing routes; using substring matching for origins; accepting another session's token; rotating tokens without considering multiple tabs; performing writes before validation finishes; forgetting authorization after CSRF validation; and logging tokens, cookies, or session identifiers.
Explain the threat first: browsers send cookies automatically, so authentication does not prove intent. Then describe the server-side token flow, safe rejection, SameSite and origin checks as extra layers, the rule that GET must not change state, the token-rotation tradeoff, and the tests you would run.






