Backend, data, and security
The server side, done safely.
APIs and the backend
7 min readYour API will get calls from your app, and also from scripts, bots, browser extensions, and people poking at it with a tool like curl. None of those callers run your frontend code, so none of them are bound by whatever checks you put there. The server is the only place a check actually holds.
Never trust the client
Client-side validation is a convenience for the honest user, catching a typo before a request even goes out. It is not a security boundary. Anyone can open devtools, edit the request, and send whatever they want straight to your endpoint. If a rule matters, enforce it again on the server.
- Validate every input on the server: type, range, required fields, and length, regardless of what the client already checked.
- Sanitize anything that reaches a database query, a shell command, or a template, so injection is not left as an open door.
- Cap list sizes and payload sizes, so one request cannot ask for the entire table at once.
- 1Request arrivesfrom your app, a script, or anyone else
- 2Server validates inputtype, range, required fields, no client trusted
- 3Business logic runsonly on input that passed validation
- 4Response returnedsane status code either way
Errors that do not leak
When something goes wrong, the caller still needs a useful, honest response: a real status code and a short, generic message. What it should never get is your stack trace, your internal error string, or a peek at your database schema. That detail belongs in your own logs, not in the response body.
| Leaky | Sane | |
|---|---|---|
| Status code | 200 with an error buried in the body | 4xx or 5xx that actually matches the problem |
| Message | Full stack trace and file paths | "Invalid request" or "Something went wrong" |
| Where the detail goes | Straight to the caller | Your server logs, for you to debug |
Timeouts and retries on outbound calls
Your backend calls other things too: a database, a payment provider, another internal service. Without a timeout, one slow dependency can hang the request that called it, and then every request waiting behind that one. Set a timeout so a stuck call fails fast instead of piling up. Pair it with a small number of retries for calls that fail for a transient reason, so a brief blip does not turn into a user-facing error.
async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`, {
signal: AbortSignal.timeout(5000), // fail fast, do not hang forever
});
if (!res.ok) throw new Error(`upstream returned ${res.status}`);
return res.json();
}- Server-side validation
- Checking input again on the server, even if the client already checked it.
More
The client can be bypassed entirely, so it is not a security boundary. Any rule that matters has to be enforced where the request actually lands.
Data and storage
7 min readCode you can redeploy in seconds. Data is different: it accumulates, it is irreplaceable, and a mistake against it does not roll back on its own. Three habits carry most of the risk out of the database layer.
Schema changes go through migrations
A migration is a small, versioned script that describes one change to the schema: add a column, add a constraint, backfill a value. Run in order, on every environment, it is what keeps your local database, staging, and production in the same shape. Hand-editing a production schema directly skips that record entirely, so nobody, including future you, can tell what changed, when, or how to reverse it.
| Hand edit in production | Migration | |
|---|---|---|
| Repeatable elsewhere | No, it lives only in that one database | Yes, the same script runs everywhere |
| Reviewable | No, nobody sees it before it runs | Yes, it is a file, reviewed like any other code |
| Reversible | Only if you remember what you did | A rollback script can undo it |
Index the queries you actually run
An index lets the database jump straight to the rows a query needs instead of scanning every row in the table. Small tables hide a missing index; the same query gets slower every month as the table grows, until one day it is the reason a page hangs. Do not guess which queries need one: look at the actual query plan for the filters, joins, and sorts your app runs most, and index those.
- Index the columns you filter on (WHERE), join on, and sort on for your highest-traffic queries.
- Check with a query plan (EXPLAIN or equivalent), not a guess, before adding or trusting an index.
- An index has a cost too: it speeds up reads but adds overhead to every write, so index what you query, not every column.
A backup you have actually restored
A backup that has only ever been taken, never restored, is an untested assumption. The only way to know it works is to run the restore, end to end, onto a machine that is not the original, and confirm the data that comes back is actually correct and complete.
- Take a backup on your normal schedule.
- Spin up a separate environment, not the original database.
- Restore the backup into it from scratch.
- Check that the data is complete and correct, not just that the restore command exited without an error.
- Write down how long the whole restore took, since that number is what an incident will actually cost you.
Auth and permissions
7 min readA login screen answers one question: who is this? That is authentication. A completely separate question is what this person is allowed to do once they are in, which is authorization. A lot of real breaches are not a cracked password at all; they are an authenticated, perfectly legitimate user reaching an action or a record nobody checked they should be allowed to touch.
Sessions and tokens that expire, and can be revoked
- Give sessions and tokens a lifetime. One that never expires is one that is still valid years after it leaked.
- Make revocation real: logging out, or an admin killing a session, should actually invalidate it server-side, not just delete a cookie on the client.
- Shorter-lived access tokens paired with a longer-lived, revocable refresh token limit the damage window without forcing a login every few minutes.
Passwords: hashed, never plaintext
Storing a password means storing a way to check it later without storing the password itself. That is what hashing does: it is a one-way function, so there is no key that turns a hash back into the original password. That makes it a different tool from encryption, which is deliberately reversible with a key, and is the wrong tool for password storage. Use a slow, purpose-built password hashing algorithm (bcrypt, scrypt, or Argon2, the last two also memory-hard), with a unique salt per password. A fast general-purpose hash like a bare SHA-256 is built to be quick, which is exactly the wrong property for a password hash: it lets an attacker who steals the database try billions of guesses per second.
- Never store a password in plaintext, and never store it with reversible encryption instead of hashing.
- Hash with a slow, purpose-built, password-specific function, not a fast general-purpose hash.
- Add a unique salt per password, so two identical passwords do not produce identical hashes.
A check on every action
Being logged in answers "who are you," not "should this succeed." Every action that touches a specific record needs its own server-side check that this particular user is allowed to do this particular thing to this particular record, not just that they hold a valid session.
- Check permission on the server for every action, not just once at login.
- Confirm object-level ownership: this user owns this record, not merely that some user is logged in.
- Hiding a delete button in the UI is not a permission check. If the request still reaches the server unguarded, anyone who can craft it can use it.
- Keep admin and privileged routes checked the same way as everything else, since guessing a URL is not a permission barrier.
- Hashing
- A one-way function used to store a password so it can be checked, but never recovered.
More
There is no key that reverses a hash back to the original value. This is different from encryption, which is deliberately reversible with a key, and is not the right tool for storing passwords.
- Authorization
- The check for what a logged-in user is allowed to do, separate from proving who they are.
Security essentials
7 min readNone of the measures below is "security" on its own. Each one closes a specific, well-known class of hole, and skipping several of them at once, quietly, with no visible bug to point at, is how most real incidents actually happen.
| Check | What it closes |
|---|---|
| HTTPS everywhere | Traffic readable or alterable in transit |
| No secrets in the browser | Keys and credentials anyone can read via view-source |
| A real OWASP Top 10 pass | The most common, best-documented web app risks |
| Row-level security | One query bug exposing every user's data at once |
| Dependency scanning | Known vulnerabilities sitting in libraries you did not write |
HTTPS everywhere
HTTPS encrypts traffic between the client and the server, so it cannot be read or quietly altered in transit. Enforce it site-wide, redirect plain HTTP to it, and watch for mixed content: a secure page that still loads one plain HTTP resource undermines the same guarantee it is supposed to provide.
No secrets shipped to the browser
Anything that reaches the browser is public. Anyone can open devtools or view-source and read it, so an API key, a database credential, or an internal URL baked into client-side JavaScript is not hidden, it is published. Secrets belong on the server, read from environment variables, never bundled into what ships to the client.
A real OWASP Top 10 pass
The OWASP Top 10 is a maintained, widely used list of the most common and most serious web application security risks, things like broken access control and injection. Knowing the list exists is not the work. The work is going through it against your actual app, endpoint by endpoint, and confirming each risk on the list does not apply to you.
Row-level security
Application-layer permission checks (from the last lesson) can have bugs: a missing filter, a copy-pasted query. Row-level security (RLS) enforces access rules at the database itself, so even when the app-layer check fails, the database still will not hand back another user's rows. It is a second, independent layer behind the first one, not a replacement for it.
Scan your dependencies
Almost every app runs on a stack of libraries it did not write, and some of them will have a publicly known vulnerability at some point. Scan dependencies for known vulnerabilities and update them on a schedule, not only after something already broke.
- HTTPS enforced everywhere, with no mixed content.
- No API keys, credentials, or internal URLs shipped in client-side code.
- A real pass over the OWASP Top 10 against your actual endpoints.
- Row-level security enabled at the database as a second layer behind app-level checks.
- Dependencies scanned for known vulnerabilities and updated on a schedule.
This lesson is the essentials, not the full list. Before real users show up, walk through the pre-launch checklist: it covers these five in more depth alongside the rest of what breaks in production, from the frontend to disaster recovery.
- Row-level security (RLS)
- Access rules enforced at the database, so a query cannot return rows a user should not see.
More
It acts as a second, independent layer behind your application code's permission checks, so a bug in the app layer alone does not expose another user's data.
- OWASP Top 10
- A maintained, widely used list of the most common and serious web application security risks.
Section 3 quiz
25 questions. Pass at 75% to master this section. Retakes are unlimited, and the quiz is where the learning sticks.
Why is client-side validation not enough on its own?