Web Security Basics Every SME Site Skips

Most small business sites fail the same seven security checks, from missing headers to a PHP version nobody has looked at. None need a security team, and each one runs from a terminal. Here are the commands and configs, in order.

Most small business sites I audit fail the same seven checks. None of them need a security team. Most take under an hour, and every one can be tested from a terminal.

Run these against sites you own or have written permission to test. Swap example.com for your domain.

Update, 1 November 2026. On 21 October, PHP released emergency fixes for two critical flaws, CVE-2026-109618 and CVE-2026-109619, nicknamed PHP2Shell. Both let an attacker run code on a web server without logging in. The fixed releases are 8.5.12, 8.4.27, 8.3.36 and 8.2.35. Exploit attempts were reported around the time of disclosure, so check your version now using section 6 below. CISA’s advice for anyone who can’t update, or who’s still on PHP 8.1 or older, is to take PHP systems offline until they can.

Sources: CISA alert and PHP’s announcements.

1. Security headers

Start here, because it takes one command.

bash

curl -sI https://example.com

Look for Strict-Transport-Security, Content-Security-Policy, X-Content-Type-Options, Referrer-Policy and Permissions-Policy. On most SME sites you’ll find none of them. Each one tells the browser to refuse something an attacker would otherwise get for free: guessing file types, framing your site inside theirs, leaking full URLs to third parties.

In nginx, inside your server block:

nginx

add_header Strict-Transport-Security "max-age=300" always;  # raise later
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy-Report-Only "default-src 'self'; frame-ancestors 'self'" always;

Two cautions. Start HSTS with a short max-age and raise it to a year (31536000) once every subdomain serves HTTPS, because browsers will hold you to it. And keep CSP in report-only mode at first. A strict policy breaks analytics, fonts and embedded widgets until you’ve listed them all.

2. HTTPS that stays HTTPS

A certificate that works today and expires in ninety days with no working renewal is a future outage. Check the expiry date:

bash

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -enddate

Then prove renewal works:

bash

sudo certbot renew --dry-run

If you don’t use certbot, whatever issues your certificates needs the same test. Add an external monitor that alerts at 14 days left. Also confirm http:// redirects to https:// with a 301, and that no page loads scripts or images over plain HTTP.

3. Files that should never be public

bash

for p in .env .git/config backup.zip phpinfo.php; do
  printf "%s: " "$p"
  curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/$p"
done

A 200 on any of these is an incident. An exposed .env hands over database passwords and API keys. An exposed .git/config can lead to your whole source history. Single-page apps and some CMSs return 200 for every path, so open the response and read it before panicking.

Keep secrets outside the web root, then block dotfiles in the server config:

nginx

location ^~ /.well-known/ { allow all; }
location ~ /\. { deny all; }

The first line matters. Certbot’s validation lives in /.well-known/, and blocking it breaks your renewals. If a secret was ever exposed, rotate it. Deleting the file doesn’t undo the exposure.

4. Forms and rate limiting

Every form is a door into your database and your inbox. Validate on the server, always. Browser-side checks only help honest users.

Use parameterised queries, never string-built SQL:

js

// Unsafe
db.query(`SELECT * FROM users WHERE email = '${email}'`);

// Safe
db.query('SELECT * FROM users WHERE email = ?', [email]);

Then limit how often a form can be hit. In nginx, define the zone in the http block:

nginx

limit_req_zone $binary_remote_addr zone=forms:10m rate=5r/m;

And apply it to the route:

nginx

location /contact {
    limit_req zone=forms burst=3 nodelay;
}

Add a honeypot field or a CAPTCHA to contact forms. Make sure no user input can end up in an email header, or your form becomes a spam relay with your domain on it.

5. Logins and sessions

Check how your cookies are set:

bash

curl -sI https://example.com/login | grep -i set-cookie

Session cookies should carry Secure, HttpOnly and SameSite. Without HttpOnly, a single cross-site scripting bug hands over every logged-in session.

Then the human side. Two-factor on every admin account. No username called admin. A lockout or rate limit on the login route. Delete the accounts of people who’ve left. On WordPress, disable XML-RPC if you don’t use it, since it’s a favourite route for password guessing.

6. Patching and dependency audits

Start with the layer people forget: PHP itself. Your plugins can be spotless while the server runs a version with a known remote code execution flaw.

Find your version. Over SSH:

bash

php -v

Treat that number with caution. Many hosts run several PHP versions side by side, and the command line can report a different one from the version serving your website. On WordPress, the reliable check is Tools, then Site Health, then Info, then Server. On other hosting, look for a PHP selector in the control panel.

Compare what you find with the supported releases listed at php.net/supported-versions.php. Anything not on that list gets no security fixes, and a flaw in it stays open.

On shared hosting, you can’t patch PHP yourself. Ask your host which patch level they run and whether it’s current. Most panels let you move to a newer branch with a couple of clicks.

Then check the layers above it:

bash

npm audit --omit=dev
composer audit
pip-audit

On WordPress, WP-CLI does the job:

bash

wp core verify-checksums
wp plugin list --update=available

verify-checksums compares your core files against the official release. A modified file you didn’t change is a red flag. While you’re there, delete unused plugins and themes. Deactivated code is still code on your server.

Put a monthly slot in the calendar, and follow PHP’s announcements or your host’s status page so emergency releases reach you before a trending headline does. “Whenever there’s time” means never.

7. Backups you’ve restored

A backup you’ve never restored is a hope. Keep three copies, on two kinds of storage, with one offsite. Then, once a quarter, restore the latest backup to a throwaway server and time it. That number is your real recovery time, and it’s usually longer than anyone assumed.

Check where the backups live, too. A backup.zip sitting in the web root is check three failing again.

Make it a routine

Put all seven on a monthly reminder. The first pass takes an afternoon. After that, it’s about twenty minutes.

If you’d rather have someone run them for you, get in touch through mohankossie.com.

Picture of admin

admin

Leave a Replay