Skip to main content
    Courses/PHP/Advanced Security

    Lesson 24 • Advanced

    Securing PHP Applications 🛡️

    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.

    What You'll Learn in This Lesson

    • Send a Content-Security-Policy and the core security headers (HSTS, X-Frame-Options, nosniff)
    • Encrypt sensitive data at rest with the sodium extension
    • Keep secrets out of code with environment variables and .env
    • Verify JWTs safely — pin the algorithm and always check expiry
    • Block SSRF and insecure unserialize() deserialization attacks
    • Scan dependencies with composer audit and handle uploads securely

    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.

    Sending the core security headers
    <?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
    }
    ?>
    Output
    Content-Security-Policy: default-src 'self'; script-src 'self'
    Strict-Transport-Security: max-age=31536000; includeSubDomains
    X-Frame-Options: DENY
    X-Content-Type-Options: nosniff
    Referrer-Policy: no-referrer
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    sodium encryption + an SSRF host guard
    <?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";
    ?>
    Output
    Ciphertext bytes: 40 (unreadable)
    Decrypted: Patient SSN: 123-45-6789
    Fetching from api.example.com ...
    BLOCKED: '169.254.169.254' is not an allowed host
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Verifying a JWT the safe way
    <?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";
    ?>
    Output
    ACCEPTED: user ada
    REJECTED: alg 'none' != expected 'HS256'
    REJECTED: token expired or has no expiry
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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).

    A hardened upload handler
    <?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];
        return "STORED as {$safeName} (original '{$clientName}' ignored)";
    }
    
    echo acceptUpload("cat.png", "image/png", 50_000) . "\n";
    echo acceptUpload("shell.php", "application/x-php", 1_000) . "\n";
    ?>
    Output
    STORED as a3f1c0d9e2b48a17.png (original 'cat.png' ignored)
    REJECTED: type 'application/x-php' not allowed
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    🎯 Your turn: finish the security headers
    <?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
    ?>
    Output
    Content-Security-Policy: default-src 'self'
    Strict-Transport-Security: max-age=31536000
    X-Frame-Options: DENY
    Fill the two ___ blanks (a header key and a header value, both in quotes), then run it. You should see three header lines.

    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.

    🎯 Your turn: stop the alg-confusion attack
    <?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
    ?>
    Output
    ACCEPTED: ada
    REJECTED: bad alg
    Replace the ___ with $token['alg'] so anything other than "HS256" is rejected. The forged token should be blocked.

    Common Errors (and the fix)

    • "Warning: Cannot modify header information — headers already sent" — you called header() (or set a cookie/session) after some output was already sent, even a stray space before <?php. Send all headers before any echo or HTML.
    • "Call to undefined function sodium_crypto_secretbox()" — the sodium extension isn't enabled. It ships with PHP 7.2+; enable it in php.ini (extension=sodium), or use OneCompiler which already has it.
    • JWT verifies but anyone can forge a token — you read the algorithm from the token instead of pinning your own. Pass the expected algorithm explicitly to your verify call and reject anything else; never accept alg: "none".
    • Your API key shows up in a leaked repo — a secret was committed to Git. Move it to an environment variable, add .env to .gitignore, and rotate the key — Git history keeps the old value forever.
    • An uploaded "image" runs as PHP — you trusted the file extension and stored it in the web root under its original name. Validate the MIME type, generate your own filename, and store uploads outside the public directory.

    Pro Tips

    • 💡 Never unserialize() user input. It can rebuild objects and fire magic methods (__wakeup/__destruct) — an object-injection gadget chain. Use json_decode(), which only ever produces plain data.
    • 💡 Run composer audit in CI. Your code can be perfect while a vulnerable library sinks you. Fail the build on a known-vulnerable dependency so it never reaches production.
    • 💡 Encrypt for read-back, hash for passwords. Use sodium for data you must decrypt later; use password_hash() (Argon2) for passwords, which you only ever compare.

    📋 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

    Frequently Asked Questions

    Q: 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.

    Q: 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.

    Q: 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.

    Q: 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.

    Q: 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.

    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.

    🎯 Mini-Challenge: allow only trusted hosts
    <?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
    ?>
    Write isAllowedHost(), parse the host with parse_url, and allow only your whitelist. The metadata IP must print BLOCKED.

    🎉 Lesson Complete!

    • Security headers (CSP, HSTS, X-Frame-Options, nosniff) are free, server-side defence — send them before any output
    • Encrypt data at rest with the sodium extension; a stolen backup is then useless without the key
    • Keep secrets in environment variables / .env, never in code or Git
    • Verify JWTs safely — pin the algorithm yourself and always check exp
    • Block SSRF and deserialization — whitelist outgoing hosts, and use json_decode() instead of unserialize()
    • Audit dependencies with composer audit, and handle uploads with your own filenames outside the web root
    • Next lesson: Password Security — hashing credentials with Argon2 and storing them safely

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service