Building secure apps with clarity
A practical look at writing software that stays maintainable, safe, and easy to evolve — threat modeling, explicit errors, and defense in depth.

Security is not a feature you bolt on at the end; it is a property of how clearly you think about your system. In this article I walk through repeatable patterns that keep software maintainable and safe: knowing your assets, treating errors as an API, enforcing typed contracts at boundaries, and layering defenses so one mistake doesn't become a breach.
1. Start with the assets
Before writing code, map what you are protecting:
- **Data** — user records, payment info, secrets.
- **Credentials** — passwords, tokens, API keys.
- **Availability** — can the service keep working under load or attack?
Tracing how each asset moves through the system shows you exactly where validation, rate-limiting, and authorization must live. Security work then follows the data instead of being sprinkled everywhere.
2. Treat errors as an API
Errors are a contract between your system and its callers. Make them intentional:
- **Internal errors** are logged with context (request id, user id, the failure cause).
- **User-facing messages** are generic — never leak stack traces or SQL.
- **Observability** ties the two together so you can debug without exposing internals.
ts
// Bad: leaks internals to the client
res.status(500).json({ error: dbError.stack });
// Good: generic message, detailed log
logger.error("order.create.failed", { orderId, cause: dbError });
res.status(500).json({ error: "Something went wrong. Please try again." });
3. Enforce typed contracts at boundaries
The edges of your system — HTTP handlers, message queues, third-party calls — are where bad data enters. Validate there, once, with a schema:
ts
import { z } from "zod";
const CreateUser = z.object({
email: z.string().email(),
password: z.string().min(8),
});
app.post("/users", (req, res) => {
const parsed = CreateUser.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error);
// safe to trust req.body here
});
Doing this in one place means the rest of the code can assume valid input.
4. Defense in depth
No single control is enough. Stack complementary ones:
- **Validation** at the boundary (Zod/schema).
- **Authorization** checks centralized in middleware, not scattered in handlers.
- **Rate limiting** to blunt abuse.
- **Monitoring** to notice anomalies early.
If validation has a hole, auth still catches it. If auth misconfigures, rate limiting slows the blast. Each layer shrinks the blast radius of the others.
5. Deployment pitfalls to avoid
- Don't ship internal error details to clients.
- Don't commit secrets — use environment variables and a secrets manager.
- Don't disable security headers to "make it work."
- Don't skip dependency updates; known CVEs are the easiest attacks.
Checklist
- [ ] Assets identified and traced through the system
- [ ] Errors separated into logged vs user-facing
- [ ] Boundaries validated with a schema
- [ ] Auth checks centralized
- [ ] Rate limiting + monitoring in place
Secure software is mostly clear software. When the boundaries, contracts, and failure modes are explicit, security becomes the natural result rather than an afterthought.