Security advice usually arrives as a list of principles nobody disagrees with and nobody can act on. This is the version we actually work from: what we build into applications, roughly in the order we build it, and where teams most often get hurt.
This post was written in 2024. The fundamentals held up. The AI section did not exist then and is now one of the most common ways new applications get exposed, so it has been added.
Start with authorization, not authentication
Most teams get authentication reasonably right, because it is visible and there are good libraries for it. Authorization is where the real breaches live.
Authentication answers "who are you." Authorization answers "are you allowed to do this specific thing to this specific record." The second question has to be asked on every single request, on the server, every time. Not in the UI, which only hides buttons. Not once at login. On every request.
The most common vulnerability we find in code reviews is an endpoint that checks whether you are logged in but not whether the record you asked for belongs to you. Change the ID in the URL and you are reading someone else's data. It is unglamorous and it is everywhere.
Practical version:
- Check ownership or role on every request that touches a record, in the API layer, not the client.
- Default to deny. New endpoints should be inaccessible until someone explicitly grants access.
- Use row-level security in the database where your stack supports it, so a missed check in application code is not the only thing standing between a user and someone else's data.
- Treat server actions and background jobs as public endpoints, because functionally they are.
Handle secrets like they will leak
Assume any secret in the codebase will eventually end up somewhere public, because that is what keeps happening.
- Environment variables for everything, and never in source control.
- Separate credentials per environment. A staging key that works in production is a production key.
- Rotate on a schedule and immediately on staff changes.
- Scan commits for secrets automatically. A pre-commit hook and a CI check cost an hour to set up.
- Scope keys narrowly. A third-party token with full account access, used for one read endpoint, is a liability with no upside.
Encrypt, but know what you are protecting against
TLS everywhere is table stakes and nobody argues about it. Encryption at rest is more often misunderstood.
Full-disk encryption protects against someone walking off with the hardware. It does nothing about an attacker who has valid application credentials, which is the realistic threat. If you hold genuinely sensitive fields, encrypt those fields specifically, with keys managed outside the database, so a database dump alone is not enough.
And do not store what you do not need. The safest way to protect a Social Security number is to not have one. Every field you collect is a field you are responsible for, indefinitely.
Validate on the server, always
Client-side validation is a user experience feature. It provides zero security, because anyone can send whatever they want directly to your API.
- Validate and parse every input server-side with a schema, and reject anything that does not match.
- Use parameterized queries or a query builder. Manual SQL string concatenation is still the fastest way to a critical vulnerability.
- Escape output by context. Modern frameworks handle most of this, but any place you bypass that protection to render raw HTML deserves a second look.
- Rate limit authentication, password reset, and anything expensive, per account and per IP.
- Set upload size limits, validate file types by content rather than extension, and never serve user uploads from your application's own origin.
Keep dependencies current, and know what you shipped
A modern application is mostly other people's code. The vulnerability that gets you is usually in a package three levels deep that nobody chose directly.
- Automated dependency scanning in CI, with a policy for how fast critical findings get patched.
- Lockfiles committed, always.
- Actually read what you are adding. A package with two maintainers and 40 transitive dependencies is a supply chain decision, not a convenience.
- Keep a dependency inventory so that when the next widely-exploited package lands, answering "are we affected" takes minutes rather than a day.
What is new: securing AI features
This is the section that did not exist in the 2024 version, and it is now where we see the most exposure in new builds.
Prompt injection is the default state of the world. Any text an agent reads can contain instructions. A support ticket, a web page, a PDF, a database field a user controls. Treat all model input as untrusted, exactly as you would a form field, and never let model output alone authorize an action.
Scope tools, not prompts. The security boundary is what the agent is technically able to do, not what you asked it to do in the system prompt. If an agent has a database tool with write access, a sufficiently clever input will eventually find its way to using it. Give each tool the narrowest permission that works, and enforce that in the API, not the instructions.
Keep a human on irreversible actions. Sending money, deleting records, emailing customers, changing permissions. An agent can prepare the action. A person approves it. This is a design decision that also happens to be your best security control.
Watch what goes into context. Retrieval systems have a habit of pulling in documents the requesting user should never see. Apply the same authorization rules to retrieval that you apply to your API, filtered before the model sees anything, not after.
Log everything the agent does. Inputs, tool calls, outputs, and who triggered the run. When something goes wrong you need to reconstruct it, and "the model decided to" is not an audit trail.
We go deeper on how we build these guardrails in practice on our AI development page.
Build the habits, not the audit
Security is not a phase before launch. The practices that work are the boring recurring ones:
- Threat modeling at design time for anything touching money, personal data, or permissions. Twenty minutes of "how would someone abuse this" before writing code beats a penetration test after launch.
- Security review as part of normal code review, with authorization checks on the checklist.
- Dependency and secret scanning running automatically, so nobody has to remember.
- A written incident plan you have actually read, including who to call and how to notify affected users.
- Periodic access reviews. Old accounts and stale credentials accumulate silently.
Compliance follows, it does not lead
GDPR, CCPA, SOC 2, HIPAA. Build the practices above and most of what these frameworks require is already in place, and the audit becomes documentation rather than remediation. Build for the audit first and you get a binder full of policies alongside an application that still has an authorization bug in it.
The one thing worth doing early regardless of framework: know what personal data you hold, where it lives, and how you would delete it for a single user on request. Retrofitting that answer is genuinely painful.
Where this shows up in our work
Most of this lives in the API and data layer, which is where our backend development practice concentrates. Polarity is a reasonable example: passwordless guest checkout, payment handling through Stripe Connect, and enforcement rules where being wrong meant either a customer overcharged or a business unpaid. Nothing about the security work there was exotic. It was the same list above, applied consistently, in a place where mistakes were expensive.
That is generally what good security looks like. Not clever. Consistent.