skip to content

Secure Coding

The PHP functions and habits that keep an application safe: filtering input, escaping output, avoiding dangerous calls, hashing passwords and checking CSRF tokens. Interviewers test the exact API.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

26

In a plain PHP app, how do you protect a bank's change-email form with a CSRF token, from generating it to checking it on POST?

level: juniorimportance: must knowfreq 50%

answer

  1. random token stored server-side in $_SESSION
  2. echo it in a hidden form field
  3. compare submitted vs stored on POST
  4. use hash_equals, not ==
  5. reject when missing or mismatched

basics

~20 s

Generate an unpredictable token (bin2hex(random_bytes(32))), store it in $_SESSION, and echo it in a hidden field of the change-email form. On POST, compare the submitted field against the session copy with hash_equals(); if it is missing or does not match, reject the request.

solid answer

~40 s

The synchronizer-token flow has four PHP steps. **Generate** an unpredictable value — `bin2hex(random_bytes(32))` — once per session. **Store** it server-side in `$_SESSION['csrf']`. **Emit** it in the change-email form as a hidden field. **Verify** on the POST handler *before* changing anything: `if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) { http_response_code(400); exit; }`. It works because the token lives in the server-side session and is echoed only into pages your app renders; a hostile page can send the user's session cookie but **cannot read or guess the token**, so its forged POST lacks the field and is rejected. Compare with **`hash_equals`**, not `==`/`===`. The *reason* forged cross-site requests arrive authenticated, and synchronizer-token theory, are owned by the CSRF protocol topics.

go deeper

for a junior

Recall the four steps: generate, store in $_SESSION, echo in a hidden field, and compare on POST with hash_equals.

for a middle

Explain why the server-side session copy is the trustworthy one and why the attacker can replay the cookie but not the token.

for a senior

Design where the check sits (before side effects), handle missing fields, and choose hidden field vs header per client.

for a principal

Set the app-wide token policy and reason about its interaction with SameSite, Origin checks, and re-authentication on sensitive actions.

## The four steps in PHP A CSRF (cross-site request forgery) token is a secret the server plants in its own pages and requires back on any state-changing request. For a change-email form on a bank portal: 1. **Generate** — a per-session unpredictable value from the CSPRNG: ```php if (empty($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(32)); // 64 hex chars } ``` 2. **Store** — it is already stored: `$_SESSION` is server-side, so the browser never sees the raw session contents, only the session *cookie*. 3. **Emit** — render it into the form the user legitimately loads: ```php echo '<input type="hidden" name="csrf" value="' . htmlspecialchars($_SESSION['csrf'], ENT_QUOTES) . '">'; ``` 4. **Verify** — on the POST that changes the email, before any side effect: ```php if (!hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) { http_response_code(400); exit; } ``` ## Why this stops the forgery A change-email endpoint that trusts only the session cookie is forgeable: a hostile page can make the victim's browser POST to it, and the browser attaches the bank's session cookie automatically. The token breaks that because: - it is **stored in the server-side session** and only **echoed into pages the bank itself renders**; - the same-origin policy stops the attacker's page from **reading** the bank's pages, so it cannot learn the token; - the forged POST therefore arrives **without the matching `csrf` field**, and step 4 rejects it. The attacker can replay the cookie but not the token — that asymmetry is the whole point. ## The pieces that are easy to get wrong | Mistake | Consequence | |---|---| | Comparing with `==` or `===` | timing side channel, and `==` can be bypassed by type juggling | | Checking the token *after* updating the email | the side effect already happened | | Storing the token in a hidden field only (no session copy) | nothing server-side to compare against — the attacker supplies both halves | | Putting the token in a GET query string | it leaks via `Referer`, logs, and history | | Forgetting `?? ''` on a missing field | a warning or a type error instead of a clean rejection | ## What belongs to PHP here, and what does not The PHP-specific content is: store in `$_SESSION`, emit in a hidden field, read from `$_POST`, and compare with `hash_equals`. The *concepts* — why the browser sends the cookie, what a synchronizer token proves, how SameSite and Origin checks compare — are owned by the CSRF protocol topics; the CSPRNG behind `random_bytes` is owned by the password/random leaf. An interview answer here is judged on wiring the four steps correctly and using `hash_equals`, not on re-deriving the attack.

  • Where must the token be stored for the check to mean anything?
    Server-side, in `$_SESSION`. The value echoed in the hidden field is only the copy the browser sends back; the trustworthy copy is the session one, which the attacker cannot read. If you keep the token *only* in the form and not in the session, there is nothing independent to compare against and the check is meaningless.
  • Should the token go in the URL query string instead of a hidden POST field?
    No. A token in a GET query string leaks through the `Referer` header, browser history, server logs, and shared links. Keep it in a hidden field of a POST form (or a request header for AJAX), and reserve GET for safe, non-state-changing requests that need no token.
  • Do you need a new token for every single request?
    Not necessarily. A single per-session token in `$_SESSION`, reused across the session, is a valid and common design and works cleanly with multiple tabs and the back button. Per-request rotation is stronger against some attacks but has trade-offs; that design question is owned by the CSRF synchronizer-token topic.

It is like a callback password a bank leaves on your account: they say it to you only on a line you initiated, and require you to repeat it before changing anything. A stranger phoning in your name has your account number (the cookie) but not the password (the token), so the change is refused.

saying these in an interview costs you the question

  • storing the token only in a hidden field is enough
  • comparing the token with == or === is fine
  • check the token after performing the state change
  • put the CSRF token in the URL query string
  • a GET request that changes data needs no token
  • the attacker's page can read the token from the bank's page
open as a page

In PHP, how do you safely echo a user's review into an HTML page with htmlspecialchars(), and which flags and charset should you pass?

level: juniorimportance: must knowfreq 78%

basics

~20 s

Wrap every untrusted value in htmlspecialchars() at the moment it is echoed. Pass ENT_QUOTES | ENT_SUBSTITUTE and 'UTF-8', the PHP 8.1+ defaults, so both quote types are encoded and malformed UTF-8 cannot turn the output into an empty string.

open as a page

In PHP, how do you validate an age field with filter_var() and FILTER_VALIDATE_INT, and how do you detect a failed check correctly?

level: juniorimportance: must knowfreq 66%

basics

~10 s

Call filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 16, 'max_range' => 99]]). It returns an int on success and false on failure, so test with === false, because a valid 0 is falsy.

open as a page

In PHP, what do password_hash() and password_verify() do, and what exactly should you store in the database?

level: juniorimportance: must knowfreq 62%

basics

~20 s

password_hash($plain, PASSWORD_DEFAULT) returns a self-describing string that embeds the algorithm, cost and a random salt; store that one string. password_verify($plain, $hash) rehashes the input the same way and returns a bool. Never generate the salt yourself or compare hashes manually.

open as a page

In PHP, which built-in calls turn attacker-controlled request data into executed code or instantiated objects, and why are they dangerous?

level: juniorimportance: must knowfreq 58%

basics

~10 s

eval() runs a string as PHP; include/require execute a file you supply; unserialize() rebuilds objects and calls their magic methods; extract() writes request keys into variables. Never feed request data to any of them.

open as a page

In PHP, why compare a CSRF token with hash_equals() instead of ===, and which argument goes first?

level: middleimportance: must knowfreq 45%

basics

~20 s

=== returns as soon as it finds a differing byte, so its run time leaks how much of the secret matched — a timing side channel. hash_equals($known, $user) compares in constant time for equal-length strings. Pass the stored token first and the user-supplied value second.

open as a page

In PHP's filter extension, what separates FILTER_VALIDATE_* from FILTER_SANITIZE_* filters, and why is sanitizing input no substitute for escaping output?

level: middleimportance: must knowfreq 55%

basics

~20 s

Validate filters decide whether a value is acceptable and return it typed or signal failure; sanitize filters strip or encode characters and always return a string. Sanitizing cannot know the output context, so escaping still happens at use.

open as a page

In PHP, which functions generate a secure token, and why are rand() and mt_rand() the wrong choice for one?

level: middleimportance: must knowfreq 48%

basics

~20 s

Use random_bytes($len) for a token (then bin2hex/base64 to make it printable) and random_int($min, $max) for a secure number; both draw from the OS CSPRNG and throw Random\RandomException on failure. rand() and mt_rand() are fast but predictable, so an attacker can reproduce their output.

open as a page

A legacy PHP app unserializes a 'remember me' cookie on every request; why is that a remote-code-execution risk, and does unserialize's allowed_classes option fix it?

level: seniorimportance: must knowfreq 52%

basics

~20 s

unserialize() rebuilds whatever object graph the cookie bytes describe and calls magic methods like __wakeup and __destruct, so attacker-chosen objects run code (a gadget chain). allowed_classes narrows which classes are built but PHP still warns against untrusted input; replace the cookie with signed JSON.

open as a page

In PHP, what is the difference between htmlspecialchars() and htmlentities(), and when, if ever, do you need htmlentities()?

level: juniorimportance: should knowfreq 50%

basics

~20 s

htmlspecialchars() converts only &, <, >, and the quotes; htmlentities() also converts every character that has a named HTML entity, such as é into é. Both protect markup equally, so on a UTF-8 page htmlspecialchars() is the standard choice.

open as a page

In PHP, how do you read a CSRF token that a fetch/AJAX call sends in an X-CSRF-Token header, and why is a header used?

level: middleimportance: should knowfreq 33%

basics

~20 s

PHP exposes a request header as $SERVER['HTTP' + uppercased name with hyphens as underscores], so X-CSRF-Token becomes $_SERVER['HTTP_X_CSRF_TOKEN']. Read it, compare with hash_equals against the $_SESSION copy. A header suits fetch/JSON calls that carry no form field.

open as a page

In PHP templates, why must an attribute value escaped with htmlspecialchars() still be quoted, and which attributes can escaping not make safe?

level: middleimportance: should knowfreq 48%

basics

~20 s

htmlspecialchars() encodes quotes and angle brackets but not spaces or equals signs, so an unquoted attribute value can still be split into new attributes. Always quote the value; and for event handlers, style or href, escaping alone is not enough.

open as a page

Why is PHP's strip_tags() not a safe way to clean user reviews for display, and what goes wrong with its allowed_tags argument?

level: middleimportance: should knowfreq 40%

basics

~20 s

strip_tags() removes things that look like tags but does not escape what remains, keeps every attribute on allowed tags, and can delete ordinary text after a stray <. The PHP manual says not to use it against XSS.

open as a page

In PHP, when do you reach for ctype_digit() or an allow-list instead of filter_var(), and what traps do the ctype_* functions hide?

level: middleimportance: should knowfreq 38%

basics

~20 s

Use ctype_digit() for digit-only strings where leading zeros matter, such as postcodes, and an allow-list for fixed sets of values. ctype functions take strings: an int argument is deprecated and misread as a character code.

open as a page

In PHP, how do FILTER_NULL_ON_FAILURE and PHP 8.5's FILTER_THROW_ON_FAILURE change what a failed filter_var() check returns?

level: middleimportance: should knowfreq 40%

basics

~10 s

By default a failed filter returns false. FILTER_NULL_ON_FAILURE returns null instead, which matters when false is a valid result; PHP 8.5's FILTER_THROW_ON_FAILURE throws Filter\FilterFailedException. The two flags cannot be combined.

open as a page

In PHP, what do FILTER_VALIDATE_EMAIL and FILTER_VALIDATE_URL actually check on a sign-up form, and what do they still let through?

level: middleimportance: should knowfreq 45%

basics

~10 s

Both check syntax only. FILTER_VALIDATE_EMAIL confirms an address looks well-formed, not that it exists. FILTER_VALIDATE_URL requires a scheme but accepts any scheme, including file: and javascript: forms, so allow-list http and https yourself.

open as a page

In PHP, what is bcrypt's 72-byte limit in password_hash(), and how does it bite long or multibyte passwords?

level: middleimportance: should knowfreq 30%

basics

~20 s

With PASSWORD_BCRYPT, password_hash() silently truncates the password to 72 bytes, so anything past byte 72 is ignored. Because it counts bytes, multibyte UTF-8 characters use several each. Two long passwords sharing a 72-byte prefix verify as equal. ARGON2ID has no such limit.

open as a page

In PHP, would you keep one CSRF token per session in $_SESSION or one per form, and why regenerate the token after login?

level: seniorimportance: should knowfreq 32%

basics

~20 s

A single $_SESSION['csrf'] reused for the session is simplest and multi-tab friendly; per-form tokens are a $_SESSION map keyed by form and unset after use, but break the back button. Regenerate the token value at login, since one issued to a pre-auth session may be attacker-known.

open as a page

In PHP, how do you pass data such as a list of reviews into an inline <script> block safely, and why do the JSON_HEX_* flags matter there?

level: seniorimportance: should knowfreq 30%

basics

~10 s

Encode the data with json_encode() plus JSON_HEX_TAG, JSON_HEX_AMP, JSON_HEX_APOS and JSON_HEX_QUOT, and JSON_THROW_ON_ERROR. htmlspecialchars() is wrong inside a script, and a raw </script> in the data would end the block early.

open as a page

When validating a whole PHP form with filter_input_array() or filter_var_array() and a definition array, what does the result contain, and which pitfalls must you handle?

level: seniorimportance: should knowfreq 32%

basics

~20 s

The result holds only the defined keys: a typed value if valid, false if invalid, null if missing. Pitfalls: null-on-failure blurs invalid and missing, arrays fail scalar filters, and filter_input_array() reads the original request input, not $_POST.

open as a page

You inherit a forum whose users table stores raw MD5 password hashes; how do you migrate it to password_hash() without resetting everyone's password?

level: seniorimportance: should knowfreq 38%

basics

~20 s

You cannot batch-convert MD5 hashes because you lack the plaintext. Migrate on login: detect a legacy row, verify with the old scheme, then rehash the plaintext with password_hash and store it. Optionally wrap every existing MD5 in password_hash immediately so all rows get a slow layer at once.

open as a page

In PHP, why should a login flow call password_needs_rehash(), and what does the PHP 8.4 bcrypt cost change mean for existing hashes?

level: seniorimportance: should knowfreq 42%

basics

~20 s

You can only upgrade a hash when you hold the plaintext, which is at login. After a successful password_verify, call password_needs_rehash($hash, PASSWORD_DEFAULT); if it returns true, rehash the plaintext and store it. PHP 8.4 raised bcrypt's default cost from 10 to 12, so pre-8.4 hashes need a rehash.

open as a page

In PHP, why does building an include/require path from request input risk code execution, and what do allow_url_include and basename() each actually stop?

level: seniorimportance: should knowfreq 40%

basics

~20 s

include/require runs the file at the path you give, so request input selects the executed file: a planted local file runs as PHP, or a remote URL runs if allow_url_include is on. allow_url_include (default Off) blocks only the URL case. Use an allow-list, not basename().

open as a page

In PHP, how can comparing a hash with == (or in_array without strict) let an attacker bypass a check, and what is the fix?

level: seniorimportance: should knowfreq 44%

basics

~20 s

Some md5/sha1 outputs are '0e' followed by digits; under == PHP treats two such hashes as the number 0, so different inputs compare equal — a 'magic hash' bypass PHP 8.0 did not change. Compare with hash_equals(), or === and in_array(..., true).

open as a page

In PHP, why is extract($_GET) or a variable variable built from request keys dangerous, and how should you read request values instead?

level: middleimportance: nice to knowfreq 26%

basics

~20 s

extract($_GET) creates a local variable for every request key, so a request can define or overwrite variables your logic trusts (like $isAdmin). The default EXTR_OVERWRITE clobbers existing ones; variable variables ($$key) share the flaw. Read expected keys by name instead.

open as a page