Security & authorization
Kovo's security model is built so the hard questions — what's reachable without auth, which rows
leak across users, what a deploy exposes to the public internet — are answerable from a committed
artifact instead of a manual audit. This guide collects that model in one place: the guard
combinators shared by routes, queries, and mutations; typed sessions and the sessionProvider; the
owner:/IDOR authorization story; CSRF; and the three kovo explain audits that turn all of it into
CI gates.
Guards: one combinator chain everywhere#
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, mutation, route } from '@kovojs/server';
// composable: short-circuits on the first denial
export const addToCart = mutation('cart/add', {
guard: guards.all(
guards.authed<CommerceRequest>(),
guards.rateLimit<CommerceRequest>({ max: 10, per: 'session' }),
),
// …
});
export const adminRefund = mutation('admin/refund', {
guard: guards.role('admin'),
// …
});
export const adminPage = route('/admin', {
guard: guards.role<AdminRequest>('admin'),
page: () => <AdminDashboard />,
});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 (SPEC §6.5).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: 'session' | 'global', with a keyed variant for per-tenant limits.guards.all(...guards)— composes left to right and propagates the first denial as-is, so the §6.5 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 (default: 303 redirect to the login route with the original URL
as next); authorize-but-unauthorized failures render the 403 shell with status 403. Mutation guard
failures take the typed-error path (no redirect vocabulary on enhanced responses) — see
the 422 path (SPEC §6.5, §9.2).
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 (SPEC §6.5):
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#
Session provenance is an application capability, not a framework-owned identity system. 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 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 (SPEC §6.5):
// the provider adapts your auth library to the declared session shape
export const commerceSessionProvider = commerceSession.provider(
betterAuthSession(commerceBetterAuth, ({ session: authSession, user }) => ({
id: authSession.id,
user: { id: user.id, roles: user.roles },
})),
);
// wired into the request shell alongside routes, mutations, and the db provider
createApp({
sessionProvider: commerceSessionProvider,
// routes, mutations, queries, csrf, db, …
});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
export const orders = pgTable(
'orders',
{
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
total: integer('total').notNull(),
},
kovo({ domain: 'order', owner: (t) => t.userId }),
);Then scope every read and write of that table to the session, not to client input:
// CORRECT: the user id comes from req.session, traceable by the predicate extractor
export const orderHistoryQuery = query('orderHistory', {
guard: authed,
load: (db, _args, req) => db.select().from(orders).where(eq(orders.userId, req.session.user.id)),
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 --unscoped audit below — the same §11.1 predicate extractor
that derives row keys does the tracing (SPEC §10.1, §10.3). 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 session-bound synchronizer token stamped into every emitted mutation form. The server
verifies it first — before schema parsing, before replay lookup, before the guard chain — on every
mutation POST (SPEC §6.6, §9.1). Note the wire field name is kovo-csrf; the app-side config object
carries a field (e.g. 'csrf') plus the signing secret and a sessionId resolver:
export const commerceCsrf = {
field: 'csrf',
secret: process.env.CSRF_SECRET!,
sessionId(request: CommerceRequest) {
return request.session?.id;
},
};
export const addToCart = mutation('cart/add', { csrf: commerceCsrf /* … */ });You render the field with csrfField, though enhanced forms emit it for you:
import { csrfField } from '@kovojs/server';
const csrf = csrfField(request, commerceCsrf); // → <input type="hidden" name="csrf" value="…">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 — and every
opt-out shows up in the --endpoints audit with its justification (SPEC §6.6, §11.4).
The three static audits#
Security review's first three questions are answerable from the committed graph.json without
executing a browser. Each is a kovo explain mode that prints a stable, diffable table you run in CI
with fail-on-findings (SPEC §10.3, §11.4):
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--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 (SPEC §9.4). 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.
--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:orderHistory order via user_id key predicate not traceable to session
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.
--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 (SPEC §11.4):
kovo-explain/v1
ENDPOINTS
webhook:stripe POST /hooks/stripe prefix verifier:stripe-signature exempt:webhook order
SUMMARY total=1This 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.
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 anything per-user,roleon admin surfaces,rateLimiton mutations,guards.all(...)to compose. - Annotate
owner:on every per-user table and scope predicates toreq.session, never to client input. - Leave CSRF on; justify every
csrf: falseand confirm it in--endpoints. - Run the three audits in CI with fail-on-findings, next to
kovo check.
Next#
- Mutations & forms — the guarded request lifecycle and the 422 path.
- Reading kovo check & kovo explain — the audits as CI assertions.
- Domains, writes & data access — where
owner:annotations live.
Spec & diagnostics
The guard chain, combinators, and the --unguarded / --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, the kovo-csrf token, and
the soundness boundary: SPEC §6.6, §9.1. 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
--endpoints machine-ingress audit: SPEC §11.4. Typed mutation error path: SPEC §9.2.