I have been writing fullstack apps for about ten years now. I am Filipino, I live in Norway, and the only thing that changes between summer and winter here is the lighting in the room where I debug. Over those ten years I have developed one strong, slightly grumpy opinion: most backend security incidents are not clever. They are boring. Somebody forgot a body limit. Somebody left CORS on * with credentials because a tutorial said to. Somebody wrote fetch(req.body.url) and never thought about where that URL could point.
This used to be a “junior developer” problem. Now it is everyone’s problem, because most backend code is no longer typed by a human at all. You describe an app, an agent installs forty dependencies, writes the routes, runs the tests, and opens a PR. The code works on the happy path. It returns 200. It deploys within the hour. And that is exactly the trap.
A backend that boots and returns 200 feels finished. It is not finished. It is just not complaining yet.
This post is about a specific idea: that the safe path should be the default path, and the dangerous things should be the ones you have to consciously switch on. I will use a TypeScript framework called DaloyJS to make the point concrete, because it is the framework I have been building around this exact philosophy. I will also be honest about where it does not save you, because a security post that only lists wins is marketing, and you can smell marketing from three screens away.
The Median Express App
Ask any code assistant for “a Node API with Express that has a couple of routes and calls another service.” You will get something close to this. I am not picking on Express here. Express is fine. I am picking on the defaults that everyone copies without reading.
import express from "express";
import cors from "cors";
const app = express();
app.use(express.json()); // no size limit
app.use(cors()); // reflects any origin
app.get("/books/:id", (req, res) => {
res.json({ id: req.params.id, title: `Book ${req.params.id}` });
});
app.post("/fetch-cover", async (req, res) => {
const r = await fetch(req.body.url); // SSRF speedrun
const buf = await r.arrayBuffer();
res.set("content-type", r.headers.get("content-type"));
res.send(Buffer.from(buf));
});
app.post("/books", (req, res) => {
// no auth, no validation, trusts req.body shape
db.insert(req.body);
res.json(req.body);
});
app.listen(3000);
This is not a strawman. This is the median. Let me walk through what is wrong, because the list is longer than people expect and almost none of it shows up in a passing test.
express.json() with no limit will happily buffer a multi-megabyte body into memory. Send a few hundred of those at once and you have a denial of service that costs the attacker nothing. cors() with no options reflects the request origin, which combined with credentials is the cross-origin equivalent of leaving your keys in the door. The /fetch-cover route is a textbook Server Side Request Forgery: pass http://169.254.169.254/latest/meta-data/iam/security-credentials/ and on a lot of cloud setups you just exfiltrated the instance’s credentials. The /books route trusts req.body completely, so prototype pollution through __proto__ is on the menu, and there is no schema saying what a book even is. There is no rate limit. A request for DELETE /books/:id returns 404 instead of a real 405, which quietly tells scanners that the route does not exist when it does. And when something throws, Express in its default error handler will cheerfully send a stack trace to the client in a lot of configurations.
None of that fails a test. The test posts a book, gets a book back, goes green. The agent reports success. Everybody moves on. The vulnerabilities ship to production wearing a little green checkmark.
Safe by Default, Not Careful by Habit
Here is the philosophy I keep coming back to. There is a great line from a write-up by the Supabase and Aikido folks that I think about constantly: “If you tell an AI to make something work, it might remove the very security checks that protect you.” That risk is not unique to AI. Humans do it too, at 2am, when a test is red and the deploy window is closing. The difference is that an agent does it faster and without the small voice in the back of its head that says “wait, why was that check there?”
So the fix is not “be more careful.” Being more careful does not scale to a world where the code is written by something that does not feel fear. The fix is to make the framework’s defaults safe, so that “make it work” and “make it safe” are the same action. You should have to go out of your way to be insecure. The dangerous knobs should be off until you deliberately turn them on, and ideally the framework should refuse to start at all when your configuration is obviously a foot-gun.
That is the whole pitch. Let me show you what it looks like in practice.
The Same Surface in DaloyJS
Here is roughly the same surface in DaloyJS. Read the constructor first, because that is where the interesting part lives.
import { z } from "zod";
import {
App,
NotFoundError,
bearerAuth,
cors,
rateLimit,
requestId,
secureHeaders,
} from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";
const BookSchema = z.object({ id: z.string(), title: z.string() });
const app = new App({
bodyLimitBytes: 64 * 1024, // hard cap, streamed, Content-Length checked first
requestTimeoutMs: 5_000, // slow-loris and hung-handler protection
openapi: { info: { title: "Bookstore API", version: "1.0.0" } },
docs: true, // mounts GET /docs, /openapi.json, /openapi.yaml
})
.use(requestId()) // cryptographic correlation id per request
.use(secureHeaders()) // CSP, nosniff, frame-ancestors, COOP/CORP
.use(cors({ origin: "https://app.example.com", credentials: true }))
.use(rateLimit({ windowMs: 60_000, max: 120 }))
.route({
method: "GET",
path: "/books/:id",
operationId: "getBookById",
request: { params: z.object({ id: z.string() }) },
responses: {
200: { description: "Found", body: BookSchema },
404: { description: "Not found" },
},
handler: async ({ params }) => {
const book = books.get(params.id);
if (!book) throw new NotFoundError(`No book ${params.id}`);
return { status: 200 as const, body: book };
},
})
.route({
method: "POST",
path: "/books",
operationId: "createBook",
auth: { scheme: "bearer" },
hooks: bearerAuth({ validate: (t) => t === process.env.TOKEN }),
request: { body: BookSchema }, // unknown keys rejected, body validated
responses: {
201: { description: "Created", body: BookSchema },
401: { description: "Unauthorized" },
422: { description: "Validation error" },
},
handler: async ({ body }) => {
books.set(body.id, body);
return { status: 201 as const, body };
},
});
serve(app, { port: 3000 });
A few things happened here that you did not have to ask for. The body is capped at a hard limit and read as a stream, with Content-Length checked before a single byte is buffered. There is a request timeout, so a handler that hangs gets aborted instead of holding a connection open forever. The JSON parser strips __proto__, constructor, and prototype through a reviver, so prototype pollution through the request body is closed by default rather than by a library you remembered to add. A request for an undeclared method returns a real 405 with an Allow header. Errors come back as RFC 9457 problem+json, and in production mode the detail field on 5xx responses is stripped automatically so you are not leaking internals to whoever is poking at your API.
The request: { body: BookSchema } line is doing double duty. It validates the incoming body and rejects unknown keys, and it is also the single source of truth that generates your OpenAPI document and, if you want it, a fully typed client. You write the shape once. You do not write it again in a validator, again in the docs, and again in the frontend types. The contract and the validation cannot drift apart — that is a security property, not just a developer-experience nicety.
Misconfiguration That Crashes at Boot, Not at Runtime
The part I want to dwell on is what happens when you misconfigure this thing.
My favorite category of security feature is the one that turns a silent runtime vulnerability into a loud startup crash. A vulnerability you ship is expensive. A crash on pnpm start in CI is free. So DaloyJS refuses to boot in a few specific situations where the configuration is almost certainly a mistake.
Wildcard CORS with Credentials
Reflecting every origin while also sending credentials is one of those things that works perfectly in development and quietly exposes every state-changing route cross-origin in production. So in production mode, a wildcard origin is refused outright:
// In production this throws on construction, it does not start:
app.use(cors({ origin: "*", credentials: true }));
// Error: cors({ origin: "*" }) refused in production: a wildcard CORS origin
// exposes every state-changing route cross-origin.
You cannot deploy this by accident. You either give it a real allowlist or it does not run. That is the right tradeoff. I would much rather get paged about a deploy that would not start than read about my own incident on a Monday.
Weak Secrets
If you use a session or any subsystem that needs a secret, and you hand it a placeholder, a too-short value, or a single repeated character, it refuses to start and tells you exactly how to fix it:
import { session } from "@daloyjs/core";
// All of these refuse-to-boot in production:
session({ secret: "changeme" }); // well-known placeholder
session({ secret: "short" }); // below the minimum byte length
session({ secret: "aaaaaaaaaaaaaaaa" }); // single repeated character
// The error literally suggests the fix:
// session(): production secret is too short (5 bytes; require >= 32).
// Generate one with `openssl rand -base64 48` and load it from an env var.
I cannot count the number of breaches that trace back to a secret somebody meant to change later. “Later” is where security goes to die. Making the framework reject the obviously-fake secret means the lazy path and the safe path point in the same direction.
Unauthenticated State-Changing Routes
If you stand up a stateful endpoint that changes server state but has no authentication, in production, the app refuses to boot. The classic version of this is an unauthenticated /metrics endpoint or a health probe that leaks internals, or a state-changing route mounted with no guard at all. The framework’s position is that an anonymous, state-changing, public route in production is more likely a mistake than a deliberate choice, so you have to be explicit:
// Refuses in production unless you give it a token:
app.metrics();
// app.metrics() refused in production: provide opts.token to require auth.
app.metrics({ token: process.env.METRICS_TOKEN }); // explicit, fine
Proxy Trust
The last misconfiguration in this family is about proxies. If your app runs behind a load balancer or reverse proxy and you trust forwarded headers without telling the framework which proxies are allowed, you open yourself up to IP spoofing via X-Forwarded-For. DaloyJS requires you to declare your proxy trust level explicitly in production rather than silently accepting forwarded headers from anywhere.
The Point
The goal here is not a framework that makes you feel safe by hiding complexity. It is a framework where the path of least resistance leads somewhere that does not end in an incident review. AI agents, sleep-deprived humans, and interns copying Stack Overflow answers all share one trait: they follow the happy path. If the happy path is also the secure path, you get security for free on every one of those cases. The only way to be insecure is to fight the framework, which at least means you made a deliberate choice.
That is worth more than any linter rule, any security checklist, and any amount of “be more careful” advice — because it works even when nobody is being careful at all.