
Security by Design When You Are the Whole Team
You do not get a security review, a pentest budget, or a second pair of eyes. What you get is the ability to make the cheap decisions early — before they become expensive.
On a client project there is no security team. There is no pentest budget, no threat modelling workshop, and no second pair of eyes on your auth code. There is you, a deadline, and a client who assumes security is included.
It mostly is included — but only if you make a handful of decisions early, while they are still free.
The five minutes that replace a threat model
Before building any feature that touches data, I answer four questions in writing. It takes five minutes and catches most of what a formal review would have caught.
- What is the worst thing someone could do with this feature? Not the likeliest — the worst.
- Who is allowed to do this, and where is that enforced? If the answer is "the UI hides the button", there is no enforcement.
- What crosses a trust boundary? Anything arriving from a browser, a webhook, or a third-party API is hostile until validated.
- What ends up in logs? This is where personal data leaks quietly for years.
Four sentences. Written in the ticket. That is the entire ceremony, and it is enough to stop the expensive mistakes.
Authorisation on the server, always
The most common serious bug I find in small-team codebases is not injection or XSS. It is an endpoint that trusts a client-supplied identifier.
// broken: the client tells you whose data to return
app.get('/api/orders', (req, res) => {
return db.order.findMany({ where: { userId: req.query.userId } })
})
// correct: the session tells you
app.get('/api/orders', requireAuth, (req, res) => {
return db.order.findMany({ where: { userId: req.user.id } })
})
Every list endpoint, every detail endpoint, every update. The identity comes from the session, never from the request. Hiding a button in the interface is a usability decision, not an access control.
Validate at the edge with a schema
Hand-written validation drifts and gets skipped under deadline. A schema on every route body is one line per endpoint and it converts a whole class of bugs into a 400.
const CreateBooking = z.object({
serviceId: z.string().uuid(),
date: z.coerce.date(),
notes: z.string().max(500).optional()
})
The max matters more than it looks. Unbounded string fields are how you end up storing a megabyte of junk in a column that a template later renders.
Secrets never reach the browser
Anything in your frontend bundle is public. Not obscure — public. A VITE_-prefixed variable is shipped to every visitor, and a third-party key sitting there will be found and used.
If the browser needs a capability that requires a secret, the browser gets a server endpoint that uses the secret on its behalf, with rate limiting attached. There is no third option that is not a leak.
Rate limit before you launch, not after
Login, password reset, OTP verification, contact forms, anything calling a paid API. All of them need a limit, and adding one after the abuse starts is a stressful evening.
Per-IP is the floor. Per-account on top of it is what actually stops credential stuffing, since a distributed attack rotates addresses but still targets one account.
Headers are ten minutes of work
Most static hosts let you set response headers in a config file. There is no reason to skip these:
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Add a Content-Security-Policy if the site allows it. Every one of these closes a real attack class for the cost of a few lines in a deploy config.
Dependencies are a decision, not a detail
Every package is code you did not write running with your privileges. Before adding one for a task you could solve in thirty lines, check when it was last updated, how many transitive dependencies it drags in, and whether the maintenance looks alive.
npm audit before every handover. It is imperfect, and it still catches things.
Handover is part of the job
The security of a project after you leave depends on what you left behind. Client-owned credentials, no shared logins, a written note of what to rotate and when, and admin access that does not depend on your personal account.
A small team cannot do everything. It can do the cheap things reliably, and the cheap things prevent most of what actually goes wrong.
The short list
Session-derived identity. Schema validation at the edge. Secrets server-side only. Rate limits on anything abusable. Security headers. Audited dependencies. Clean handover. None of it is advanced, and doing all of it consistently puts you ahead of most projects of this size.
Need this built properly?
I build secure, fast, bilingual platforms for clients across Egypt, Saudi Arabia, the UAE and Kuwait.


