PHP Security
Web security in PHP — SQL injection and prepared statements, XSS and output escaping, CSRF tokens, password hashing, session security and fixation, input validation versus output encoding, secrets management, and rate limiting.
12 questions
JuniorTheoryVery commonWhy store passwords with password_hash() rather than MD5 or SHA-1 plus a salt?
Why store passwords with password_hash() rather than MD5 or SHA-1 plus a salt?
MD5 and SHA-1 are built to be fast, so an attacker tries billions of guesses a second — a salt only defeats precomputed rainbow tables, it does not slow brute force down. password_hash() uses bcrypt or argon2: a random per-hash salt plus a tunable cost factor make every guess deliberately expensive. password_verify() reads the algorithm, salt and cost back out of the stored string.
Common mistakes
- ✗Thinking a salt alone fixes MD5 — it stops rainbow tables, not fast brute force
- ✗Storing the salt or the cost in a separate column when the hash string already carries them
- ✗Comparing two hashes with
===instead of callingpassword_verify()
Follow-up questions
- →What does
password_needs_rehash()let you do when you raise the cost factor? - →Why should a login for an unknown user take as long as one with a wrong password?
JuniorTheoryVery commonWhat is SQL injection, and what exactly makes a prepared statement immune to it?
What is SQL injection, and what exactly makes a prepared statement immune to it?
Injection happens when user input is concatenated into the SQL string, so it becomes syntax the parser executes. A prepared statement sends the query to the server first — it is parsed and planned before any value arrives — and the bound parameters travel separately, as data. A parameter therefore can never turn into SQL.
Common mistakes
- ✗Saying a prepared statement escapes the input, rather than parsing the query before binding
- ✗Believing
addslashes()or manual quoting is an equivalent defence - ✗Thinking only string parameters are dangerous and integers need no binding
Follow-up questions
- →What does
PDO::ATTR_EMULATE_PREPARESchange about where the query is actually parsed? - →Which parts of a query can never be bound as a parameter?
JuniorTheoryCommonWhat is CSRF, and why does a form token defeat it when a cookie check cannot?
What is CSRF, and why does a form token defeat it when a cookie check cannot?
CSRF works because the browser attaches your session cookie to any request to your origin, including a form posted from an attacker's page — so the cookie proves nothing about where the request came from. A token the server embedded in your page cannot be read cross-site, so only your page can submit it. SameSite narrows the attack but is defence-in-depth, not a replacement.
Common mistakes
- ✗Thinking CSRF is the attacker stealing or guessing the session id
- ✗Believing a
SameSitecookie makes the token unnecessary - ✗Relying on the
Refererheader, which can be absent or stripped by a proxy
Follow-up questions
- →Why does a
GETendpoint that changes state need a token as well? - →What does
SameSite=Laxstill let through that a token would catch?
JuniorTheoryCommonWhy must database credentials and API keys stay out of version control?
Why must database credentials and API keys stay out of version control?
A committed secret lives in the repository history forever — every clone, fork and CI log has it, and deleting the line in a later commit does not take it back out. Keep secrets in environment variables or a secret manager, commit only a .env.example with empty values, and gitignore the real .env. A leaked key has to be rotated, not merely removed.
Common mistakes
- ✗Believing a private repository makes a committed secret safe
- ✗Deleting the line in a later commit and not rotating the key — the history keeps it
- ✗Committing the real
.envinstead of a.env.examplewith empty values
Follow-up questions
- →What is the first thing you do when an API key turns up in a public commit?
- →How do you get a secret to a
PHP-FPMworker without writing it to disk?
JuniorTheoryCommonAfter session_start(), what is kept in the cookie and what stays on the server?
After session_start(), what is kept in the cookie and what stays on the server?
The cookie holds only the session id — an opaque random string. The data in $_SESSION lives server-side, by default in a file under session.save_path, keyed by that id. The id is therefore a bearer token: whoever holds it is that user, which is why it must be unpredictable, sent over HTTPS, and marked HttpOnly.
Common mistakes
- ✗Thinking the
$_SESSIONdata itself travels in the cookie - ✗Assuming PHP binds a session to the client's address or user agent
- ✗Forgetting the id is a bearer token — whoever holds it is that user
Follow-up questions
- →What breaks when two web servers do not share
session.save_path? - →Why does
HttpOnlyon the session cookie matter so much when an XSS bug exists?
JuniorTheoryCommonWhat is XSS, and which output context does htmlspecialchars() actually cover?
What is XSS, and which output context does htmlspecialchars() actually cover?
XSS is attacker-controlled data rendered as markup or script in another user's browser. htmlspecialchars() encodes <, >, & and quotes, which makes a value safe as text in the HTML body and, with ENT_QUOTES, inside a quoted attribute. It does not make it safe inside a <script> block, a javascript: URL or an unquoted attribute — escaping is per context.
Common mistakes
- ✗Treating one
htmlspecialchars()call as safe in every output context - ✗Escaping on input and storing the encoded value instead of escaping on output
- ✗Believing
htmlspecialchars()strips tags — it encodes characters and removes nothing
Follow-up questions
- →How would you safely place a PHP value inside a
<script>block? - →What does a template engine's auto-escaping still not know about your output context?
MiddleTheoryCommonInput validation and output encoding both touch user data. Which one stops XSS, and why?
Input validation and output encoding both touch user data. Which one stops XSS, and why?
Validation decides whether data is acceptable for your domain — an email looks like an email, an age is 0-150 — and it runs once, on the way in. Encoding decides how a value renders safely in one specific context, and it runs on every output. Only encoding stops XSS: the same stored string is safe as HTML text and dangerous inside a <script> block. Validation narrows the input; it does not make it printable.
Common mistakes
- ✗Treating validation as the XSS boundary — it narrows input, it does not make it printable
- ✗Encoding on input and storing the encoded value, which corrupts every other context
- ✗Assuming one escaping function covers HTML, JavaScript and URL contexts alike
Follow-up questions
- →Where does validation genuinely stop an attack, if not at XSS?
- →Why does storing HTML-encoded text corrupt a JSON API response built from the same column?
JuniorTheoryOccasionalWhat does an API rate limiter count, and what does its counter key on?
What does an API rate limiter count, and what does its counter key on?
It counts requests per identity per time window. The counter is keyed by whatever identifies the caller — an API key, a user id, or the IP address when there is nothing better — combined with the window. It lives in shared storage such as Redis, not in PHP memory, because every PHP-FPM worker serves a different request. Over the limit you answer 429 with Retry-After.
Common mistakes
- ✗Keeping the counter in per-worker memory, so each worker grants the full limit separately
- ✗Keying only on IP when callers share one NAT address or already present an API key
- ✗Not returning
429withRetry-After, leaving the client to guess when it may retry
Follow-up questions
- →How does a sliding window differ from a fixed one right at the boundary?
- →How do you limit fairly when many callers sit behind a single IP address?
MiddleTheoryOccasionalWhat is session fixation, and which single call at login defeats it?
What is session fixation, and which single call at login defeats it?
The attacker plants a session id they already know — through a link, a subdomain or an injected cookie — and waits for the victim to log in under it. That id is now authenticated, and the attacker holds it. The fix is to issue a fresh id the moment privilege changes: session_regenerate_id(true) right after password_verify() succeeds, which also destroys the old session data.
Common mistakes
- ✗Confusing fixation, where the attacker supplies the id, with hijacking, where they steal it
- ✗Regenerating the id at logout but not at login, which is where the privilege change happens
- ✗Believing
HttpOnlyand HTTPS alone stop an id the attacker planted himself
Follow-up questions
- →Why does
session_regenerate_id(true)taketrue, and what is left behind without it? - →Where else besides login does a privilege change call for a fresh session id?
MiddleTheoryOccasionalA user-chosen sort column goes into ORDER BY. Why can you not bind it, and what do you do?
A user-chosen sort column goes into ORDER BY. Why can you not bind it, and what do you do?
A placeholder is a value slot: the server parses the query first, so a bound parameter can only ever be data — never an identifier or a keyword. ORDER BY :col would sort by a constant string, not by the column. Map the input through an allowlist — an array of permitted column names — and interpolate only the value you looked up. The ASC/DESC direction goes through an allowlist too.
Common mistakes
- ✗Trying to bind the column name and getting a sort by a constant string instead
- ✗Escaping the identifier instead of choosing it from an allowlist
- ✗Allowlisting the column but interpolating
ASC/DESCstraight from the request
Follow-up questions
- →What can an attacker learn by sorting on a column you never meant to expose?
- →How do you keep the allowlist in step with the columns the API really exposes?
SeniorDesignRareYou expose a public JSON API from PHP-FPM behind a load balancer — several servers, dozens of workers on each, no shared application memory. You must rate-limit it: anonymous callers by address, authenticated callers by their account, and the login endpoint far more strictly than the rest, because it is the target of credential-stuffing runs. Some legitimate customers sit behind a single corporate NAT address, and one partner integration bursts a hundred calls at midnight and then goes quiet for the day. Design the limiter. Cover where the counter lives so that every worker on every server sees the same number, what the counter is keyed by and how that key changes once a caller authenticates, how you choose between a fixed and a sliding window given the midnight burst, what a caller over the limit receives and how they learn when to retry, and what happens to the whole API if the store backing your counters goes down.
You expose a public JSON API from PHP-FPM behind a load balancer — several servers, dozens of workers on each, no shared application memory. You must rate-limit it: anonymous callers by address, authenticated callers by their account, and the login endpoint far more strictly than the rest, because it is the target of credential-stuffing runs. Some legitimate customers sit behind a single corporate NAT address, and one partner integration bursts a hundred calls at midnight and then goes quiet for the day. Design the limiter. Cover where the counter lives so that every worker on every server sees the same number, what the counter is keyed by and how that key changes once a caller authenticates, how you choose between a fixed and a sliding window given the midnight burst, what a caller over the limit receives and how they learn when to retry, and what happens to the whole API if the store backing your counters goes down.
Put the counter in shared storage — Redis with an atomic INCR and an expiry — as each worker is its own process. Key it by identity, not by connection: the account id once authenticated, the API key for partners, the address only as the anonymous fallback, so one NAT'd office is not a single bucket. Limit login separately, far tighter. A sliding window stops a burst doubling across a fixed boundary. Answer 429 with Retry-After, and pick the store's failure mode: fail-open or fail-closed.
Common mistakes
- ✗Counting in per-worker memory, so the effective limit is the limit times the worker count
- ✗Keying everything on the address, which merges a NAT'd office into one bucket
- ✗Never deciding what the API does when the counter store itself is unavailable
Follow-up questions
- →Why does a fixed window let a caller send double the limit around the boundary?
- →Would you fail open or fail closed when Redis is down, and what does that choice cost?
SeniorDesignRareA colleague opens a pull request that drops out of the ORM for a reporting screen. The new code builds one long SQL string by hand: a SELECT whose WHERE clause is assembled from optional filters the user ticks in the UI, an IN (...) list built by imploding an array of ids, a sort column and direction taken straight from the query string, and a LIMIT interpolated from a page-size parameter. They have also set PDO::ATTR_EMULATE_PREPARES to true in the connection factory, because a statement failed without it. Their argument is that the ORM's generated query was too slow and that they escape everything with PDO::quote() anyway. You are the reviewer. Say what you would require before this merges: which parts of that query can be bound and which cannot, what you do about each of the ones that cannot, what the emulation setting changes about where the query is actually parsed, and what you would ask them to prove about the performance claim itself.
A colleague opens a pull request that drops out of the ORM for a reporting screen. The new code builds one long SQL string by hand: a SELECT whose WHERE clause is assembled from optional filters the user ticks in the UI, an IN (...) list built by imploding an array of ids, a sort column and direction taken straight from the query string, and a LIMIT interpolated from a page-size parameter. They have also set PDO::ATTR_EMULATE_PREPARES to true in the connection factory, because a statement failed without it. Their argument is that the ORM's generated query was too slow and that they escape everything with PDO::quote() anyway. You are the reviewer. Say what you would require before this merges: which parts of that query can be bound and which cannot, what you do about each of the ones that cannot, what the emulation setting changes about where the query is actually parsed, and what you would ask them to prove about the performance claim itself.
Values bind: the filters, the IN list (one generated placeholder per element) and the LIMIT. Identifiers do not — the sort column and its direction must come from an allowlist, never from the request. PDO::quote() is not a substitute for binding. And ATTR_EMULATE_PREPARES = true means PDO interpolates client-side, so there is no server-side parse-then-bind at all; turn it off. Finally make them show an EXPLAIN and a measurement — the ORM was slow is a claim, not a finding.
Common mistakes
- ✗Accepting
PDO::quote()as equivalent to binding a parameter - ✗Leaving
ATTR_EMULATE_PREPARESon and still believing the server parses first - ✗Trying to bind the sort column or its direction instead of allowlisting them
Follow-up questions
- →How do you build an
IN (...)list from a variable-length array without interpolating it? - →What in an
EXPLAINwould actually justify leaving the ORM for this one report?