Menu

Guides

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:

ts
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 when request.session?.user is present; refines the request type so req.session.user is non-null inside the handler. A null/undefined session 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), checking req.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):

ts
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):

ts
// 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:

ts
// 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:

ts
// 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:

ts
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:

ts
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):

sh
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=0

A 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=1

The 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=1

This 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#

  1. Type the session with session(s.object(...)) — guards and query keys depend on it.
  2. Resolve it once with a sessionProvider; treat null as anonymous.
  3. Guard from the bottom upauthed on anything per-user, role on admin surfaces, rateLimit on mutations, guards.all(...) to compose.
  4. Annotate owner: on every per-user table and scope predicates to req.session, never to client input.
  5. Leave CSRF on; justify every csrf: false and confirm it in --endpoints.
  6. Run the three audits in CI with fail-on-findings, next to kovo check.

Next#

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.