Skip to main content

Website Security Checklist 2026 for Small Businesses and Developers

Updated September 2026. This checklist covers the security basics every small business website needs, then the developer checks, run against our own Django site. The settings, headers, TLS results and command output we quote all come from this website, checked on 25 September 2026, and so do the gaps those checks turned up: a missing Content-Security-Policy header, an HSTS preload that was never submitted, a fallback secret key and a setting that no longer does anything. We have since removed the fallback key, as the developer checklist below explains.

The short version

  1. Apply updates promptly, and use software that is still supported.
  2. Take automatic off-site backups, and test a restore.
  3. Turn on two-factor authentication for every account that can change the site, domain or hosting.
  4. Give each person their own login, with only the access they need.
  5. Serve everything over HTTPS with TLS 1.2 or newer, and turn on HSTS.
  6. Put a firewall and CDN such as Cloudflare in front of the site.
  7. Protect forms and logins from bots.
  8. Keep card details off your server by using a hosted payment page.
  9. Send the main security headers, and check them on the live site.
  10. Audit your dependencies before every deploy.
  11. Monitor uptime and errors, and plan for a bad day.

Checklist for small business owners

None of this needs code. If someone else built or hosts your site, use these as questions to ask them.

1. Updates, and software that is still supported

Most attacks on small sites use known holes that already have a fix. Apply security updates to your CMS, plugins, themes and framework within days, not months. Then check the version you're on still receives fixes at all. Django 4.2, for example, stopped getting security updates on 7 April 2026, so a Django site still on 4.2 should move to 5.2 LTS (supported until April 2028) or 6.x. The same goes for old PHP versions and server operating systems. We took our own advice late: this site was still on Django 4.2 when we ran these checks, and we have since upgraded it to 5.2 LTS.

2. Backups you have actually restored

A backup you have never restored is a hope, not a plan. Back up the database and uploaded files automatically every day, keep a copy away from the server (a different provider or account), keep enough history to go back to before a problem started, and restore one to a test site at least once a quarter. Our guide on how to back up your website walks through the options.

3. Two-factor authentication and least privilege

Stolen and reused passwords are still the easiest way in. Turn on two-factor authentication (2FA) for your site admin, hosting account, domain registrar, DNS provider and the email account that receives password resets, because whoever controls that inbox can reset everything else. Use an authenticator app or a passkey rather than text messages where you can.

Give everyone their own account with only the access their job needs: a content editor doesn't need to install plugins or manage users. Remove accounts the day someone leaves, and use a password manager so every password is unique.

4. HTTPS, TLS and HSTS

Every page should load over HTTPS, plain HTTP should redirect to it, and the server should refuse outdated protocol versions. When we tested this site with a short Python script, the server refused TLS 1.0 and 1.1 and accepted TLS 1.2 and 1.3. Its certificate is valid for only 90 days, which works because renewal is automatic. HSTS then tells browsers never to try plain HTTP on your domain again. There's more on certificates in our guide to SSL certificates and HTTPS.

5. A firewall and CDN in front of the site

A service such as Cloudflare sits between visitors and your server. It absorbs floods of traffic, filters known attack patterns and can rate-limit login pages. This site runs behind Cloudflare, which redirects plain-HTTP requests to HTTPS before they reach our server. If you add Cloudflare, set its SSL/TLS mode to Full (strict), so the connection from Cloudflare to your server is encrypted and verified too.

6. Forms, logins and spam

Contact forms and login pages attract bots. This is how our contact form deals with them, cheapest check first:

  • A honeypot: a hidden field people never see. Bots that fill in every field give themselves away.
  • A signed timestamp: anything submitted within a few seconds of the form loading, or hours later, is turned away. It's signed, so a bot can't fake it.
  • A rate limit on how many messages one IP address can send in an hour.
  • A content score for links, markup, sales phrases and disposable email domains.

Anything the rate limit or content score catches goes into a spam folder rather than being deleted, so a genuine enquiry can be recovered, and the sender sees the normal confirmation, so a bot learns nothing about which check it failed. Real visitors never have to solve a puzzle. For logins, slow down or lock out repeated failed attempts, and put 2FA on every admin account.

7. Payments: keep card details off your server

If you sell online, use a hosted payment page such as Stripe Checkout, so card numbers go straight to the payment provider and never touch your server. That usually keeps your PCI DSS paperwork to the shortest self-assessment questionnaire, SAQ A. Our guide to integrating payments securely in an online store covers the options.

8. Monitoring, and a plan for a bad day

Set up uptime monitoring with alerts, keep error logs, and watch for sudden spikes in traffic or failed logins. Write down who to call and where the backups are. If personal data is exposed, UK GDPR requires you to report it to the ICO within 72 hours of becoming aware of it, unless it's unlikely to put anyone at risk, so name the person who makes that call in advance. Our website maintenance plans include daily off-site backups, security patching and uptime monitoring if you'd rather hand this part to us.

Web hosting security checklist

Whoever hosts your site controls a lot of the above. Before you sign up, or at renewal, check that the host:

  • patches the operating system, web server and database, or says clearly that it's your job;
  • isolates your site from other customers' sites;
  • supports TLS 1.2 and 1.3, with certificates that renew automatically;
  • takes off-server backups that you can restore yourself;
  • offers 2FA on your account, and SSH keys rather than passwords for server access;
  • keeps secrets such as database passwords in environment variables, not in your code;
  • gives you access logs, and has DDoS protection or works well behind Cloudflare.

What our site actually sends

This is the response from our own homepage on 25 September 2026, fetched with curl -sI https://customcodedwebsites.com/ and trimmed to the security-relevant lines:

HTTP/2 200
cross-origin-opener-policy: same-origin
referrer-policy: same-origin
server: cloudflare
strict-transport-security: max-age=31536000; includeSubDomains; preload
x-content-type-options: nosniff
x-frame-options: DENY
HeaderWhat it doesWhere ours comes from
Strict-Transport-SecurityBrowsers use HTTPS only for the next year, subdomains includedOur SECURE_HSTS_* settings
X-Content-Type-Options: nosniffStops browsers guessing file types, which blocks some script injection tricksDjango default
X-Frame-Options: DENYStops other sites loading the page in a frame (clickjacking)Django's clickjacking middleware, default DENY
Referrer-Policy: same-originOther sites aren't told which page a visitor came fromDjango default
Cross-Origin-Opener-Policy: same-originCuts the link between the page and windows opened from other sitesDjango default

The CSRF cookie set by our contact page came back with HttpOnly, Secure and SameSite=Lax set.

The header that was missing

Content-Security-Policy isn't in that list, and it should be. It limits which scripts a page may run, which makes it the best backstop against cross-site scripting. Our settings file defines a full policy using django-csp's old CSP_* settings, but django-csp 4.0 replaced those with a single CONTENT_SECURITY_POLICY dictionary and ignores the old names. The package does have a check that flags old settings, but it only runs when "csp" is in INSTALLED_APPS. Ours wasn't, so nothing warned us: the middleware found no policy and quietly sent no header. We confirmed the cause locally with Django's test client.

The new format looks like this. Start in report-only mode, which reports violations in the browser console without blocking anything, fix what it flags, then switch to CONTENT_SECURITY_POLICY to enforce it:

from csp.constants import NONCE, NONE, SELF

INSTALLED_APPS += ["csp"]  # so django-csp's own system checks run

CONTENT_SECURITY_POLICY_REPORT_ONLY = {
    "DIRECTIVES": {
        "default-src": [SELF],
        "script-src": [SELF, NONCE, "https://www.googletagmanager.com"],
        "object-src": [NONE],
        "base-uri": [SELF],
        "form-action": [SELF],
        "frame-ancestors": [NONE],
    },
}

On Django 6.0 and later you don't need the package: CSP is built in, through ContentSecurityPolicyMiddleware and the SECURE_CSP setting.

One more finding: our HSTS header includes preload, but hstspreload.org lists the domain as not preloaded. The directive only makes a domain eligible; you still have to submit it, and removal takes months. The lesson from both: a settings file tells you what you asked for, not what a browser receives. Check the live site.

Developer checklist: the Django settings we use

These are excerpts from this site's settings.py. DEBUG comes from the environment and defaults to off, and the production block applies only when it is off:

DEBUG = config('DJANGO_DEBUG', default=False, cast=bool)

# Production Security Settings
if not DEBUG:
    SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')

    # HSTS (HTTP Strict Transport Security)
    SECURE_HSTS_SECONDS = 31536000  # 1 year (recommended for production)
    SECURE_HSTS_INCLUDE_SUBDOMAINS = True
    SECURE_HSTS_PRELOAD = True  # Required for HSTS preload list

    SECURE_SSL_REDIRECT = True

    # CSRF settings for production
    CSRF_COOKIE_SECURE = True
    CSRF_COOKIE_HTTPONLY = True
    SESSION_COOKIE_SECURE = True
    SESSION_COOKIE_HTTPONLY = True
  • SECURE_PROXY_SSL_HEADER is there because HTTPS ends at the proxy in front of the app, so Django sees plain HTTP. Without it, SECURE_SSL_REDIRECT sends every request round an endless redirect loop. Only set it if your proxy always sets or strips X-Forwarded-Proto; otherwise anyone can claim to be on HTTPS.
  • Start HSTS small. A one-year max-age with includeSubDomains makes any subdomain without HTTPS unreachable for browsers that have seen the header. Try an hour first, then raise it.
  • Read the secret key from the environment, with no fallback in the code. A missing variable should stop the app starting, not quietly run it with a key that sits in your repository. Ours used to fall back to a django-insecure- key from the project template, which is exactly what this rule is there to catch. We have since removed that fallback: production now refuses to start without DJANGO_SECRET_KEY, and only local development, with DEBUG on, generates a throwaway key instead.
  • SECURE_BROWSER_XSS_FILTER does nothing. Our settings still set it, left over from an older template, but Django removed the setting in 4.0 and browsers have dropped the X-XSS-Protection filter it used to switch on. CSP is the replacement.

Run manage.py check --deploy

Django has a deployment checklist built in. We ran it against this site's code with DJANGO_DEBUG=False and a freshly generated DJANGO_SECRET_KEY in the environment:

$ python manage.py check --deploy
System check identified no issues (0 silenced).

Run with development settings (DEBUG on), the same code produced five warnings, and a key starting django-insecure-, as generated by startproject, adds a sixth:

WarningWhat it meansFix
security.W004No HSTSSECURE_HSTS_SECONDS
security.W008No HTTP to HTTPS redirectSECURE_SSL_REDIRECT = True, or redirect at the proxy
security.W012Session cookie can travel over plain HTTPSESSION_COOKIE_SECURE = True
security.W016CSRF cookie can travel over plain HTTPCSRF_COOKIE_SECURE = True
security.W018DEBUG is onDEBUG = False
security.W009Weak or default secret keyA long random key from the environment

Run it in your deploy pipeline with production environment variables set, but know its limits: it checks Django's settings, not your proxy, your CDN or what the browser receives. It passed our site cleanly while the CSP header was missing.

Audit your dependencies

Every package you install is code you're trusting, and new advisories appear for versions you installed months ago. Check them before each deploy and on a schedule:

pip install pip-audit
pip-audit -r requirements.txt

# JavaScript projects
npm audit --omit=dev

pip-audit exits with a non-zero status when it finds a known vulnerability, so it can fail a CI build. Turn on Dependabot alerts in GitHub as well, and pin exact versions so what you audited is what you deploy.

Admin accounts, logins and queries

  • Put 2FA on the admin. Django doesn't include it, but django-otp adds one-time codes from an authenticator app to the standard admin.
  • Moving the admin URL cuts noise, not risk. A non-default path reduces automated login attempts, but 2FA and rate limits are what protect it.
  • Rate-limit logins and forms, in the app (django-axes locks out repeated failed logins) or with a Cloudflare rate limiting rule.
  • Get the client IP right behind a proxy. The first address in X-Forwarded-For is whatever the client sent, so a per-IP limit keyed on it can be dodged. Behind Cloudflare, use CF-Connecting-IP, and only trust it if requests can reach your server through Cloudflare alone.
  • Let the ORM build your SQL. Django's ORM passes values as query parameters, which prevents SQL injection. The risk returns with raw(), extra() or SQL built with string formatting.
  • Publish a security.txt at /.well-known/security.txt (RFC 9116), so anyone who finds a problem knows how to report it.

Extra checks for WordPress and WooCommerce

If your site runs on WordPress, everything above applies, plus:

  • Turn on automatic updates for core and for plugins you trust, and delete, not just deactivate, the plugins and themes you don't use.
  • Replace plugins that haven't been updated in over a year.
  • Add define( 'DISALLOW_FILE_EDIT', true ); to wp-config.php, so a stolen admin login can't edit theme and plugin code from the dashboard.
  • Limit login attempts, add 2FA, and never use "admin" as a username.
  • For WooCommerce, use a hosted payment method so card details never pass through the site.

How often to run through it

  • Every deploy: dependency audit and manage.py check --deploy.
  • Monthly: apply updates, check the backups ran, review who has admin access.
  • Quarterly: restore a backup to a test site, re-check headers and TLS from outside, and confirm your software is still supported.
  • Yearly, or after major changes: an outside review or penetration test, especially if the site takes payments or stores personal data.

Security is never finished, but most of this list is a one-off job followed by a few minutes a month. If you'd like a second pair of eyes on your site, book a free call and we'll look at it with you.

Share this post: