noboil

Security

Auth enforcement, access control, rate limiting, input sanitization, and scalability guidance.

Security is enforced server-side on every read and every write. Clients never touch the database directly.

How it works

Read filters and write checks both run on the server, before any data is read or written. The client receives a result or an error code — nothing else. Visibility is enforced at the subscription / query layer, ownership is enforced at the mutation layer, and both are derived from your schema brand (makeOwned / makeOrgScoped / makeBase / makeSingleton / makeLog / makeKv / makeQuota).

For the platform-specific details (Convex's auth/pub destructure vs SpacetimeDB's clientVisibilityFilter SQL), see Convex vs SpacetimeDB.

Auth enforcement

Every generated create, update, rm enforces authentication. There's no way to call a write endpoint without a valid session — NOT_AUTHENTICATED is thrown before the handler runs.

const api = noboil({
  query, mutation, action, internalQuery, internalMutation, getAuthUserId,
  tables: ({ table }) => ({
    blog: table(s.blog, { rateLimit: { max: 10, window: 60_000 } })
  })
})

getAuthUserId is the integration point with @convex-dev/auth. SpacetimeDB injects identity automatically via ctx.sender.

Row-level security

Pass pub on a table to control what unauthenticated callers see.

table(s.blog, { pub: { where: { published: true } } })  // Convex
table(s.blog, { pub: 'published' })                      // SpacetimeDB
ConfigurationBehavior
pub: trueNo filter — table is fully public
pub: 'fieldName'Indexed filter WHERE fieldName = true OR userId = :sender
pub: { where: {...} }Default where clause for the public read tier (Convex)
No pub (owned)Owner-only: WHERE userId = :sender
No pub (singleton)Per-user: WHERE userId = :sender
No pub (orgScoped)Org membership: JOIN orgMember WHERE userId = :sender
base / cacheNo filter — cache tables are public by design
log (no pub)Owner-of-parent only: WHERE EXISTS(parent WHERE userId = :sender)
log with pub: 'parentField'Public when parent's flag is true, otherwise owner-only
kv (always)Public reads (get/list); writes gated by writeRole predicate
quotaInternal-only by default; expose via wrapper mutation that calls consume()

Ownership enforcement

Table typeWho can createWho can update/delete
ownedAny authenticated userRow owner only
orgScopedOrg members onlyRow owner, org admin, or owner
childrenParent row owner onlyParent row owner only
singletonAny authenticated userOwner only (1:1 per user)
logParent owner (and bypassed via pub parent-field)Author only via update/rm; purgeByParent owner-of-parent only
kvAnyone passing writeRole(ctx)Same predicate gates set/rm/restore
quotaInternal mutations only (wrap in your own gated reducer)Same

A user who doesn't own a row gets NOT_FOUND, not FORBIDDEN. This prevents enumeration — callers can't distinguish "doesn't exist" from "exists but not yours".

Conflict detection

Update endpoints accept an optional expectedUpdatedAt parameter. If the row was modified since the client last read it, the update throws CONFLICT. This prevents lost-update races in collaborative editing without requiring transactions. The built-in ConflictDialog (see Components) handles the resolution UI.

Organization security

Membership checks

Every org-scoped endpoint checks org membership before any read or write — there's no way to skip it. The create_* reducer requires orgId in addition to the fields and verifies the caller belongs to that org. update_* and rm_* check that the caller is either an org admin or the original creator.

Roles

RolePermissions
ownerAll permissions. Transfer ownership. Delete org.
adminManage members. Invite/remove. Approve join requests. Full CRUD.
memberCRUD own resources within the org. Leave org.

Role levels are compared numerically (owner: 3, admin: 2, member: 1). Bulk operations require at least admin.

Editor ACL

For per-item permissions beyond role-based access, enable acl: true:

table(s.wiki, { acl: true, softDelete: true })

This generates addEditor, removeEditor, setEditors, and editors endpoints. The editors field stores an array of user IDs (or Identity arrays on SpacetimeDB). Owners and admins can always edit. Members can edit their own rows. Members listed in editors can also edit. Adding an editor requires admin role and verifies the target is an org member — you can't grant access to outsiders.

Membership flow

  • Invite: admin/owner sends invite by email, recipient accepts with a token
  • Join request: user requests to join, admin/owner approves or rejects
  • Leave: any member can leave (except sole owner, who must transfer first)
  • Remove: admin can remove members, owner can remove admins

Invite tokens are 24 random bytes (crypto.getRandomValues()) encoded as a 32-character base-36 string. No Math.random(), no Date.now().

Rate limiting

Built-in sliding window rate limiting on mutations:

table(s.blog, { rateLimit: { max: 10, window: 60_000 } })
table(s.blog, { rateLimit: 10 })  // shorthand: max 10/min

max requests per window (ms) per authenticated user per table. Uses a single-row counter — no write amplification. When the window expires, the counter resets. Exceeding the limit throws RATE_LIMITED with a retryAfter value in milliseconds.

Rate limiting is skipped in test mode so tests don't need to mock it.

Input sanitization

Every string field is stripped of dangerous content before any database write.

Attack vectorPattern removed
Script injection<script>...</script> tags
Event handler injectiononclick=, onerror=, any on*= attributes
Protocol-based XSSjavascript: protocol in URLs
Data URI injectiondata:text/html URIs
Dangerous HTML elements<iframe>, <object>, <embed>, <applet>, <form>, <base>, <meta>
HTML entity obfuscationEncoded angle brackets (&#x3c;, &#60;, &#x3e;, &#62;)

The inputSanitize middleware runs on beforeCreate and beforeUpdate. Pass fields to target specific fields only:

inputSanitize({ fields: ['title', 'content'] })

Sanitization is applied recursively to all string values in nested objects and arrays.

Middleware system

Middleware is a composable security pipeline that runs around every CRUD operation. Configure it once in noboil() config and it applies to all tables:

import { auditLog, inputSanitize, slowQueryWarn } from 'noboil/convex/server'

noboil({
  query, mutation, action, internalQuery, internalMutation, getAuthUserId,
  middleware: [
    inputSanitize(),
    auditLog({ logLevel: 'info', verbose: true }),
    slowQueryWarn({ threshold: 300 })
  ],
  tables: ({ table }) => ({ /* ... */ })
})

Built-in middleware:

  • inputSanitize(opts?) — strips XSS patterns from string fields on create and update
  • auditLog(opts?) — logs every create, update, and delete with userId, table, and id. Set verbose: true to include the data payload
  • slowQueryWarn(opts?) — logs a warning when an operation exceeds threshold ms (default: 500)

Custom middleware:

import type { Middleware } from 'noboil/convex/server'

const notifyOnCreate: Middleware = {
  name: 'notifyOnCreate',
  afterCreate: async (ctx, { id }) => {
    await sendWebhook({ table: ctx.table, id, userId: ctx.userId })
  }
}

Middleware can also be applied per-table via hooks on individual table options, which compose with global middleware.

Error codes

CodeMeaning
NOT_AUTHENTICATEDNo session — user not logged in
NOT_FOUNDRow doesn't exist or caller lacks access
FORBIDDENCaller lacks permission (non-owner write, insufficient role)
NOT_ORG_MEMBERCaller is not a member of the org
INSUFFICIENT_ORG_ROLECaller's role is below the required minimum
EDITOR_REQUIREDACL check failed — caller not in editors list
VALIDATION_FAILEDInput failed Zod validation — includes field-level errors
CONFLICTRow was modified since expectedUpdatedAt
RATE_LIMITEDToo many mutations — includes retryAfter (ms)
LIMIT_EXCEEDEDBulk operation exceeds 100-item cap
DUPLICATEUnique constraint violation

On the client, use matchError or handleError:

import { matchError } from 'noboil/convex/server'

const msg = matchError(error, {
  NOT_FOUND: () => 'Item not found',
  RATE_LIMITED: d => `Slow down — retry in ${d.retryAfter}ms`,
  _: () => 'Something went wrong'
})

Scalability

Query patternUses index?Scales to
{ own: true }Yes (by_user)Millions
{ category: 'tech' }No (runtime filter)~1,000 docs
{ price: { $gte: 100 } }No (runtime filter)~1,000 docs
Custom-indexed queryYesMillions

For high-volume tables, add a custom index and use pubIndexed/authIndexed:

blog: ownedTable(s.blog).index('by_category', ['category'])

const techPosts = useQuery(api.blog.pubIndexed, {
  index: 'by_category',
  key: 'category',
  value: 'tech'
})

Enable strictFilter: true in noboil() config to throw instead of warn when a filter set exceeds 1,000 docs. Recommended in production.

import { warnLargeFilterSet } from 'noboil/convex/server'

const docs = await ctx.db.query('post').collect()
warnLargeFilterSet(docs.length, 'post', 'home-feed')
warnLargeFilterSet(docs.length, 'post', 'home-feed', true)  // strict mode

For SpacetimeDB-specific scaling guidance (chunk pattern, in-memory bounds), see Convex vs SpacetimeDB and Data fetching.

Pagination

Always paginate. Never call .collect() on large tables in custom queries.

const { items, hasMore, loadMore } = useList(api.blog.list, { where: { own: true } })

useList handles cursor management automatically. Start with small page sizes (pageSize: 20).

Data lifecycle

softDelete: true sets deletedAt instead of removing the row. This gives users an undo window and preserves audit history. Hard delete is the default and removes the row immediately, cleaning up any associated storage files. Soft delete filters out deleted rows from list queries automatically. The restore endpoint is generated when softDelete: true.

Anti-patterns

  • Exporting public reads for data that should require login. If the data is user-specific, use the auth tier or omit pub entirely.
  • Skipping requireOrgMember in custom org queries. If you write a custom query that reads org data, call requireOrgMember yourself. Generated endpoints do it automatically; custom code doesn't.
  • Using .collect() on large tables. Use .paginate() or index-based queries for tables that grow unboundedly.
  • Storing unbounded data without pagination or TTL. Tables that grow forever slow down queries proportionally.
  • Client-side access control. Don't hide UI elements and call it security. The server is the only enforcement point.
  • Passing user IDs from the client. Ownership comes from the server-side session. Never accept a userId argument from the client for ownership purposes.
  • Subscribing to entire large tables without filtering (SpacetimeDB). Every subscribed row is held in client memory.

On this page