Most security problems in small business systems are not the work of clever attackers. They are ordinary mistakes that happen to be reachable from the internet: a page that checks whether you are logged in but not whether the record is yours, an upload form that accepts a script, a password in the repository, an administrator account with a weak password and no second factor.
These are cheap to find before launch and expensive after it. This article is the review I go through before a system goes live, in roughly the order that matters most.
Access: who may see what
The most common serious fault is not a missing login. It is a missing check on who owns what. A customer logs in, opens their order at an address ending in its number, changes the number, and sees someone else’s order.
The rule that prevents it is deny by default: every request is refused unless a rule explicitly allows it, and the rule is checked on the server, for the specific record, every time.
- Checking that someone is logged in is authentication. Checking that this person may see this record is authorisation. Both are needed.
- Hiding a button in the interface is not a check. The request behind the button must be refused on the server.
- Sequential numbers in addresses are fine as long as the check is there. They just make its absence easy to find.
The practical test: log in as one customer, copy the address of one of their records, log in as another customer, and open it. It must fail.
Sessions and cookies
A session is how the system remembers who you are between requests, usually with a cookie. The cookie should be:
HttpOnly, so scripts on the page cannot read itSecure, so it is only sent over HTTPSSameSite, so other sites cannot make your browser send it along with their requests
Logging out must end the session on the server, not just delete the cookie. Sessions should expire, and a password change should end the other sessions of that account.
Passwords and two-factor login
- Store passwords only as slow, salted hashes, with an algorithm made for passwords, such as bcrypt or Argon2. Never reversibly, never with a fast general-purpose hash.
- Limit login attempts, per account and per address, so passwords cannot be guessed at speed.
- Offer two-factor login, and require it for administrators. An administrator account is the most valuable thing an attacker can get, and a second factor turns a stolen password into a useless one.
- Password reset links should expire quickly, work once, and not reveal whether an e-mail address has an account.
File uploads
Anywhere users can upload a file is a place someone will try to upload something harmful. A safe upload:
- checks the file’s real type from its content, not from its name
- limits its size
- stores it under a new, random name, never the name the user gave it
- stores it where the web server will not execute it, or in separate storage altogether
- serves private files only after checking that the person asking may see them
An upload that ends up executable on the server is one of the fastest ways from a small mistake to a complete compromise.
Forms and input
- Validate every input on the server. Validation in the browser is for convenience; it can be skipped.
- Use parameterised queries, never text glued into SQL. Modern tools do this by default; handwritten queries are where it goes wrong.
- Escape everything shown back to users, so a name that contains a script is shown as text.
- Protect forms that change something against cross-site requests.
- Put public forms, contact, sign-up, password reset, behind rate limits, so they cannot be used to send thousands of e-mails.
Secrets
Passwords, API keys and signing keys do not belong in the code. Every copy of the repository carries whatever is in it, including its history.
- Keep secrets in environment variables or a secrets manager, per environment.
- Search the repository and its history for anything that looks like a key before launch. If one is found, change the key; removing it from the code is not enough.
- Give each service the smallest access it needs: the database user that serves the website does not need to drop tables.
Dependencies
Most of the code in a modern application was written by someone else, in libraries. Known vulnerabilities in them are published, and tools can check for them:
npm audit # Node.js
composer audit # PHP
pip-audit # PythonRun the check before launch and regularly after it, and decide about each finding: many are in code the application never uses, some are real.
Headers and error pages
A few HTTP headers close whole categories of attack at little cost:
Strict-Transport-Security, so browsers only ever use HTTPS for the siteContent-Security-Policy, which limits where scripts may come fromX-Content-Type-Options: nosniffand a sensibleReferrer-Policy- a frame policy, so the site cannot be embedded by others to trick users into clicks
Error pages should show a friendly message to users and keep the details, stack traces and query text, in the logs.
After launch
Security does not end at launch. Two things make the difference when something does go wrong:
- Logging and an audit trail: who logged in, who changed what, and failed attempts, kept long enough to look back at.
- Backups that have been restored at least once, so a mistake or an attack is a bad day rather than the end of the data.
An hour of threat modelling first
Before the checklist, an hour spent on one question pays for itself: what would hurt most if it went wrong? For most business systems the answers are the same handful:
- someone seeing other customers’ personal data
- someone changing prices, orders or payments
- someone taking over an administrator account
- the data being lost or encrypted by ransomware
- the system being used to send spam or attack others
For each one, ask how it could happen and what already stops it. The answers tell you where to spend the rest of the review, and they are a better guide than any generic list, including the one below.
The admin area
The administrator area deserves more protection than the rest of the site, because it can do everything:
- Two-factor login for every administrator, without exceptions.
- Roles with the least access each person needs. An editor does not need to manage users; the person who packs orders does not need the settings.
- Sessions that expire sooner than customers’ sessions do.
- An audit log of what each administrator changed.
- Where practical, the admin on its own address, which makes it easy to add further protection later, such as limiting it to known networks.
Rate limits
Rate limiting stops one visitor from making a thousand requests where a person would make ten. It protects logins from password guessing, forms from abuse and the whole site from being overwhelmed by a single misbehaving client.
The useful limits are per purpose: a tight limit on login attempts per account and per address, a moderate one on forms that send e-mails, and a generous one on ordinary browsing. When a limit is hit, the answer should say so and say when to try again, rather than failing mysteriously.
A Content Security Policy, step by step
A Content Security Policy tells the browser where scripts, styles and images may come from. It is the strongest protection there is against injected scripts, and also the easiest header to get wrong. Start in report-only mode, which reports violations without blocking anything, then tighten it once you know what the site really loads:
Content-Security-Policy-Report-Only:
default-src 'self';
script-src 'self';
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline';
frame-ancestors 'none';
report-uri /csp-reportEvery third-party script the site uses has to be listed. That is useful in itself: it forces a decision about each one.
What to log, and what not to
Logs are how you find out what happened after the fact. Log what someone investigating an incident would need: logins and failed logins, changes to permissions, administrator actions, access denied, and errors. Do not log what an attacker would want to find in them: passwords, full card numbers, tokens, or more personal data than the investigation needs. And keep logs somewhere an attacker who gets into the application cannot quietly delete them.
Personal data
Security and privacy overlap. Data you do not collect cannot leak, and data you delete when it is no longer needed cannot leak later. Before launch, list the personal data the system holds, why, and for how long. Make sure a user’s data can be exported and deleted when they ask, including from the parts of the system nobody thinks of, such as logs, backups and outside services the data was sent to.
When a penetration test is worth it
A professional penetration test, someone paid to attack the system, is valuable for systems that hold sensitive data or money, and for customers who require it. It is most useful after the basics in this article are done: paying specialists to find a missing ownership check is expensive. Do the review first, fix what it finds, then let someone try to prove you wrong.
The first hour of an incident
Something will go wrong eventually. Deciding in advance what happens in the first hour turns panic into a list:
- Who is told, and how, including out of hours.
- How to take the system offline or into maintenance, if needed.
- How to revoke sessions and change keys quickly.
- Where the logs and backups are, and who can reach them.
- Who decides whether customers or authorities must be informed, and within what time. Under data-protection law in the EU, a personal data breach must usually be reported to the authority within 72 hours.
The packages you did not write
Modern applications install hundreds of third-party packages, and each package is written and published by someone else. A few habits reduce the risk that one of them turns against you:
- Lock exact versions, with the lock file committed, so the build installs what was tested and not whatever was published this morning.
- Prefer well-maintained packages with many users over obscure ones that do one small thing.
- Review what a new dependency pulls in before adding it. Sometimes a few lines of your own code are safer than a package with fifty of its own dependencies.
- Watch for alerts about the packages you use, and update promptly when a real vulnerability is published.
E-mail that cannot be faked
E-mail sent by your system, order confirmations, password resets, invoices, can be forged by others unless the domain says who may send on its behalf. Three DNS records do that: SPF lists the servers allowed to send, DKIM signs each message so it can be verified, and DMARC tells receiving servers what to do with messages that fail the checks. Without them, your real e-mails are more likely to land in spam, and fake ones in your customers’ inboxes.
[ ] Every record checked for ownership on the server
[ ] Session cookies HttpOnly, Secure, SameSite
[ ] Logout and password change end sessions
[ ] Passwords hashed with bcrypt or Argon2
[ ] Login attempts rate-limited
[ ] Two-factor login required for administrators
[ ] Uploads: type checked, size limited, renamed, not executable
[ ] Inputs validated on the server, queries parameterised
[ ] Public forms rate-limited
[ ] No secrets in the repository or its history
[ ] Dependency audit run and findings decided
[ ] HSTS, CSP and friends set
[ ] Error pages show no internals
[ ] Audit log on, backups restored onceLaunching soon?
Tell me what the system does and who uses it.
