Security & authorization
You have an /admin/orders page and a refund/order action. Only staff should open the page, only
admins should submit the refund, and every review should show that no secret field, unsafe write, or
capability URL slipped onto the public surface. This guide starts with that job: guard the route,
guard the action, then use kovo explain to inspect the rest of the security graph.
One explicit honesty line up front: Kovo does not claim prompt-injection immunity. If an app hands adversarial text to a model or lets a model choose tools, OWASP LLM01-class failures are still possible. Kovo's claim is narrower: default-deny guards, the outbound-egress floor, structured HTML/URL/SQL sinks, and future capability-bounded tool adapters can reduce the consequence of a bad model decision, but they do not make prompt-driven apps safe by construction.
Guard the route and action#
A guard is a function from a request to true or a denial. The same guards combinators apply to
mutations, routes, and queries, so authorization is one vocabulary across the app.
import { guards, route } from '@kovojs/server';
export const accountPage = route('/account', {
guard: guards.authed(),
page: () => <AccountOverviewPage />,
});Layer role checks, rate limits, and mutation-specific authorization after the first protected page works.
The combinators (verified against @kovojs/server's guards):
guards.authed()— passes whenrequest.session?.useris present; refines the request type soreq.session.useris non-null inside the handler. Anull/undefinedsession means anonymous and is treated as unauthenticated, never as a malformed request.guards.role(role)— fails unauthenticated callers as unauthenticated, and authenticated-but- wrong-role callers as unauthorized (403), checkingreq.session.user.roles.guards.rateLimit({ max, per, windowMs? })—per: 'ip' | 'session' | 'global', with a keyed variant for per-tenant limits.per: 'ip'keys fromreq.clientIp, the normalized client address Kovo exposes from the request shell. Use it for anonymous forms and machine ingress where there is no stable session yet.per: 'session'is only as strong as the anonymous session binding before login; do not treat it as a per-person throttle until the session is authenticated.guards.all(...guards)— composes left to right and propagates the first denial as-is, so the status mapping stays intact.
Guard outcomes are fixed so auth stays part of the typed surface. Route/query authed failures run
the app's onUnauthenticated handler; authenticated-but-unauthorized failures render the 403 shell.
Mutation guard failures split the same way: expired sessions get the reauth path, while valid sessions
that lack permission get a typed form failure. See mutations for the form
failure paths.
A practical security checklist#
- Type the session with
session(s.object(...)); guards and query keys depend on it. - Resolve it once with a
sessionProvider; treatnullas anonymous. - Guard from the bottom up:
authedon per-user reads,roleon admin surfaces,rateLimiton actions,guards.all(...)to compose. - Annotate
owner:on every per-user table and scope predicates toreq.session, never to client input. - Classify confidential columns with
secret; project only provably public fields. Keep declassification out of every request-reachable module and inspectkovo explain revealed. - Govern server-owned columns with
governed; write them throughserverValue(...)ortrustedAssign(...), never from request input. - Leave CSRF on; justify every
csrf: falseand confirm it inkovo explain endpoints. - Use capability URLs for downloads: mint with
ctx.signUrl(...), serve throughcreateStorageDownloadEndpoint, and review the download endpoint inkovo explain endpoints. - Run the security review modes in CI next to
kovo check:kovo explain unguarded,kovo explain unscoped,kovo explain endpoints,kovo explain revealed,kovo explain trust,kovo explain capabilities,kovo explain access,kovo explain cookies, andkovo explain sources-sinks. Verify detached escape reviews withkovo explain attestbefore deployment. - Review every escape hatch in the source/sink table before merging raw protocol code.
Type your session#
req.session is a declared s.object schema, not an any bag — and that's structural, not a
nicety. Query instance keys (product:p1) and guard refinements (authed making req.session.user
non-null) are load-bearing on session fields, so an untyped session would be a hole directly under the
proof surface:
import { s, session } from '@kovojs/server';
export const commerceSession = session(
s.object({
id: s.string(),
user: s.object({
id: s.string(),
roles: s.array(s.string()),
}),
}),
);Resolve the session with a provider#
The app declares a sessionProvider in the request shell. Kovo runs it once per request, before any
route, query, or mutation guard, and exposes the validated result as req.session. The provider's
return type must be assignable to the session schema under static checking; browser input still
crosses the runtime validators. The generated Better Auth integration returns a sanitized provider,
not the raw auth instance:
// authBindings comes from the framework-owned generated auth/database boundary
export const commerceSessionProvider = commerceSession.provider(authBindings.sessionProvider);
// captured once by the app contract; declarations close under app.assemble(...)
const app = defineKovo({
auth: commerceSessionProvider,
// csrf, db, document, …
});
export default app.assemble({ mutations, queries, routes });A provider that returns null/undefined is anonymous, and guards.authed() rejects it as
unauthenticated.
Authorization: the owner: / IDOR model#
Authentication asks "who are you?"; authorization asks "may you touch this row?" The second is where IDOR (insecure direct object reference) bugs live — a query or write scoped to a user id that comes from request input instead of the session. Kovo makes that statically visible.
Annotate the column that ties a table's rows to a principal in the schema:
// schema.ts — `owner:` names the principal column
import { kovo } from '@kovojs/drizzle';
import { integer, pgTable, text } from 'drizzle-orm/pg-core';
export const orders = pgTable(
'orders',
{
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
total: integer('total').notNull(),
},
kovo((columns) => ({ domain: 'order', owner: columns.userId })),
);Then scope every read and write of that table to the session, not to client input:
import { guards, query } from '@kovojs/server';
// CORRECT: the user id comes from req.session, traceable by the predicate extractor
export const orderHistoryQuery = query({
guard: guards.authed(),
load: (_args, context: { db?: any; request: CommerceRequest }) => {
const userId = context?.request.session.user.id;
return context?.db?.select().from(orders).where(eq(orders.userId, userId)) ?? [];
},
reads: [order],
});A query or write that touches an owner:-annotated table whose key predicate the analyzer can't trace
back to req.session is reported by the kovo explain unscoped audit below. The same predicate extractor that
derives row keys does the tracing. The fix is always the same: filter by a session field, never by an
unguarded args.userId.
CSRF is on by default#
kovo-csrf is a synchronizer token stamped into every emitted mutation form. The server verifies it
first: before schema parsing, before replay reservation, before the guard chain, on every
mutation POST. When req.session exists, the token is bound to that session. When the user is
anonymous, it is bound to a framework-owned signed-cookie secret so login, signup, and password-reset
forms are protected before there is a session. Note the wire field name is
the configured field (csrf in the starter). The generated Better Auth helper consumes boot-pinned
signing material and owns session/anonymous binding. App source receives no raw secret, generic
signer, or custom binding callback:
import { betterAuthCsrfFromEnvironment } from '@kovojs/better-auth';
import { mutation, publicAccess, s } from '@kovojs/server';
export const commerceCsrf = betterAuthCsrfFromEnvironment({ field: 'csrf' });
export const addToCart = mutation({
access: publicAccess('public storefront cart'),
csrf: commerceCsrf,
input: s.object({ productId: s.string() }),
handler: (input) => ({ ok: true, productId: input.productId }),
});Kovo emits the hidden field for mutation forms. Do not copy the returned configuration into a raw object or try to recover its signing material.
CSRF stays on for server-rendered mutation endpoints. The only opt-out is csrf: false on an
individual mutation, reserved for non-browser or externally authenticated endpoints. A csrf: false
mutation must not read req.session or run a session/cookie-derived guard; doing so is a
CSRF/session diagnostic
because the mutation would skip CSRF while still using ambient browser authority. Route non-browser
writes through endpoints and webhooks, where cookies are not
interpreted and verifier auth is explicit. Every opt-out shows up in the kovo explain endpoints audit with
its justification.
If a machine client still uses mutation() and your app configures a replay store, bind retries to
the verified caller:
import { mutation, s } from '@kovojs/server';
declare function verifySignedImportRequest(request: Request): boolean;
export const importRows = mutation({
csrf: false,
csrfJustification: 'the gateway verifies a signed X-Import-Key header',
guard: verifySignedImportRequest,
input: s.object({ batchId: s.string() }),
machineReplayPrincipal: (request) => request.headers.get('X-Import-Tenant') ?? '',
handler: (input) => input,
});The signature verifier should cover the public X-Import-Tenant value. The selector runs once after
that verifier succeeds. Return a stable public caller or tenant id, not the credential itself. Kovo
hashes the exact UTF-16 code-unit sequence before building the replay key. A missing or invalid value
fails before the replay store and handler.
Idempotency and replay#
Each emitted form carries a Kovo-Idem token. It is fresh for each logical submit, not a hidden
form-instance constant, and enhanced success responses refresh it for the next submit. The replay
store atomically reserves the caller binding, mutation, and token after input parsing and the current
guard chain. A duplicate or concurrent submit replays the settled response and does not execute the
handler again. Protected browser writes use the CSRF/session binding. csrf: false machine writes
use machineReplayPrincipal. Enhanced and no-JavaScript delivery share one claim, so switching
delivery modes with the same token returns a conflict instead of running the write twice.
Review the security graph#
Security review's first questions are answerable from the committed graph.json without executing a
browser. Each review mode prints a stable, diffable table you can run in CI.
Run it#
Hit one guarded route logged out, then run one graph review mode:
curl -i http://localhost:3000/account
kovo explain unguarded dist/.kovo/graph.jsonThe route should redirect or render the configured unauthorized shell before private data appears.
The review command should either print SUMMARY total=0 or name the reachable mutation, route, or
query you need to guard.
kovo explain unguarded graph.json # reachable without an authed guard
kovo explain unscoped graph.json # owner-annotated rows not provably session-scoped (IDOR)
kovo explain endpoints graph.json # machine ingress: auth scheme + CSRF posture
kovo explain revealed graph.json # confidential fields intentionally revealed
kovo explain trust graph.json # trusted HTML/SQL/URL escapes and their evidence
kovo explain access graph.json # explicit public/authenticated/machine access decisions
kovo explain cookies graph.json # cookie posture and downgrade findings
kovo explain sources-sinks # source/sink inventorykovo explain unguarded — what's reachable without auth#
Lists every mutation, route, and query reachable without an authed guard. Queries count because
every query is addressable over GET at /_q/<key> and its guard runs on every read. Clean output on
the commerce app:
kovo-explain/v1
UNGUARDED
SUMMARY total=0A finding adds one line per reachable item above the summary, so a guard dropped in a refactor turns CI red instead of landing quietly.
kovo explain unscoped — the IDOR audit#
Lists every query and write touching an owner:-annotated table whose key predicate the analyzer
can't trace to req.session — data that should be scoped to its owner but provably might not be:
kovo-explain/v1
UNSCOPED
UNSCOPED QUERY cartById domain=cart scope=args site=cart.queries.ts:21
SUMMARY total=1The fix is to scope the predicate to a session field as shown above; the line disappears when the extractor can trace it.
kovo explain endpoints — the machine-ingress table#
The stable machine-ingress audit: every declared endpoint() and webhook(), plus every route
returning respond.file()/respond.stream(). Each row lists name, method, path, mount mode, auth
scheme (session+guard, verifier:<scheme>, custom:<name>, or none:<justification>), CSRF posture
(checked or exempt:<justification>), and for webhooks the write→domain chain:
kovo-explain/v1
ENDPOINTS
ENDPOINT app-shell/order-paid surface=webhook method=POST path=/webhooks/order-paid mount=exact auth=verifier:stripe-signature csrf=exempt:signed stripe webhook cache=no-store body=raw bodySize=- rateLimit=webhook:stripe headers=Stripe-Signature files=- dynamic=- writes=order
ENDPOINT echo surface=endpoint method=POST path=/api/echo-json mount=exact auth=public:public echo endpoint is CSRF checked csrf=checked cache=no-store body=json bodySize=- rateLimit=- headers=- files=- dynamic=- writes=-
ENDPOINT health surface=endpoint method=GET path=/healthz mount=exact auth=none:public uptime probe csrf=safe:read-only cache=no-store body=json bodySize=- rateLimit=- headers=- files=- dynamic=- writes=-
ENDPOINT inventory/download surface=route-file method=GET path=/downloads/inventory.bin mount=exact auth=custom:api-key csrf=safe:read-only cache=private,no-store body=bytes bodySize=stream rateLimit=download:user headers=Content-Disposition,Content-Type files=inventory.bin dynamic=- writes=-
SUMMARY total=4This answers "what can reach this app, and what can it touch?" — the report is snapshot-locked with
the rest of the explain output, so a new endpoint or a csrf: false opt-out can't slip in unreviewed.
kovo explain revealed — confidential data crossing the boundary#
Lists every reviewed confidentiality reveal. A proof-grade row comes from a statically analyzed projection that excludes secret columns. An audit-grade row comes from an explicit typed declassification policy outside the request-closed module graph and must be reviewed like any other escape hatch.
kovo explain access — default-deny access decisions#
Lists the explicit access decision for each query, mutation, route/page, endpoint, or webhook. A
missing row is a build-blocking access gap; add a guard chain, publicAccess("reason"), or verified
machine auth rather than relying on default reachability.
Capability-style powers#
Review capability-style powers through the concrete surfaces that create them today:
kovo explain revealed for audit-grade typed declassification, kovo explain trust for trusted sink escapes,
kovo explain endpoints for signed download endpoints, and kovo explain sources-sinks for the raw capability APIs.
Read across owners in an admin tool#
Most Postgres reads stay owner-scoped. If a reviewed admin tool needs rows across owners, opt the
table into the Postgres runtime and keep the read behind guards.role('admin'):
import { sql } from '@kovojs/drizzle';
import { guards, query } from '@kovojs/server';
type AdminOrderRow = { id: string; total: number };
const adminOrdersDefinition = {
guard: guards.role('admin'),
load: (_args, context?: { db?: any }): AdminOrderRow[] =>
context?.db?.crossOwnerRead(sql`SELECT id, total FROM ${orders}`, {
reads: ['orders'],
reason: 'admin order export',
role: 'admin',
}) ?? [],
};
export const adminOrders = query(adminOrdersDefinition);crossOwnerRead is the capability name. The role: 'admin' declaration is the runtime role posture
that must match a passed guards.role('admin') request guard and the generated Postgres
kovo_admin_scope policy. Kovo records the reason and principal for review. The admin role posture
is visible through the access and source/sink review modes. Reading as one other user remains
ctx.actAs(id) in endpoint({ db: true }).
Keep confidential data off the wire#
Mark confidential fields at the data boundary. A secret field is not eligible for the client query
wire or a client module. That keeps readable HTML and query frames from becoming a data leak.
import { kovo } from '@kovojs/drizzle';
import { pgTable, text } from 'drizzle-orm/pg-core';
export const users = pgTable(
'users',
{
id: text('id').primaryKey(),
email: text('email').notNull(),
passwordDigest: text('password_digest').notNull(),
},
kovo((columns) => ({ domain: 'user', secret: [columns.passwordDigest] })),
);Project only the fields the UI needs:
import { domain, guards, query } from '@kovojs/server';
interface SupportRequest {
session?: { user?: { roles: readonly string[] } | null } | null;
}
const user = domain('user');
declare const users: any;
const supportRead = guards.role<SupportRequest>('support');
export const supportUsers = query({
guard: supportRead,
async load(_input: unknown, context?: { db?: any }): Promise<{ email: string; id: string }[]> {
return context?.db?.select({ id: users.id, email: users.email }).from(users) ?? [];
},
reads: [user],
});An unresolved projection from a table with secret columns is reported as a confidential-data
diagnostic. Remove the secret field, or rewrite the projection so the analyzer can prove that only
public columns cross the wire. Request-reachable modules cannot import DeclassifyPolicy or any
reveal door, even through a helper or re-export; capability closure rejects that graph instead of treating human
review as authorization to release request-selected data.
Prevent mass assignment#
Do not let request input choose server-owned columns. Mark those columns governed, then write them
through explicit server provenance.
import { kovo } from '@kovojs/drizzle';
import { pgTable, text } from 'drizzle-orm/pg-core';
export const accounts = pgTable(
'accounts',
{
id: text('id').primaryKey(),
displayName: text('display_name').notNull(),
role: text('role').notNull(),
},
kovo((columns) => ({ domain: 'account', governed: [columns.role] })),
);This is the bad shape:
await db.update(accounts).set({ displayName: input.displayName, role: input.role });role came from the request, so Kovo reports a governed-write diagnostic. Use a server-derived value or an explicit
trusted assignment instead:
import { trustedAssign, serverValue } from '@kovojs/server/write-safety';
await db.update(accounts).set({
displayName: input.displayName,
role: serverValue('member', 'default role'),
});
await db.update(accounts).set({
role: trustedAssign(input.role, {
invariant: 'governed-write.authorized-principal',
why: { kind: 'guard-chain', guard: 'guards.role:admin' },
evidence: {
kind: 'test',
reference: 'tests/authz/admin-role-editor',
digest: 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
},
}),
});serverValue(...) documents a literal or private value that Kovo already proves is not request
input. It does not accept an opaque helper result. Use trustedAssign(...) for an intentionally
reviewed opaque computation or an authorized admin write. Keep the obligation inline: prose,
variables, spreads, computed fields, and malformed evidence fail kovo check. The build writes the
unsigned subject to .kovo/escape-obligations.json; your out-of-band reviewer signs it for
kovo explain attest ... --escape-reviews reviews.json. That signature records review of these
exact bytes. It does not prove the policy is correct.
Serve file downloads with capability URLs#
Do not hand-build storage URLs or raw download endpoints. Use a framework download endpoint and mint a short-lived signed URL from the request context.
import { createStorageDownloadEndpoint } from '@kovojs/server/storage-downloads'
import { guards, route } from '@kovojs/server'
import { scopedKey } from '@kovojs/server/storage-keys';
export const downloads = createStorageDownloadEndpoint({
basePath: '/downloads',
secret: process.env.KOVO_CAPABILITY_SECRET!,
storage: invoicesBucket,
scope: () => 'invoice-download',
});
export const invoiceRoute = route('/account/invoice', {
guard: guards.authed(),
page: async ({ signUrl }, req: { session: { user: { id?: string } } }) => {
const signed = await signUrl!({
key: scopedKey(req, `invoices/${req.session.user.id}/latest.pdf`),
scope: 'invoice-download',
expiresIn: 10 * 60 * 1000,
});
return <a href={signed.url}>Download latest invoice</a>;
},
});signUrl(...) uses signCapability under the hood. The download endpoint verifies the method, key,
expiry, and scope before any storage read. A leaked URL is still a bearer credential, so keep the
expiry short and prefer one-time URLs for sensitive exports. Review the declared download endpoint in
kovo explain endpoints and the signing call in kovo explain sources-sinks.
Source/sink boundaries#
The same audit posture applies outside SQL. Treat every value that crossed a request, session, database, model, generated DOM stamp, static-export path, or environment boundary as a source until a Kovo parser, schema, guard, or trust API narrows it. Treat every output that can execute, navigate, select, cache, download, store, or authorize as a sink.
| Source | Safe Kovo path | Dangerous sink | Escape hatch |
|---|---|---|---|
| Request params, search, forms, query args, headers, cookies | Route/query/mutation schemas, CSRF, guards, typed redirects | HTML text/attributes, URL attrs, redirects, selectors | trustedHtml / trustedUrl with provenance |
| Session and provider state | session(s.object(...)), sessionProvider, guards.authed, owner: predicates |
Owner-table reads/writes, auth redirects, cacheable private reads | Public-read or custom guard justification |
| Raw endpoint or webhook body | endpoint() audit metadata, executable verifier auth, webhook() verify-before-parse lifecycle |
Raw Response, Location, headers, cookies, file/stream output |
Raw endpoint purpose plus verifier/custom/none justification |
| Database, model, or streamed text | Query output schemas, <kovo-query>, <kovo-text>, contextual escaping |
SQL text, raw HTML insertion, script/JSON islands, stream renderers | trustedSql / trustedHtml from reviewed renderer code |
| Files, storage keys, manifests, static export paths | Capability URLs, createStorageDownloadEndpoint, respond.file, respond.stream, containment checks, static-export validation |
Filesystem/S3 paths, Content-Disposition, inline HTML/SVG/MIME |
ctx.signUrl / signCapability with per-object scope |
| Framework code paths and generated artifacts | Shared source/sink registry plus drift detection | innerHTML, Headers, querySelector, dynamic import, eval/process/fs |
Narrow repo-internal exclusion with evidence |
Common app code rules are intentionally blunt:
- Never interpolate request, session, database, model, or generated-DOM data into HTML, URL, SQL, headers, cookies, filesystem paths, or raw endpoints without the matching Kovo safe helper or a named trust API.
- Do not present CSV, TSV, spreadsheet, or formula hardening as a Kovo-supported safe-by-default lane. If an app exports spreadsheet-readable data, it is app-owned raw endpoint/download code behind its own security review.
- Prefer typed
mutation(),query(),route(), capability downloads, cookies, and verifier helpers over hand-built response strings. When you need an escape hatch, make it show up inkovo explain.
For agentic or LLM-backed features, treat model output like any other untrusted source until a Kovo schema, guard, or sink-specific trust API narrows it. Prompt injection is about confused instructions; Kovo can narrow what a compromised model action may touch, not prove the model will ignore malicious content.
Next#
- Confidential values - mark secrets, redactions, and untrusted input before they reach the wire.
- Outbound requests & egress - tighten third-party HTTP destinations instead of letting them drift open.
- Mutations & forms — the guarded request lifecycle and the 422 path.
- Endpoints & webhooks — machine ingress and CSRF exemptions.
- Reading kovo check & kovo explain — the review modes as CI assertions.
- Domains, writes & data access — where
owner:annotations live.
Spec & diagnostics
The guard chain, combinators, and the kovo explain unguarded / kovo explain unscoped audits: SPEC §10.3 (verified
against examples/commerce/src/domain.ts and @kovojs/server's guards). Typed sessions, the
sessionProvider, and guard-failure outcomes: SPEC §6.5. CSRF default-on, anonymous-CSRF, KV418, the
kovo-csrf token, and the soundness boundary: SPEC §6.6, §9.1. Per-submit Kovo-Idem and replay
reservation: SPEC §10.3. The typed read endpoint and per-read guard checks: SPEC §9.4. Live-push
guard re-checks (fragments must not become a privilege-escalation channel): SPEC §9.3. The owner:
annotation and exempt: SPEC §10.1. The verification surface and kovo explain endpoints machine-ingress
audit: SPEC §11.4. Confidential data and typed declassification: SPEC §6.6, KV435/KV448. Governed write
provenance, structured obligations, and detached review signatures: SPEC §6.6, §10.3, §11.4,
KV438. Capability URLs for storage downloads:
SPEC §6.6. Typed mutation error path: SPEC §9.2.
API reference: @kovojs/core, @kovojs/drizzle, @kovojs/server.