Every framework that says "batteries included" is really making a promise about defaults. The whole pitch — declare your data and get an admin, a REST API, forms, migrations — only holds up if the thing you get for free is also the thing that's safe. The moment the convenient path and the correct path diverge, "batteries included" becomes "footguns included."
So one week, we stopped adding features and tried to break Umbral instead. We read every plugin as if we were the attacker, not the author. The rule was simple: assume nothing, and treat every "this is probably fine" as a bug until proven otherwise.
It was not a comfortable week. But it was the most important one.
The uncomfortable questions
Good security review is mostly a list of rude questions you'd rather not ask about your own code.
"What happens if two users hit this at the same time?" Our signal system ran subscriber callbacks while holding a global lock — so one slow audit-log handler could quietly throttle every write in the whole process, and a handler that re-entered the API would deadlock it forever. We now clone the handler list, drop the lock, and run handlers free.
"Whose IP is this, really?" Throttles and logs keyed on X-Forwarded-For — a header any client can forge. Behind a reverse proxy that's fine; directly exposed it's a rate-limit bypass and a way to frame another user. So we made the framework ask you how many proxies it should trust, and resolve the real client from there. If you configure a per-IP throttle without a trusted proxy, it now tells you at boot instead of silently sharing one bucket across the whole internet.
"Can I read a row that isn't mine?" A REST API that serves every row to anyone who can guess an ID is the single most common web vulnerability there is. We added object-level scoping — .scope() and .owned_by() — so list and detail endpoints only ever return the rows a caller is allowed to see. And uploaded files, which used to be world-readable by URL, got an access-control hook that runs before a single byte is served.
"What ends up in the logs?" Signals fanned out the entire row to every subscriber — password hashes, tokens, PII — which an innocent audit-log subscriber would then dutifully copy into permanent storage. Now a field marked #[umbral(signal_skip)] never leaves the building.
The defaults nobody thinks about
Some of the best fixes were the ones that change nothing on the happy path and everything under stress.
Argon2 password hashing is deliberately expensive — that's the point. But a flood of logins could spawn hundreds of those hashes at once and simply eat all the memory on the box. We put a concurrency gate in front of it: peak memory is now bounded, and past a threshold the server sheds load with a 503 instead of falling over. You never notice it. An attacker does.
Sessions could live forever if you used them once a fortnight. Now you can set an absolute maximum age and a SameSite policy, so "stay logged in" has an outer limit. And on Postgres, tenant isolation is enforced by the database itself with FORCE row-level security and a per-request context that can't leak from one request into the next — the last line of defence lives below your handler, where a coding mistake can't reach it.
Even the migrations
Security isn't only the request path. Deploys are where quiet disasters happen.
Two app replicas deploying at once used to race the same schema change; one would win and the other would abort mid-migration. Now a Postgres advisory lock serializes them — one migrates, the rest wait and skip. A migration that would drop a table (because you deleted one line of model registration) no longer runs on a makemigrations && migrate reflex; it stops and makes you type --allow-destructive. And tightening a column to NOT NULL now backfills the existing nulls instead of failing halfway through against real data.
Why this is a feature
Here's the thing about all of this: if we did it right, you will never see any of it. There's no dashboard for "the OOM that didn't happen" or "the row you didn't leak." Secure-by-default is the rarest kind of feature — the one whose entire job is to be invisible.
But it's also the reason a batteries-included framework is worth using at all. The value was never that you can build auth, or throttling, or multi-tenancy. You can build those anywhere. The value is that you get them already thought through — including the 3 a.m. edge cases you'd never have time to chase on a deadline.
We tried to break our own framework for a week. What we actually did was write down all the hard questions once, so you don't have to ask them every time you ship.