Every client conversation eventually gets to the same question, asked in a dozen different ways: "is it going to be secure?" The honest answer is that security isn't a feature you add — it's a set of defaults you don't skip. So instead of answering that question fresh every time, I just ship the same baseline on every project, whether the client asked for it or not. This is that baseline.
None of this is exotic. It's the boring, well-understood stuff that stops the overwhelming majority of real-world attacks: header hardening, rate limiting, authentication done correctly, parameterized queries, and secrets that never touch a git diff. The interesting part isn't any single control — it's that all five go in before the first feature branch, not during a pre-launch security pass that gets cut for time.
Why baseline, not checklist
A checklist implies something you run through once, near the end, and then move on from. That's how vulnerabilities ship — not because nobody knew about CSRF or SQL injection, but because the audit got scheduled for "after launch" and launch day arrived first. Baking these controls into the project skeleton means there's no point in the timeline where the app exists without them. There's no "secure version" to migrate to later, because the insecure version never exists.
1. Headers: Helmet, configured, not just installed
Helmet.js ships sane defaults for Express, but the defaults are a starting point, not a finished job. The two settings I always revisit are the Content-Security-Policy and Strict-Transport-Security directives — a CSP that's actually scoped to the app's real script, style, and connect sources, and HSTS with a long max-age once the domain is fully on HTTPS.
import helmet from "helmet";
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "https://static.cloudflareinsights.com"],
connectSrc: ["'self'", "https://api.rushingtechnologies.com"],
imgSrc: ["'self'", "data:"],
objectSrc: ["'none'"],
},
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
})
);
A default-permissive CSP is barely better than no CSP. The point is to name every legitimate source explicitly and let the browser reject everything else, including the inline script some future contributor will be tempted to paste in.
2. Rate limiting on anything that costs money or trust
Login endpoints, password resets, contact forms, anything that sends an email or hits a paid API — all of it gets a rate limiter in front of it before it ships, not after the first bot finds it. The goal isn't to stop a determined attacker outright; it's to make brute force and abuse expensive enough that they go elsewhere.
import rateLimit from "express-rate-limit";
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { ok: false, error: "too_many_requests" },
});
app.post("/auth/login", authLimiter, loginHandler);
On Cloudflare Workers I lean on the platform's own rate limiting bindings instead of an in-process limiter, since a single Worker instance has no shared memory to count against across requests. Same principle, different mechanism — the binding lives in front of the handler either way.
3. JWT done right, which mostly means done narrowly
Most JWT problems aren't cryptographic — they're scope creep. Tokens that never expire. Tokens that double as both access and refresh credentials. Tokens stored in localStorage where any injected script can read them. The fix isn't a fancier signing algorithm, it's tighter discipline:
- Short-lived access tokens (15–30 minutes), separate long-lived refresh tokens, rotated on use.
- Refresh tokens in an
httpOnly,Secure,SameSite=Strictcookie — never in JS-accessible storage. - Signature verification on every request, with the algorithm pinned server-side so a token can't downgrade itself to
alg: none. - A real revocation path — a denylist or token version field — so a stolen refresh token doesn't stay valid indefinitely.
4. Parameterized queries, no exceptions
String-concatenated SQL doesn't appear in new code I write — not because I'm disciplined about catching it in review, but because the only query interface available is parameterized. Whether that's better-sqlite3, a D1 prepared statement, or an ORM, the raw string-building path simply isn't reachable from application code.
// D1 / Cloudflare Workers
await env.DB.prepare(
`SELECT id, project, rating FROM feedback_entries WHERE id = ?`
).bind(feedbackId).first();
"No exceptions" is doing real work in that sentence. The moment a project has one trusted dynamic query "because it's just an admin tool," that's the one an attacker finds.
5. Secrets locked down, tight
API keys, signing secrets, and database credentials live in the platform's secret store — Wrangler secrets, environment variables injected at deploy time, a vault — never in a committed .env, never in client-side JavaScript, and never typed into a chat or a ticket if it can be avoided. If a credential is ever exposed outside that boundary, even briefly, the right response is to rotate it, not to hope nobody noticed.
# set once, never committed
wrangler secret put SEND_EMAIL_API_KEY
What this baseline doesn't claim to do
None of this replaces a real penetration test for anything handling sensitive data at scale, and none of it is a substitute for dependency scanning or keeping frameworks current. It's the floor, not the ceiling — the set of defaults that should exist before anyone starts arguing about what additional hardening a specific project needs. Most client projects never need more than this floor. The ones that do, at least start from solid ground.
// want this on your project
This is the default on everything I build — business sites, web platforms, mobile apps, and SaaS products alike. If you're starting a build or auditing an existing one, let's talk.
transmit_message