Menu

Guides

View as Markdown

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.

ts
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 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.
  • 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: 'ip' | 'session' | 'global', with a keyed variant for per-tenant limits. per: 'ip' keys from req.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#

  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 up: authed on per-user reads, role on admin surfaces, rateLimit on actions, guards.all(...) to compose.
  4. Annotate owner: on every per-user table and scope predicates to req.session, never to client input.
  5. Classify confidential columns with secret; project only provably public fields. Keep declassification out of every request-reachable module and inspect kovo explain revealed.
  6. Govern server-owned columns with governed; write them through serverValue(...) or trustedAssign(...), never from request input.
  7. Leave CSRF on; justify every csrf: false and confirm it in kovo explain endpoints.
  8. Use capability URLs for downloads: mint with ctx.signUrl(...), serve through createStorageDownloadEndpoint, and review the download endpoint in kovo explain endpoints.
  9. 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, and kovo explain sources-sinks. Verify detached escape reviews with kovo explain attest before deployment.
  10. 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:

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#

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:

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

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

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

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

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

sh
curl -i http://localhost:3000/account
kovo explain unguarded dist/.kovo/graph.json

The 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.

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
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 inventory

kovo 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=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.

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

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

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.

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

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

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

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

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

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

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

ts
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 in kovo 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#

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.