Why Kovo?
Most of a web app's security is a discipline you have to remember. Scope every query to the current
user. Escape every string before it reaches the page. Parameterize every SQL statement. Miss one —
one forgotten WHERE user_id = ? — and it isn't a bug that renders blank. It's a stranger reading
someone else's orders.
Kovo's bet is that human memory is the wrong place to keep a security boundary. So the safe shape is the one you write by default, the unsafe shape is a build error, and where the compiler can't prove it, the database itself refuses.
Here's the whole idea in one line. You mark who owns a row:
import { kovo } from '@kovojs/drizzle';
import { pgTable, text } from 'drizzle-orm/pg-core';
const orders = pgTable(
'orders',
{
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
},
kovo((columns) => ({ domain: 'order', owner: columns.userId })),
);Now every read of orders is scoped to the signed-in user — enforced by Postgres, at the
connection, not by a WHERE clause you remembered to add. Delete the filter, write the query wrong,
reach the table through a different handle: the database still returns only that user's rows. Forget
to scope an owned table at all, and the build fails at the query before it ships.
This page shows that pattern applied to the three mistakes that leak data: reading another user's rows (IDOR), SQL injection, and XSS. Real code, both sides — what you write, and what Kovo won't let you write.
Safe by construction, then defense in depth#
Three things have to hold for "you can't hold it wrong" to mean anything:
- The safe shape is the default. Ordinary code — a plain query,
{user.name}in your markup — is already scoped, already escaped. There's no separate "secure" variant to reach for. - The unsafe shape doesn't compile. Where static analysis can prove a trust-boundary mistake — an unescaped sink, a raw SQL string, an owned table read with no scope — it's a build error, not a lint you can wave through.
- Where it can't prove it, it fails closed. Authorization is a runtime fact — does this user own this row? — so it's enforced at runtime, by the database, default-deny. The engine says no without needing a proof.
And every deliberate exception is visible. When you genuinely need raw HTML or raw SQL, you pass a
branded value — trustedHtml(...), a branded sql — and it shows up in kovo explain. There is no
silent way to opt out of a guarantee. The opt-outs are enumerable.
IDOR: reading another user's rows#
Orders are owned by userId, so this loader is already scoped to the caller. There is no userId
in the query, and it doesn't need one:
// Source: examples/commerce/src/queries.ts
orders = pgTable(
'orders',
{
/* … */
},
kovo((columns) => ({ owner: columns.userId })),
);
const orderHistory = query({
guard: guards.authed(),
load: (_input, context) => context.db.select().from(orders), // returns only the caller's orders
});The Postgres role this query runs as can only see rows where userId matches the session. Kovo sets
the principal per request; the database does the filtering. (Our own commerce example keeps an
explicit .where(eq(orders.userId, userId)) — for readers, not for safety. Deleting it changes
nothing about who can see what.)
Now the version that leaks in most stacks — an owned table read with no way to scope it:
db.select().from(orders); // ✗ build error — read of an owned table is not owner-scopedAt build, that's a scoping diagnostic. At runtime, if you route around the check somehow, the database returns
zero rows — not everyone's. Reading across users on purpose (an admin report) is a named capability,
crossOwnerRead, which requires an admin role and is logged. The wide read exists. It just can't
happen by accident.
Injection: SQL from a string#
The query builder binds values. User input is a parameter, never concatenated into text:
db.select().from(products).where(eq(products.id, input.id)); // input.id is boundWhen you need raw SQL, it only reaches the database as a branded sql value, which parameterizes
every interpolation for you:
db.select()
.from(products)
.where(sql`id = ${input.id}`); // ${input.id} is a bound parameter, not textA hand-built string doesn't carry that brand, so it can't reach a managed handle. Truly unsafe SQL —
building the statement text yourself — means calling trustedSql(...) on purpose, the audited escape
that appears in kovo explain.
XSS: a string that's really markup#
Output is escaped. A name with a <script> in it renders as characters, not as a tag:
<span>{user.name}</span> // user.name = '<img onerror=…>' renders as literal textThere's no string-accepting raw-HTML prop to slip markup through. To emit real HTML you build a
trustedHtml value deliberately, which carries provenance the compiler can audit:
import { trustedHtml } from '@kovojs/browser';
declare const comment: { body: string };
<div>{comment.body}</div>; // escaped — safe by default
<div>
{trustedHtml(comment.body, {
reason: 'moderated comment markup',
source: 'comments/moderation.ts',
})}
</div>; // you opted in, on the recordThe dangerous form is longer to write than the safe one, and it leaves a trace. That's the point.
Secrets that can't reach the browser#
Mark a column secret and its value can't be serialized to the client — even if a query selects it:
import { kovo } from '@kovojs/drizzle';
import { pgTable, text } from 'drizzle-orm/pg-core';
const session = pgTable(
'session',
{ id: text('id').primaryKey(), token: text('token').notNull() },
kovo((columns) => ({ domain: 'session', secret: [columns.token] })),
);A session token, a password hash, an OAuth secret: tag it once, and the secret-egress check keeps it off every wire frame. Select it into a loader by accident and the value is dropped at the boundary, not shipped and hoped-unused.
Where the guarantee actually comes from#
"Safe by construction" is a claim you should distrust until you've seen the door. So here's the mechanism, plainly.
Authorization runs at the storage engine. On Postgres, each request connects as a least-privilege
role — not a superuser — with the user's id set as a session variable, and owned tables carry
row-level security policies keyed to it. Every read and write through that connection is filtered by
the database, by the role, regardless of how the query is written. That's what makes "delete the
WHERE clause and it's still safe" true: the boundary sits one layer below your code, where a typo
can't reach it. At startup Kovo audits the database's actual grant graph and refuses to serve if it
finds anything the role can reach that isn't provably scoped.
The static checks are the early-warning layer, not the boundary. The scoping diagnostic tells you at build that a read isn't scoped; it doesn't have to be complete, because the engine is what actually says no. Kovo tests exactly this: a paranoid build mode turns off every static security check and runs the app, so the runtime enforcement has to hold on its own. If switching off the compiler's help lets a leak through, that's a bug we fix — not a guarantee we quietly rest on the compiler for.
What you're still on the hook for#
Installing Kovo does not make your app secure. The honest boundary:
- The escapes are yours to justify.
trustedHtml,trustedSql,crossOwnerReadexist because sometimes you need them. Each is visible inkovo explain, but the framework can't know whether the HTML you vouched for is actually clean. It makes the exception loud, not correct. - Custom policies are your logic. For rules
ownercan't express — team membership, sharing, roles — you write a SQL predicate withauthzPolicy. Kovo guarantees the policy is attached and enforced. It can't guarantee your predicate says what you meant. - The types are guardrails, not the proof. Branded values make the unsafe call awkward to write; the enforcement is the runtime and the engine. Don't read a green typecheck as "proven safe."
- The strong guarantees are on Postgres. That's where engine-level enforcement lives. SQLite is a dev-only, single-tenant convenience — it does not provide these authorization guarantees, and it says so out loud when you use it.
The rest of Kovo#
Security is why the constraints exist, but the same wiring the compiler tracks to hold a boundary also catches ordinary breakage. Rename a column and the query that selected it, the element bound to it, and the link that carried its id all fail the build at once — stale-UI bugs become compile errors. What ships is a real multi-page app: interactive at first paint, little JS on the critical path, its handler and query names legible in View Source and the Network panel. The mental model and the tutorial go through that side in depth.
How it compares#
At the level that matters for security, the difference is where the boundary lives. Most frameworks hand you the tools to be safe and trust you to use them every time — parameterize here, scope there, escape that. Kovo moves the boundary off your memory and into the compiler and the database, so the default is safe and the exception is loud. The rest of the shape is familiar:
| If you've used… | What's familiar | What Kovo changes |
|---|---|---|
| Next / Remix | Server-rendered React, file-based routing | No hydration and no client-state library; the server↔client wiring is typechecked end-to-end, not runtime-discovered |
| htmx / LiveView | HTML over the wire, server-rendered fragments | Same wire philosophy, but the fragment/query contracts are statically typed; no stateful socket tier (htmx has none either; unlike LiveView, no per-client server session) |
| Qwik | Resumability, lazy handler loading on interaction | Borrows resumability, but compiles to near-zero client runtime and stays a true MPA — no resumable client app graph |
| Astro | Islands, MPA, ship-little-JS | Same islands-on-an-MPA shape, but Kovo owns the typed data/mutation/authorization graph across the stack, where Astro stays framework-agnostic and content-first |
The trade-offs (read this part)#
Kovo makes sharp choices, and they cost you things. Where it's the wrong tool:
- Long-lived, single-heap client apps. Figma-class canvases, video editors, DAWs — anything built around one mutable client session that lives across navigations. Islands can host a rich widget, but the app shell is the document, not a persistent runtime.
- Offline-first. The server is unconditionally authoritative. There's no sync engine and no local-first story.
- Escape-hatch-heavy code. The static guarantees hold because app code stays in TypeScript's
sound subset:
stricton, noany, noascasts, no non-null!. Those are lint errors here, not warnings. - Pick-your-own-database freedom, today. The authorization and invalidation guarantees ride on Postgres plus Drizzle metadata. Other stacks can work, but you own the metadata the extractor would otherwise give you.
And the status line: Kovo is pre-v1 and not published to npm. You can build with it today inside
the repository (the Tutorial does exactly that), but it isn't a pnpm add away yet,
and the widget ecosystem is still thin.
Next steps#
- Quickstart — get a page rendering and see the checks fire.
- Thinking in Kovo — the mental model, built through one small app.
- Tutorial — build a real commerce app end to end.
Spec & diagnostics
Security by construction is the Prime Principle: SPEC §2. Owner-scoping and authorization at the
storage engine — the least-privilege runtime and closure audit, KV414, crossOwnerRead,
authzPolicy — plus secret classification and branded SQL (KV435, KV422): SPEC §10 /
spec/10-data-plane.md. Escaped output and trusted-output escapes (KV426): SPEC §6 /
spec/06-type-system.md. Non-goals (Figma-class apps, offline-first, pick-your-own-database):
SPEC §1.4.