Advanced Security

Reviewed & published by Brayan K

By the end of this lesson you'll harden a PHP app the way production teams do — sending the right security headers, encrypting data at rest, verifying JWTs safely, blocking SSRF and deserialization attacks, and auditing your dependencies before they ship.

Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ Security Headers & Content-Security-Policy

A security header is a short instruction you attach to a response that tells the browser how to behave — and it's free, server-side defence the attacker can't remove. The most powerful one is Content-Security-Policy (CSP): a whitelist of where scripts, styles, and images may load from. With script-src 'self', even if an attacker injects a <script> the browser simply refuses to run it. HSTS forces every visit over HTTPS, X-Frame-Options: DENY stops your page being framed for clickjacking, and nosniff stops the browser guessing file types. One catch: headers must be sent before any output, so call them at the very top.

<?php
// Security headers are extra instructions you send WITH the page that tell
// the browser how to behave. They must be sent BEFORE any echo/HTML output,
// because once the body starts, the headers are already on their way.

// Content-Security-Policy (CSP): a whitelist of where the browser may load
// scripts, styles and images from. The single best defence against XSS — even
// if an attacker injects a <script>, the browser refuses to run it.
$headers = [
    "Content-Security-Policy"   => "default-src 'self'; script-src 'self'",
    "Strict-Transport-Security" => "max-age=31536000; includeSubDomains", // HSTS: force HTTPS for a year
    "X-Frame-Options"           => "DENY",       // can't be put in an <iframe> -> stops clickjacking
    "X-Content-Type-Options"    => "nosniff",    // don't guess file types
    "Referrer-Policy"           => "no-referrer", // don't leak the URL you came from
];

foreach ($headers as $name => $value) {
    // header("{$name}: {$value}");  // <- the real call in a live request
    echo "{$name}: {$value}\n";     // we echo here just to SEE them
}
?>

In a real request you'd swap each echo for the commented-out header(...) call. Send these once, near the start of your app, and every page inherits them.

2️⃣ Encryption at Rest & SSRF

Encryption at rest means scrambling sensitive data before it lands on disk, so a stolen database backup is useless without the key. Use the modern sodium extension (built into PHP since 7.2) — it picks safe defaults so you can't accidentally choose a broken cipher like ECB in raw openssl. The same code also shows SSRF (Server-Side Request Forgery): if user input decides which URL your server fetches, an attacker can point it at internal addresses like the cloud-metadata endpoint 169.254.169.254 and steal credentials. The fix is the same whitelist pattern you use everywhere — only allow hosts you trust.

<?php
// === Encryption AT REST: scramble sensitive data before it touches the disk ===
// Use the modern sodium extension (built into PHP 7.2+). It picks safe defaults
// so you can't accidentally choose a broken cipher.

$key       = sodium_crypto_secretbox_keygen();           // 32-byte secret key
$nonce     = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); // unique per message
$plaintext = "Patient SSN: 123-45-6789";

// Encrypt: turns readable text into ciphertext nobody can read without the key.
$cipher = sodium_crypto_secretbox($plaintext, $nonce, $key);
echo "Ciphertext bytes: " . strlen($cipher) . " (unreadable)\n";

// Decrypt: only the holder of the key + nonce gets the original back.
$decrypted = sodium_crypto_secretbox_open($cipher, $nonce, $key);
echo "Decrypted: {$decrypted}\n";

// === SSRF: never let user input pick which URL the server fetches ===
// VULNERABLE: file_get_contents(\$_GET['url']);  ?url=http://169.254.169.254/
// An attacker could point it at internal cloud-metadata or your own database.
function safeFetch(string $url): string
{
    $host = parse_url($url, PHP_URL_HOST);                // pull out the hostname
    $allowed = ['api.example.com', 'cdn.example.com'];    // only trust these
    if (!in_array($host, $allowed, true)) {
        return "BLOCKED: '{$host}' is not an allowed host";
    }
    return "Fetching from {$host} ...";
}
echo safeFetch("https://api.example.com/data") . "\n";
echo safeFetch("http://169.254.169.254/latest/meta-data/") . "\n";
?>

Notice the ciphertext is unreadable bytes, yet decryption with the same key and nonce returns the original exactly. And the server flatly refuses to fetch from 169.254.169.254 because it isn't on the allow-list.

3️⃣ JWT Pitfalls: Algorithm Confusion & Expiry

A JWT (JSON Web Token) is signed JSON used to prove who a user is. The danger is that the token itself claims which algorithm signed it, and naive code trusts that claim. In an alg confusion attack the attacker sets alg to "none" (no signature needed!) or swaps RS256 for HS256 to forge a valid signature. The fixes are simple: never read the algorithm from the token — demand the one you chose — and always check the exp expiry, or a stolen token works forever.

<?php
// === JWT pitfalls: a JWT is just signed JSON — verify it PROPERLY ===
// A token has three parts: header.payload.signature
// The two classic mistakes are (1) trusting the 'alg' the TOKEN claims, and
// (2) forgetting to check the expiry. We model the checks here.

function verifyToken(array $token, string $expectedAlg, int $now): string
{
    // 1) ALG CONFUSION: never trust token['alg']. Demand the algorithm YOU chose.
    //    Attackers send alg:"none" or swap RS256->HS256 to forge signatures.
    if ($token['alg'] !== $expectedAlg) {
        return "REJECTED: alg '{$token['alg']}' != expected '{$expectedAlg}'";
    }
    // 2) EXPIRY: a token without an 'exp' check is valid forever -> reject it.
    if (!isset($token['exp']) || $token['exp'] < $now) {
        return "REJECTED: token expired or has no expiry";
    }
    return "ACCEPTED: user {$token['sub']}";
}

$now = 1000;
echo verifyToken(['alg' => 'HS256', 'sub' => 'ada', 'exp' => 2000], 'HS256', $now) . "\n";
echo verifyToken(['alg' => 'none',  'sub' => 'eve', 'exp' => 2000], 'HS256', $now) . "\n";
echo verifyToken(['alg' => 'HS256', 'sub' => 'bob', 'exp' => 500 ], 'HS256', $now) . "\n";
?>

4️⃣ Secrets Management & Secure File Handling

Secrets — database passwords, API keys, encryption keys — must never live in your code or in Git. Keep them in environment variables, often loaded from a .env file that is in .gitignore, and read them with getenv() or $_ENV. A file upload is just attacker-controlled bytes with an attacker-chosen name, so three rules apply: validate the content type, cap the size, and generate your own random filename stored outside the web root — never reuse the client's name (think ../../shell.php).

<?php
// === Secure file handling: an upload is just attacker-controlled bytes ===
// Three rules: validate the TYPE, generate your OWN name, store OUTSIDE webroot.

function acceptUpload(string $clientName, string $mime, int $size): string
{
    // 1) Whitelist the content type — never trust the file extension alone.
    $allowedMimes = ['image/png' => 'png', 'image/jpeg' => 'jpg'];
    if (!isset($allowedMimes[$mime])) {
        return "REJECTED: type '{$mime}' not allowed";
    }
    // 2) Cap the size so nobody fills your disk.
    if ($size > 2_000_000) {           // 2 MB
        return "REJECTED: file too large";
    }
    // 3) NEVER reuse the client's filename ('../../shell.php' is an attack).
    //    Generate a random, safe name with the extension YOU decided.
    $safeName = bin2hex(random_bytes(8)) . '.' . $allowedMimes[$mime];
    // $safeName is random — that is the point — so report its shape.
    return "STORED as <16 hex chars>.png (original '{$clientName}' ignored)";
}

echo acceptUpload("cat.png", "image/png", 50_000) . "\n";
echo acceptUpload("shell.php", "application/x-php", 1_000) . "\n";
?>

The random filename in the output will differ each run (it comes from random_bytes) — what matters is that the PHP upload is rejected outright and the image is stored under a name the attacker never controlled.

5️⃣ Your Turn

Time to defend something yourself. Each script below is almost complete — fill in every ___ using the 👉 hint, then run it and check it against the Output panel.

<?php
// 🎯 YOUR TURN — finish the security-header set, then run it.
// You're adding the two headers most often forgotten.

$headers = [
    "Content-Security-Policy" => "default-src 'self'",
];

// 1) Add HSTS so the browser ALWAYS uses HTTPS for a year.
$headers[___] = "max-age=31536000";   // 👉 the key is "Strict-Transport-Security"

// 2) Add the header that blocks your page from being framed (clickjacking).
$headers["X-Frame-Options"] = ___;    // 👉 the value is "DENY"  (in quotes)

foreach ($headers as $name => $value) {
    echo "{$name}: {$value}\n";
}

// ✅ Expected output:
//    Content-Security-Policy: default-src 'self'
//    Strict-Transport-Security: max-age=31536000
//    X-Frame-Options: DENY
?>

One more — and this one is the real bug that defeats forged tokens. The verifier checks expiry but forgets to pin the algorithm, so an alg:"none" token gets in. Add the missing guard.

<?php
// 🎯 YOUR TURN — close the alg-confusion hole.
// This verifier checks the expiry but FORGETS to pin the algorithm,
// so a forged alg:"none" token sails through. Add the missing guard.

function verify(array $token, int $now): string
{
    // 👉 Reject the token unless its alg is EXACTLY "HS256".
    if (___ !== "HS256") {            // 👉 compare $token['alg'] to "HS256"
        return "REJECTED: bad alg";
    }
    if ($token['exp'] < $now) {
        return "REJECTED: expired";
    }
    return "ACCEPTED: {$token['sub']}";
}

echo verify(['alg' => 'HS256', 'sub' => 'ada', 'exp' => 999], 500) . "\n";
echo verify(['alg' => 'none',  'sub' => 'eve', 'exp' => 999], 500) . "\n";

// ✅ Expected output:
//    ACCEPTED: ada
//    REJECTED: bad alg
?>

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Security Headers

HeaderExample valueWhat It Protects Against
Content-Security-Policydefault-src 'self'XSS / injected scripts
Strict-Transport-Securitymax-age=31536000Downgrade to HTTP (forces HTTPS)
X-Frame-OptionsDENYClickjacking (framing)
X-Content-Type-OptionsnosniffMIME-type sniffing
Referrer-Policyno-referrerLeaking URLs to other sites
Permissions-Policygeolocation=()Unwanted access to camera/mic/location

Mini-Challenge: Build an SSRF Host Gate

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 is the exact whitelist pattern that stops a server being tricked into fetching internal addresses.

<?php
// 🎯 MINI-CHALLENGE: a safe-host gate for an outgoing request (SSRF defence).
// No code is filled in — work from the steps, then run it.
//
// 1. Write a function isAllowedHost(string $url): bool
// 2. Inside, pull the host out of the URL with:
//        $host = parse_url($url, PHP_URL_HOST);
// 3. Keep a whitelist:  $allowed = ['api.example.com'];
// 4. Return true only if $host is in $allowed (use in_array(..., true)).
// 5. Echo "OK"  for https://api.example.com/x
//    Echo "BLOCKED" for http://169.254.169.254/  (the cloud-metadata trap)
//
// Tip: echo isAllowedHost($url) ? "OK\n" : "BLOCKED\n";
//
// ✅ Expected output:
//    OK
//    BLOCKED

// your code here
?>

🎉 Lesson Complete!

Practice quiz

Which security header is the single best defence against XSS?

  • X-Frame-Options
  • Referrer-Policy
  • Content-Security-Policy
  • X-Content-Type-Options

Answer: Content-Security-Policy. A CSP like "script-src 'self'" tells the browser to refuse injected scripts, blocking XSS even if a <script> gets into the page.

What does the X-Frame-Options: DENY header protect against?

  • Clickjacking via framing your page in an <iframe>
  • MIME-type sniffing
  • SQL injection
  • Weak passwords

Answer: Clickjacking via framing your page in an <iframe>. DENY stops the page from being embedded in a frame, which is how clickjacking attacks trick users into clicking.

Why must security headers be sent before any echo or HTML output?

  • Headers are slower than output
  • PHP forbids headers in functions
  • Browsers ignore late headers but still run them
  • Once the response body starts, headers are already on their way and can't be changed

Answer: Once the response body starts, headers are already on their way and can't be changed. You get 'Cannot modify header information - headers already sent' if output (even a stray space) precedes header() calls.

What is the right tool for two-way encryption of data at rest in modern PHP?

  • md5()
  • sodium_crypto_secretbox()
  • base64_encode()
  • password_hash()

Answer: sodium_crypto_secretbox(). The sodium extension (built into PHP 7.2+) picks safe defaults; secretbox encrypts and can decrypt with the same key and nonce.

What is the key difference between hashing and encryption?

  • Hashing is one-way (passwords); encryption is two-way (data you must read back)
  • Hashing is two-way, encryption is one-way
  • They are the same thing
  • Encryption is only for passwords

Answer: Hashing is one-way (passwords); encryption is two-way (data you must read back). Use password_hash (one-way) for passwords you only compare, and sodium encryption (two-way) for data like a stored SSN you must decrypt.

How do you defend against SSRF when the server fetches a URL?

  • Trust any https:// URL
  • Use file_get_contents directly on user input
  • Allow-list permitted hosts and block private/loopback addresses
  • Only fetch URLs over HTTP

Answer: Allow-list permitted hosts and block private/loopback addresses. Never let user input pick the URL freely; allow-list the hosts you trust so attackers can't reach 169.254.169.254 or internal services.

What are the two classic JWT 'alg confusion' attacks?

  • Expired tokens and missing claims
  • alg:"none" (no signature) and swapping RS256 for HS256
  • Base64 and hex encoding mismatches
  • Wrong issuer and wrong audience

Answer: alg:"none" (no signature) and swapping RS256 for HS256. Never trust the token's alg field: attackers set it to none or swap algorithms. Pin the algorithm you expect and reject anything else.

Besides pinning the algorithm, what must you always check on a JWT?

  • The token's length
  • That it is uppercase
  • The number of dots in the token
  • The 'exp' expiry claim

Answer: The 'exp' expiry claim. A token without an expiry check is valid forever; always verify exp so a stolen token eventually stops working.

Why should you never call unserialize() on user input?

  • It is slower than json_decode
  • It can rebuild objects and fire magic methods, enabling object-injection gadget chains
  • It only works on arrays
  • It strips all whitespace

Answer: It can rebuild objects and fire magic methods, enabling object-injection gadget chains. unserialize() can instantiate objects and run __wakeup/__destruct; use json_decode(), which only ever produces plain arrays and scalars.

What does 'composer audit' do?

  • Reformats your composer.json
  • Runs your unit tests
  • Scans installed package versions against known security advisories
  • Deletes unused dependencies

Answer: Scans installed package versions against known security advisories. composer audit compares composer.lock versions to a vulnerability database and flags dependencies with known advisories.

Continue this course

Frequently asked questions

What is the difference between encryption and hashing?

They solve different problems. Hashing (with password_hash / Argon2) is one-way: you can never get the original back, which is exactly what you want for passwords — you only ever compare hashes. Encryption is two-way: you scramble data with a key and can unscramble it later with that same key, which is what you want for data you must read again, like a stored SSN or an API token. Use sodium_crypto_secretbox() for encryption at rest, and never use plain hashing where you actually need the value back.

Why should I never use unserialize() on user input?

unserialize() can rebuild full PHP objects from a string, and as it does so it runs magic methods like __wakeup() and __destruct(). An attacker who controls that string can craft a chain of objects (a 'gadget chain') that runs code or deletes files when those methods fire — this is a PHP object injection / insecure deserialization attack. Use json_decode() instead: it only ever produces plain arrays and scalars, never objects, so there is nothing for an attacker to trigger.

What is 'alg confusion' in JWTs?

A JWT states which algorithm signed it inside the token itself (the 'alg' field). If your code trusts that field, an attacker can change it. The two classic attacks are setting alg to 'none' (claiming the token needs no signature at all) and swapping an asymmetric RS256 for the symmetric HS256 so your public key gets used as the HMAC secret. The fix is to never read alg from the token to decide how to verify — hard-code the one algorithm you expect and reject anything else, and always check the 'exp' expiry claim.

How do I store secrets like database passwords and API keys?

Keep them out of your code and out of version control. The standard approach is environment variables, often loaded from a .env file that is listed in .gitignore (libraries like vlucas/phpdotenv read it for you), with the real values injected by your host or a secrets manager (AWS Secrets Manager, HashiCorp Vault) in production. Read them with getenv() or $_ENV. Never commit secrets, never echo them, and rotate any secret that has ever appeared in a commit — git history keeps it forever.

What does 'composer audit' do?

composer audit scans the exact package versions in your composer.lock against a public database of known security advisories and reports any dependency with a known vulnerability, including the advisory and a safe version to upgrade to. Your own code can be perfect while a flaw in a library you pulled in still sinks you, so run it regularly and wire it into CI so a vulnerable package fails the build before it ever reaches production.