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.
Exhibit A: the backend your assistant just wrote
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.
The actual thesis: invert the default
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 app, with the defaults flipped
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. I will come back to why that matters for security specifically, because “the contract and the validation cannot drift apart” is a security property, not just a developer-experience nicety.
The part I want to dwell on, though, is the part you cannot see in a screenshot: what happens when you misconfigure this thing.
Refuse to boot: the feature I am most attached to
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.
The first one is CORS. 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.
The second one is 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.
The third one is subtle and I am proud of it. 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 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, fineThe last one in this family is about proxies. If your app runs behind a load balancer and reads X-Forwarded-For to decide client IPs (for rate limiting, for logging, for geo-blocking), then an unconfigured trust setting is a spoofing vulnerability: anyone can send a fake X-Forwarded-For and bypass your per-IP limits. So in production, if a request arrives with X-Forwarded-* headers and you have not told the app how to trust proxies, it refuses to play along. You configure the trust boundary explicitly or you do not get to read those headers.
The theme across all four is the same. The framework assumes that an insecure configuration in production is a bug, not a preference, and it makes you prove otherwise. For AI-generated code this is enormous, because an agent will not feel uneasy about a wildcard CORS the way an experienced developer might. The agent does not get a bad feeling. The boot guard does not need one.
SSRF: the attack surface everyone forgets, right when it got more dangerous
Let me go back to that /fetch-cover route, because outbound fetch is the security hole I see ignored the most, and it is getting worse, not better.
Here is the thing about the agent era: backends now make outbound HTTP calls constantly, and increasingly to URLs that came from somewhere untrusted. A user pastes a link to summarize. A webhook subscription points at a URL the customer chose. An “agent tool” fetches a page on demand. Every one of those is a place where an attacker can hand you a URL that points inward, at your own cloud metadata endpoint, at an internal admin service, at localhost, at a database that trusts the network. The classic Capital One breach was, at its heart, an SSRF that reached the metadata service. That pattern did not go away. We just gave it more entry points.
The naive fetch(url) does nothing to stop this. So DaloyJS ships a guarded fetch that is a drop-in replacement and refuses dangerous targets by default:
import { fetchGuard, SsrfBlockedError } from "@daloyjs/core";
const safeFetch = fetchGuard(); // secure defaults, no config needed
app.route({
method: "POST",
path: "/fetch-cover",
operationId: "fetchCover",
request: { body: z.object({ url: z.string().url() }) },
responses: {
200: { description: "Cover bytes" },
400: { description: "Blocked or invalid URL" },
},
handler: async ({ body }) => {
try {
const r = await safeFetch(body.url);
return { status: 200 as const, body: await r.arrayBuffer() };
} catch (e) {
if (e instanceof SsrfBlockedError) {
// The URL pointed somewhere it should not. Refuse, do not retry.
return { status: 400 as const, body: { error: "blocked target" } };
}
throw e;
}
},
});By default fetchGuard() denies loopback (127.0.0.0/8, ::1), the RFC1918 private ranges, link-local including every documented cloud metadata IP (169.254.169.254 for AWS, Azure, and DigitalOcean, plus the AWS ECS and EKS variants), GCP’s metadata.google.internal, Alibaba’s 100.100.100.200, Oracle’s 192.0.0.192, the carrier-grade NAT range, IANA-reserved blocks, multicast, and any scheme that is not http or https. You can opt back into specific things with flags like allowLoopback or allowPrivate when you genuinely need them, but you have to ask.
The detail I care about most is redirect handling, because this is where a lot of SSRF guards fall over. A naive allowlist that only checks the first URL is trivially bypassed: the attacker gives you a public URL that responds with 302 Location: http://169.254.169.254/... and your fetch follows it right into the metadata service. fetchGuard() follows redirects manually and re-validates every hop against the deny rules, and it recursively re-checks IPv4-mapped IPv6 addresses so you cannot smuggle a blocked address through a different notation. That is the kind of thing you only get right if you have been burned before, and it is exactly the kind of thing an agent writing a quick fetch wrapper will not think about.
There is one honest caveat here that I will not hide: a guard at the application layer is defense in depth, not a substitute for locking down the metadata service itself. You should still require IMDSv2 on AWS and block link-local at the VPC or firewall layer. The application guard neutralizes the common case and the lazy attacker. It does not let you skip the infrastructure work. Anyone who tells you a library alone solves SSRF is selling something.
Tokens and the confused deputy
Auth is another place where the dangerous thing is easy and the safe thing requires knowing a specific gotcha. JWT verification is the canonical example. There is a famous class of attack where an API that accepts both symmetric (HS256) and asymmetric (RS256) algorithms can be tricked: the attacker takes the server’s public RSA key, which is public by definition, and uses it as the HMAC secret to sign a token with HS256. A verifier that picks the algorithm from the token’s own header will happily verify it. This is the “confused deputy” or “algorithm confusion” attack, and it has bitten a lot of real systems.
DaloyJS closes it by refusing to even construct a verifier that allows a symmetric algorithm when you are verifying against a JWKS:
import { jwk, requireScopes } from "@daloyjs/core";
const verify = jwk({
jwksUri: "https://login.example.com/.well-known/jwks.json",
algorithms: ["RS256"], // asymmetric-only allowlist, required
issuer: "https://login.example.com/",
audience: "books-api",
});
// This throws immediately, before any request is served:
jwk({ jwksUri: "...", algorithms: ["HS256"] });
// jwk(): algorithm "HS256" is not asymmetric. Symmetric (HS*) algorithms
// are refused by jwk() to close the JWKS confused-deputy attack.
app.route({
method: "POST",
path: "/items",
operationId: "createItem",
hooks: [verify, requireScopes(["items:write"])],
request: { body: z.object({ name: z.string() }) },
responses: { 201: { description: "Created" }, 403: { description: "Forbidden" } },
handler: async ({ body }) => ({ status: 201 as const, body }),
});The algorithm allowlist is required and non-empty, the issuer and audience are enforced, and the verifier applies the same prototype-pollution-safe reviver to the attacker-controlled claims in the token. You authorize per route with requireScopes(). The point is not that this is impossible to get wrong elsewhere. The point is that here, getting it wrong is a startup error with a message that explains the attack, instead of a quiet acceptance that you discover during a pentest.
One more thing worth saying plainly, because it is a place people get the wrong idea: DaloyJS is a resource server, not an identity provider. It verifies and enforces tokens. It does not run login pages, manage users, or mint tokens, and it should not. If you need login, you bring a real OpenID Connect provider, managed or self-hosted, and you verify its tokens. Writing your own authorization server is one of those decisions that feels productive and ends careers. Do not.
The contract is a security boundary, not just nice types
I promised I would come back to this. The reason a single-source-of-truth contract matters for security, and not only for developer happiness, is drift. In a typical stack you describe a request body in at least three places: the runtime validator, the OpenAPI docs, and the frontend types. Those three drift apart over time. The validator says one thing, the docs say another, and the gap between them is where the bugs live. An endpoint that the docs claim validates email but the validator quietly does not is an injection waiting to happen.
When the schema is the route definition, that gap cannot open. The same BookSchema validates the body, generates the OpenAPI operation, and types the client. If you forget to validate, the docs do not lie about it, because there is nothing to lie with. And there is a contract test runner that checks the things humans forget:
import { runContractTests } from "@daloyjs/core/contract";
const report = await runContractTests(app);
if (!report.ok) process.exit(1);
// Flags: declared examples that do not match their schema, duplicate or
// missing operationIds, dead routes, and body schemas on safe methods.A body schema declared on a GET route is a small thing, but it is the kind of small thing that signals somebody copy-pasted a route and did not think. Catching it in CI is cheap. The broader idea is that the framework treats your API description and your runtime behavior as the same object, so they cannot disagree, and disagreement is where a lot of quiet vulnerabilities hide.
The other half of the attack surface is your node_modules
Everything above is about requests coming into your app. But in 2026 the more fashionable way to get owned is through the code you installed before a single request arrives. We have lived through self-replicating npm worms, malicious postinstall scripts that run on npm install, CI cache poisoning, and a newer one I find genuinely funny in a dark way: slopsquatting. That is where an attacker registers a package name that AI assistants are statistically likely to hallucinate, then waits for someone to copy a non-existent import straight from a chat window into a real project. The agent invents @types/superfast-json, the attacker has already published it, and now you have a dependency you never vetted because you never even chose it.
You cannot fix all of that from inside a web framework, but you can shrink the blast radius, and you can ship sane defaults to the projects you scaffold. The core package has zero runtime dependencies, which is the single most effective supply-chain decision available: there is no transitive tree to poison because there is no tree. It is published with npm provenance and CycloneDX plus SPDX SBOMs, so you can verify on install that the bytes you got were built from the source you think they were.
The scaffolded projects inherit a pnpm posture that I wish were the industry default:
# .npmrc that create-daloy ships
ignore-scripts=true # postinstall scripts do not run on install
minimum-release-age=1440 # a package version must be >= 24h old to installThat minimum-release-age line is quietly one of the best defenses against the fast-moving compromise. Most malicious package versions get caught and yanked within hours of publish. If your installer simply refuses to pull a version younger than a day, the worm that depends on everyone installing the poisoned 1.4.7 the moment it lands just does not reach you. You trade twenty-four hours of “I cannot get the brand new release immediately” for a large reduction in the chance you are patient zero. For application code, that is an easy trade.
I want to be precise about what travels and what does not, because this is where honesty matters. The runtime protections and the published SBOM and provenance travel with every app you build, on any CI host, GitHub or not. The strongest install-time bundle, the release-age cooldown and the workspace gates, depends on you using pnpm, because those are pnpm features. And the hardening on the framework’s own release pipeline, the SHA-pinned actions and the OIDC publishing and all of that, protects the framework, not automatically your app. I am not going to pretend installing one package makes your supply chain bulletproof. It does not. It removes a category of risk and gives you defaults that point the right way.
Where this does not save you, said plainly
If you only read one section, read this one, because a security tool that oversells itself is worse than no tool. It makes you complacent.
Secure defaults cannot save you from your own logic. If you write an authorization check that says if (user.id = req.params.id) with a single equals sign, no framework on earth will catch that for you, and you will have just assigned your way into a broken access control bug. Broken object-level authorization, the boring “user A can read user B’s invoice by changing the id in the URL” bug, is consistently the most common serious API vulnerability in the wild, and it lives entirely in code that only you can write correctly. The framework gives you the tools to do auth well. It cannot do your authorization for you.
It is also new. As I write this, DaloyJS is hitting its first 1.0 beta (1.0.0-beta.0), which is the moment the API stops being a moving target and starts being a contract I have to keep. That is a real milestone, and also a real promise with the fine print attached: a beta is still a beta, so a few things can shift before the stable 1.0, and you should pin your version and read the changelog before you upgrade. What it does not have yet is age. If you need a fifteen-year-old ecosystem with a plugin for every conceivable thing and ten thousand answered Stack Overflow questions, Express and Fastify have that and DaloyJS does not, yet. I think the opinions baked into it are the right ones, but “the maintainer thinks his opinions are correct” is true of every maintainer, so weigh it accordingly.
And it is, deliberately, not a lot of things. It is not an identity provider. It is not an ORM. It is not a frontend framework. It verifies tokens, it does not issue them. It guards outbound fetch, it does not replace your firewall. The whole design is to do the web-framework job with safe defaults and stay out of the rest, which means you still assemble the rest yourself, correctly. Secure-by-default lowers the floor on how bad your mistakes can be. It does not raise the ceiling on how good your architecture is. That part is still on you.
The boring conclusion
Here is what ten years has actually taught me, stripped of any framework pitch. The teams that stay out of the incident channel are not the ones with the smartest engineers or the fanciest security tooling. They are the ones where doing the lazy thing happens to also be the safe thing, because somebody set the defaults up that way once and then everyone just followed the path of least resistance, which is what humans and agents both do.
That is the entire idea behind secure-by-default, and it matters more now than it did five years ago, because the entity writing your routes at 3am might not be a tired human who can be talked into being careful. It might be a model that does exactly what you asked, which was “make it work,” and nothing more. If “make it work” already includes a body limit, a real 405, a guarded fetch, a refusal to boot on a wildcard CORS, and a JWT verifier that will not accept the confused-deputy token, then the agent’s literal-mindedness stops being a liability.
You do not get security by being more vigilant than the machine. You get it by making the safe path the only easy path, and letting everybody, human and agent alike, be as lazy as they were always going to be anyway.
If you want to poke at the actual code, the framework is @daloyjs/core, now out as 1.0.0-beta.0. The fastest way in is to scaffold a project with create-daloy, which ships on the same 1.0 beta and drops you into an app with the secure .npmrc defaults already wired. It runs on Node, Bun, Deno, Cloudflare Workers, Vercel, and Lambda from the same source, and the security docs go deeper than I can here. Go break it. Tell me where the defaults are wrong. That feedback is worth more than any of the green checkmarks, especially now, while a beta still means I can fix things before they calcify into a stable API.
Documentation: https://daloyjs.dev/
GitHub: https://github.com/daloyjs/daloy