Security
By the end of this lesson you'll be able to spot and fix the most common PHP security holes — SQL injection, XSS, and CSRF — and harden passwords, sessions, file uploads, and HTTP headers the way real production apps do.
Part of the free Php course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
1️⃣ SQL Injection — Prepared Statements
SQL injection is the classic, still-number-one web attack. It happens when you glue user input directly into a SQL string: the input then stops being data and becomes part of the command . The fix is a prepared statement — you send the query with a ? placeholder first, then send the value separately, so the database can never confuse the two. This is exactly why the Database lesson taught you prepare() and execute() .
Read the vulnerable query out loud: the attacker's ' OR '1'='1 closes your quote and adds a condition that's always true . With a prepared statement the value stays in its own lane — there's no way to "escape" into the SQL.
2️⃣ XSS — Escape Output & Use a CSP
XSS (cross-site scripting) is the mirror image of SQL injection, but the victim is the browser instead of the database. If you echo untrusted text into a page without escaping it, an attacker can slip in a < script > tag that then runs in every visitor's browser — stealing cookies or hijacking the session. The fix: run every piece of untrusted output through htmlspecialchars() , which converts < > " ' & into harmless entities. Add a Content-Security-Policy header as a second wall.
In the safe output the < became < , so the browser displays the text instead of running it. Always escape at the moment of output, and always say which charset ( 'UTF-8' ) you mean.
3️⃣ CSRF — Per-Session Tokens
CSRF (cross-site request forgery) tricks a logged-in user's browser into firing a request at your site — say, "change my email" — without them realising. Because the browser automatically attaches their session cookie, your server thinks it's genuine. The defence is a secret token that only your own pages know: put a random value in a hidden form field, store the same value in the session, and reject any POST whose token doesn't match. Compare with hash_equals() , not === , so the comparison takes constant time and leaks no timing clues.
4️⃣ Passwords, Sessions & Cookies
Never store a real password — store a hash . Use password_hash() (bcrypt/Argon2): it's deliberately slow, salts every hash for you, and pairs with password_verify() at login. Then lock down the session cookie : httponly hides it from JavaScript (blunting XSS theft), secure sends it only over HTTPS, and samesite=Strict keeps it off cross-site requests (blunting CSRF). Call session_regenerate_id() right after login to defeat session fixation.
5️⃣ Validate Input, Uploads, SSRF & Headers
The thread running through every section is one rule: never trust user input . Validate its shape with filter_var() . For file uploads , trust the bytes (the real MIME type) not the filename, give the file a random name, and store it outside the webroot so it can't be executed. For SSRF , never fetch a user-supplied URL without an allow-list of permitted hosts. And set security headers — X-Content-Type-Options , X-Frame-Options , Strict-Transport-Security , Content-Security-Policy — on every response.
Now you try. The script below is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel.
One more. Turn a dangerous concatenated query into a parameterised one by placing a single ? where the value belongs.
Common Errors (and the fix)
- You escape input before storing it in the database — then it shows up double-escaped or mangled on the page. Rule: store the raw value, and escape with htmlspecialchars() only at the moment you output it into HTML.
- "Warning: Cannot modify header information — headers already sent" — you called header() or session_start() after something was already echoed (even a blank line or BOM before < ?php ). Set all headers and start the session before any output.
- You use === to compare a CSRF token or hash — it works but leaks timing and, worse, returns the wrong answer for some inputs. Use hash_equals() for secrets and password_verify() for passwords.
- "SQLSTATE... syntax error" once you switch to placeholders — you quoted the placeholder as '?' . A bound parameter is never quoted: write WHERE email = ? , not WHERE email = '?' .
- Uploads still get executed as PHP — you kept the original filename. Check the MIME type with finfo , rename the file to something random, and store it outside the public webroot.
Pro Tips
- 💡 Escape per context. HTML output needs htmlspecialchars() ; a URL needs urlencode() ; SQL needs a bound parameter. There's no one "make safe" function.
- 💡 Validate and escape. Validation rejects the wrong shape; escaping makes a value safe for where it's going. You need both — they're not the same job.
- 💡 Use the OWASP Top 10 as a checklist. Before any deploy, walk the list — injection, broken access control, security misconfiguration — and confirm each is handled.
📋 Quick Reference — Threat → Defence
Threat
Defence
In PHP
SQL injection
Prepared statements
$pdo- > prepare('... = ?')
XSS
Escape output + CSP
htmlspecialchars($x, ENT_QUOTES)
CSRF
Per-session token
hash_equals($sess, $sent)
Weak passwords
Slow, salted hash
password_hash() / password_verify()
Session hijack/fixation
Hardened cookie + new ID
httponly,secure,samesite; regenerate_id()
Bad / malicious input
Validate shape
filter_var($x, FILTER_VALIDATE_*)
Malicious file upload
MIME + rename + move
finfo; random name; outside webroot
SSRF
Allow-list URLs
block private/loopback hosts
Clickjacking / sniffing
Security headers
header('X-Frame-Options: DENY')
Frequently Asked Questions
Mini-Challenge: A Safe Comment Handler
No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This combines CSRF checking and XSS escaping — the two defences you'll wire into almost every form you ever build.
🎉 Lesson Complete!
- ✅ SQL injection → use prepared statements with ? placeholders; never concatenate input into SQL
- ✅ XSS → escape output with htmlspecialchars() and add a Content-Security-Policy
- ✅ CSRF → per-session token checked with hash_equals()
- ✅ Passwords → password_hash() / password_verify() , never md5/sha1
- ✅ Sessions & cookies → httponly , secure , samesite , and session_regenerate_id()
- ✅ Uploads, SSRF, headers → check MIME + rename, allow-list URLs, send security headers — and never trust user input
- ✅ Next lesson: APIs & JSON — build and consume REST endpoints, applying these same input-trust rules to JSON payloads
Practice quiz
What is the correct defence against SQL injection?
- Escaping quotes with addslashes()
- Removing spaces from input
- Prepared statements with bound parameters
- Using md5() on the query
Answer: Prepared statements with bound parameters. A prepared statement sends the SQL and the value on separate channels, so user input can never become part of the command.
Why is "' OR '1'='1" dangerous when concatenated into a query?
- It closes the quote and adds an always-true condition, matching every row
- It crashes the database
- It deletes the users table
- It is harmless text
Answer: It closes the quote and adds an always-true condition, matching every row. The injected condition is always true, so WHERE matches every row and the attacker logs in as the first user.
Which function escapes output to prevent XSS?
- strip_tags() only
- urlencode()
- json_encode()
- htmlspecialchars($x, ENT_QUOTES, 'UTF-8')
Answer: htmlspecialchars($x, ENT_QUOTES, 'UTF-8'). htmlspecialchars() converts < > " ' & into HTML entities so the browser displays untrusted text instead of executing it.
When should you escape data for XSS protection?
- Before storing it in the database
- At the moment you output it into HTML
- Only once, at registration
- Never, the database handles it
Answer: At the moment you output it into HTML. Store the raw value and escape only at output; escaping before storage leads to double-escaped, mangled text.
How should a CSRF token be compared to avoid timing attacks?
- With hash_equals(), which compares in constant time
- With === for speed
- With == loose comparison
- With strcmp()
Answer: With hash_equals(), which compares in constant time. hash_equals() compares in constant time so it leaks no timing information about the secret token.
Why must you never use md5() or sha1() for passwords?
- They are deprecated syntax
- They only work on numbers
- They are fast checksums a GPU can crack billions/sec, and aren't salted
- They produce hashes that are too long
Answer: They are fast checksums a GPU can crack billions/sec, and aren't salted. Password hashing must be slow and salted; password_hash() (bcrypt/Argon2) is built for this, md5/sha1 are not.
Which function verifies a password against a stored hash?
- password_check()
- password_verify($input, $hash)
- hash_compare()
- md5($input) === $hash
Answer: password_verify($input, $hash). password_verify() safely checks a plaintext password against a hash created by password_hash().
What do the httponly, secure, and samesite cookie flags do?
- Compress the cookie
- Encrypt the session data
- Make the cookie never expire
- Hide it from JavaScript, send only over HTTPS, and limit cross-site requests
Answer: Hide it from JavaScript, send only over HTTPS, and limit cross-site requests. HttpOnly blunts XSS theft, Secure forces HTTPS, and SameSite limits CSRF; set them before session_start().
Which function call defeats session fixation after login?
- session_destroy()
- session_regenerate_id(true)
- session_unset()
- setcookie('PHPSESSID', '')
Answer: session_regenerate_id(true). session_regenerate_id(true) issues a brand-new id and deletes the old one, so any planted id becomes worthless.
How should you handle a file upload safely?
- Trust the file extension and keep the original name
- Store it in the same folder as your scripts
- Check the real MIME type, rename it randomly, and store it outside the webroot
- Only check that the size is under 10 MB
Answer: Check the real MIME type, rename it randomly, and store it outside the webroot. An upload is attacker-controlled bytes; validate the MIME type, give it your own random name, and keep it out of the public directory.
Continue this course
- Previous: Database Integration
- Next: APIs & JSON