Secure Defaults
The default configuration is the configuration most of your system will actually run, so the security question is not "can it be configured safely?" but "what happens when nobody configures it at all?"
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
The default is the policy
Any security property that requires an engineer to remember something will be absent from a predictable fraction of your system. Not because engineers are careless, but because the number of decisions per feature is large and security is one of many. The only reliable lever is to change what happens when nobody decides.
This shows up most sharply in framework and platform design. A router where a route without an auth decorator is public produces public endpoints. A router where an unannotated route raises at startup produces annotated routes. Same team, same care, opposite outcome — the difference is entirely in which direction the default points.
The design test is straightforward: write down what a new endpoint, a new storage bucket, a new database role, a new service identity and a new cookie look like when created with zero security-specific effort. If any of those answers is "open", that is your real security posture, regardless of what the documentation says.
| Thing | Bad default | Secure default |
|---|---|---|
| New API route | Public unless a decorator is added | Fails at startup unless an explicit policy is declared |
| New storage bucket | Inherits an account-wide permissive policy | Private, with public access blocked at the account level |
| New database role | Inherits broad schema grants | No grants; permissions added per table and verb |
| Session cookie | SameSite unset, no Secure, no HttpOnly | HttpOnly; Secure; SameSite=Lax, short lifetime |
| CORS | Access-Control-Allow-Origin: * for convenience | No CORS headers; specific origins added deliberately |
| Token lifetime | Long, because refreshing is annoying | Minutes, with refresh handled by the framework |
| Error response | Stack trace and SQL in the body | Correlation id in the body, detail in the log |
| Debug endpoints | Present, gated on an environment variable | Not compiled into the production build |
Make the secure path the easy path
Secure defaults fail when they are annoying, because engineers route around annoyance reliably and creatively. A policy that makes local development painful produces a shared "dev" credential with broad permissions that eventually reaches production. A deny-by-default IAM system without a fast path to request a permission produces wildcard policies attached in frustration.
So the discipline has two halves and the second one is usually skipped: point the default at safety, *and* make the safe path fast. A helper that creates a properly-scoped role in one line. A session cookie helper that sets the attributes so no one writes Set-Cookie by hand. A test that fails when a route has no declared policy, with an error message that says exactly what to add. A template repository where the safe configuration is what you get by cloning.
The strongest version of this is making insecure configurations *unrepresentable*: a storage module that does not expose a "public" flag, a query builder with no string-concatenation entry point, an HTTP client that will not follow redirects to private address ranges. What cannot be expressed cannot be misconfigured under deadline, which is the only condition that matters.
1type Policy = { public: true } | { requires: Permission }2 3// Every route must declare a policy. There is no third option, and no default.4export function route<T>(path: string, policy: Policy, handler: Handler<T>) {5 registry.push({ path, policy, handler })6}7 8// At boot: refuse to start rather than serve something undeclared.9export function mount(app: App) {10 for (const r of registry) {11 if (!r.policy) throw new Error(`route ${r.path} has no policy — declare { public: true } or { requires }`)12 app.handle(r.path, async (req, res) => {13 if ('requires' in r.policy && !req.principal?.can(r.policy.requires)) return res.sendStatus(403)14 return r.handler(req, res)15 })16 }17 // A public route is now a visible, greppable, reviewable decision:18 // route('/health', { public: true }, healthHandler)19}Key points
- The default configuration is your real security policy, because most resources are created without security-specific thought.
- Point defaults at deny and private, and make forgetting a startup error rather than a silent exposure.
- A secure default that is annoying gets routed around; the safe path must also be the fast path.
- Strongest form: make the insecure configuration unrepresentable in the API you give engineers.
- Public, permissive or long-lived should always be an explicit, greppable, reviewable line of code.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Attacker → scan for defaults: open buckets with predictable names, default credentials, exposed dashboards, permissive CORS, verbose errors.
- 2Default → access: no exploitation needed; the resource is simply readable or the credential simply works.
- 3Access → escalation: use what the default exposed (a config file, a token, an admin UI) to obtain a real identity.
- Exposure without exploitation: the most common large data leaks require no vulnerability at all, only a permissive default that nobody changed.
- Because there is no attack, there is usually no log entry that looks unusual — the access is indistinguishable from legitimate use.
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • Deny by default at every layer: routes, storage, roles, network, and the cloud account itself.
- • Enforce defaults in code and infrastructure templates rather than in documentation or review checklists.
- • Fail closed at startup on missing declarations, so misconfiguration is caught in CI rather than by a scanner.
- • Remove the capability entirely where it is not needed: no debug endpoints in production builds, no public flag in the storage wrapper.
- • Continuous configuration scanning for public storage, wildcard IAM, `0.0.0.0/0` rules and missing cookie attributes.
- • Alert on the *change* that made something public, not only on the state, so you catch it in minutes rather than at the next audit.
- • Fail CI on infrastructure diffs that widen exposure without an explicit approval marker.
- • Close the exposure immediately; assume everything reachable during the exposure window was read.
- • Determine the window from creation time, not discovery time.
- • Fix the template or module that produced the default, since the same resource will be created again next week.
- • Third-party services have their own defaults, and yours cannot govern them.
- • Legacy resources created before the safe default existed keep their original configuration silently.
- • A safe default can be overridden legitimately, and the override is where the exposure returns.