Skip to content
SecurityOpinionField note

Willpower Is Not a Security Control: Secure Defaults Beat Training, and AI Does Not Fix That

Tanya Janca's DevSecStation episode on secure defaults matches why I built DaloyJS the way I did: security on by default, insecure paths explicit and effortful. Login with JWT is not enough, and telling an AI to build an API does not make the easy path safe.

Devlin Duldulaosoftware engineer & published book author11 min read

I listened to Secure Defaults Beat Secure Training on Tanya Janca's DevSecStation podcast (SheHacksPurple, Season 1 Episode 8) and got that mix of relief and irritation. Relief that someone said the quiet part out loud. Irritation that I have been living the same story for twelve years and still watch teams answer it with another slide deck.

Her sticky-note line: the easiest path is often the insecure one. Developers usually care. They are also busy. Training leans on memory and willpower. Defaults shape what people do when nobody is watching. In live systems, defaults win.

That is why DaloyJS looks the way it does. Security starts on. Turning pieces off is explicit, sometimes two flags, and often loud at boot. Annual training is still useful. Tanya teaches it for a living, and she is careful about that: training helps when it explains the why. Alone, under deadline pressure, it loses to copy-paste.

Disclosure: I help build DaloyJS, a contract-first TypeScript backend framework with secure defaults and zero runtime dependencies. This post is a reaction to Tanya's episode and a design note for why the framework behaves this way. Steal the idea even if you never install the package.

A normal developer day

Tanya's story is painfully ordinary. You are juggling tickets. You spin up a service. You copy config from the last repo. You leave the settings because they work. Nothing feels wrong. You did not skip security training on purpose. You followed the path that was already paved.

Insecure defaults almost never feel like a decision. They feel like progress. I have shipped that shape more times than I want to admit, usually in Express or a thin wrapper around it, usually with a valid JWT middleware and a board demo that looked fine:

ts
// A very normal Monday. New service. Copy the last one.
// Training said "validate input" and "don't leave CORS open."
// The ticket said "ship by EOD." Guess which voice wins.
import express from "express";

const app = express();
app.use(express.json()); // unlimited body by default
app.use(cors({ origin: "*" })); // works in the demo
app.post("/users/:id/plan", async (req, res) => {
  // Auth middleware somewhere. Token is valid. Ship it.
  const updated = await db.user.update({
    where: { id: req.params.id },
    data: req.body, // mass assignment says hi
  });
  res.json(updated); // passwordHash comes free
});

// Nobody "ignored security." They followed the path that was already there.

The annual training PDF said all the right things. The path of least resistance said ship. Willpower is a terrible control when the build is green and the product manager is in the doorway.

Login is not a security program

A lot of teams treat "we have JWT auth" as the finish line. Authentication tells you who called. It still leaves open whether that caller may touch this row, write these fields, or see that hash. I wrote a longer post on that gap (Your JWT Is Valid and Your API Is Still Vulnerable). Alice's token is valid, Bob's id is in the URL, and the handler still returns Bob's data.

That bug survives training because every tutorial shows the same shape: verify the token, then findById(params.id). Scoping the query to the principal, validating the body, and clamping the response stays extra work unless the framework and the template make that the default motion of writing a route.

AI multiplies whatever path you leave open

People assume a coding agent will remember the OWASP list better than a tired human. Sometimes it recites the list. Then it still generates the same handler the training data saw a million times:

ts
// What people type into a coding agent:
//   "Build a REST API with JWT auth for a todo app."
//
// What they usually get:
//   - login endpoint that issues a token
//   - middleware that checks the signature
//   - handlers that trust req.body and req.params.id
//   - CORS wide open so the SPA "just works"
//   - no response schema, no body limit, no rate limit
//
// The demo passes. The board sees a login screen.
// Alice still reads Bob's todos by changing the id.
// Training called that IDOR. The agent called it done.

Telling an AI to "build a secure API" is still training, only with a shorter attention span. The model optimizes for the demo that compiles. If your framework and scaffold treat unbounded bodies, open CORS, mass assignment, and missing response schemas as normal, the agent will reproduce normal. I covered what DaloyJS already blocks in a vibe-coding workflow in Vibe Coding Security. The design lesson is simpler: AI multiplies whatever default path you leave in the repo. It does not install judgment for free.

Training for the why, defaults for Friday at 5

Tanya ranks the approaches the same way I have seen them fail in production. Annual training alone (slides, quizzes, forgotten by the next sprint) barely moves anything. Training plus "please remember" helps a little and still depends on willpower. The combination that works is training that explains the why, plus defaults that make the secure option automatic, so people know why they should not rip the guard out and the system does not need them to be heroes at 5 p.m. on a Friday.

You can keep tools usable. You just pave the road people will take under pressure, and make that road the safe one.

What defaults look like in a backend framework

When I say DaloyJS is secure by default, I mean the boring stuff is armed when you write new App(): body size limits, request timeouts, prototype-pollution-safe JSON parsing, secure response headers, cross-origin write protection unless you register cors(), production error redaction, and boot guards for footguns like weak secrets, unauthenticated MCP routes without an explicit public flag, or session without CSRF on state changes. The full tour lives in Secure by Default and the docs.

The route shape does the same job. The contract is the gate before the handler. You do not wait for a reviewer to catch a missing comment:

ts
// Same feature, but the path of least resistance is the guarded one.
// Progressive shorthand: app.post(path, contract, handler).
// Unknown keys die with 400. Response fields you did not declare cannot leave.
// Handlers return { status, body }; response contracts use body:, not schema:.
import { z } from "zod";
import { App, jwk, rateLimit } from "@daloyjs/core";

// secureHeaders, body limit, request timeout, cross-origin write guard,
// prod error redaction: already on. You did not remember them. Good.
export const app = new App()
  .use(rateLimit({ windowMs: 60_000, max: 60 }))
  .use(
    jwk({
      algorithms: ["EdDSA", "ES256"],
      jwks: process.env.JWKS_URL!,
      issuer: "https://auth.example.com/",
      audience: "billing-api",
    }),
  )
  .post(
    "/users/:id/plan",
    {
      operationId: "updateUserPlan",
      request: {
        params: z.object({ id: z.uuid() }).strict(),
        body: z
          .object({
            plan: z.enum(["free", "pro", "team"]),
            seatCount: z.number().int().min(1).max(500),
          })
          .strict(),
      },
      responses: {
        200: {
          description: "updated",
          body: z
            .object({
              id: z.uuid(),
              plan: z.enum(["free", "pro", "team"]),
              seatCount: z.number().int(),
            })
            .strict(),
        },
      },
    },
    async ({ params, body, state }) => {
      // Auth passed. That only means "who." Resource checks still live here
      // or in the repository. A valid JWT is not a free pass to every row.
      const updated = await billing.setPlanForCaller(state.user, params.id, body);
      return { status: 200 as const, body: updated };
    },
  );

On the third coffee you still do not need to remember unknown body keys, leaking extra response fields, unlimited payloads, or silent missing headers. An AI can still write a bad repository query. Defaults will not replace resource authorization or threat modeling. They remove the class of bugs that only exist because the framework shrugged.

Opt-out should be explicit, and a little annoying

Tanya's practical advice is the design rule I care about most in DaloyJS: make the secure option automatic, and make the insecure option take explicit choice and effort.

ts
// Accidental insecure should be hard.
// secureDefaults: false in production refuses to construct
// unless you also pass acknowledgeInsecureDefaults: true.
// That second flag is the "I am doing this on purpose" signature.

import { App } from "@daloyjs/core";

// Migration escape hatch: loud at boot, logged, effortful on purpose.
export const app = new App({
  secureDefaults: false,
  acknowledgeInsecureDefaults: true, // required in production
});

// Prefer a narrow opt-out when you only need one door open:
export const app2 = new App({
  secureHeaders: false, // CDN injects its own headers
  // everything else stays on
});

// MCP tools are public only when you say so at the call site.
// mcpRoutes("/mcp", handler, { public: true }) is deliberate.

In production, secureDefaults: false without acknowledgeInsecureDefaults: true throws at construction. Even outside production, the framework logs every surface that flag disabled. Per-feature opt-outs exist so you can open one window without burning the house down. MCP routes need auth unless you pass public: true. Insecure should not look identical to the happy path in code review.

If a junior (or an agent) can disable your security posture by deleting one line that looks like a style preference, you do not have a control. You have a suggestion.

Do Tanya's one thing, even if you never touch DaloyJS

Her homework is better than any framework pitch: change one default in one active repo. Config that ships insecure "for later." CI without composition analysis. A template that skips validation. A script that assumes secrets live in plaintext. Flip it so secure is automatic and insecure is effortful. Leave a comment so future you does not undo it while cleaning "noise."

bash
# Tanya's "one thing" exercise, applied to a backend repo.
# Pick one. Flip it. Leave a comment so future-you does not undo it.

# 1) CI that always runs security-ish gates
pnpm typecheck && pnpm test
pnpm verify:no-lifecycle-scripts
pnpm verify:known-dep-names
pnpm verify:lockfile

# 2) Template that starts with validation and auth hooks, not a bare router

# 3) Config that does not ship with origin: "*" "for now"

# 4) Scripts that refuse plaintext secrets in env files committed to git

You will not fix the company today. You will protect every future change that copies that path. That is also why a framework-level default beats a wiki page titled Security Checklist.

Defaults still leave work on the table

You still need threat modeling when a feature moves money or identity. You still need resource-level authorization (the JWT-valid-but-still-broken problem). You still need encryption and key management for sensitive data at rest, human review on scary migrations and permission changes, and training that teaches why so people do not proudly delete the guard.

Defaults handle the recurring boring failures: the ones that show up because someone was rushing and the template was friendly. AI makes those failures cheaper to produce at volume. Frameworks and scaffolds that keep the insecure path frictionless will lose harder in 2026 than they did in 2019.

Why I keep building it this way

I write backends, not board decks. I got tired of reading the same pentest findings after teams completed the same annual training. Developers cared. The default path was a trap, and everyone was busy.

So DaloyJS starts with contract-first routes, schemas that run before handlers, secure headers and limits without a plugin shopping list, boot guards that fail closed, and opt-outs that leave a paper trail. If that sounds heavy-handed, good. Security that depends on everyone having a perfect day is already broken.

Tanya said it cleaner than I usually do: when you change a default so it is secure, you protect every future change that touches that code. Training can explain why. Systems have to make it stick.

Listen, then flip one default

Scaffold with the boring guards already on:

bash
pnpm create daloy@latest my-api