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| Configuration | Behavior |
|---|---|
pub: true | No 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 / cache | No 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 |
quota | Internal-only by default; expose via wrapper mutation that calls consume() |
Ownership enforcement
| Table type | Who can create | Who can update/delete |
|---|---|---|
owned | Any authenticated user | Row owner only |
orgScoped | Org members only | Row owner, org admin, or owner |
children | Parent row owner only | Parent row owner only |
singleton | Any authenticated user | Owner only (1:1 per user) |
log | Parent owner (and bypassed via pub parent-field) | Author only via update/rm; purgeByParent owner-of-parent only |
kv | Anyone passing writeRole(ctx) | Same predicate gates set/rm/restore |
quota | Internal 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
| Role | Permissions |
|---|---|
owner | All permissions. Transfer ownership. Delete org. |
admin | Manage members. Invite/remove. Approve join requests. Full CRUD. |
member | CRUD 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/minmax 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 vector | Pattern removed |
|---|---|
| Script injection | <script>...</script> tags |
| Event handler injection | onclick=, onerror=, any on*= attributes |
| Protocol-based XSS | javascript: protocol in URLs |
| Data URI injection | data:text/html URIs |
| Dangerous HTML elements | <iframe>, <object>, <embed>, <applet>, <form>, <base>, <meta> |
| HTML entity obfuscation | Encoded angle brackets (<, <, >, >) |
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 updateauditLog(opts?)— logs every create, update, and delete withuserId,table, andid. Setverbose: trueto include the data payloadslowQueryWarn(opts?)— logs a warning when an operation exceedsthresholdms (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
| Code | Meaning |
|---|---|
NOT_AUTHENTICATED | No session — user not logged in |
NOT_FOUND | Row doesn't exist or caller lacks access |
FORBIDDEN | Caller lacks permission (non-owner write, insufficient role) |
NOT_ORG_MEMBER | Caller is not a member of the org |
INSUFFICIENT_ORG_ROLE | Caller's role is below the required minimum |
EDITOR_REQUIRED | ACL check failed — caller not in editors list |
VALIDATION_FAILED | Input failed Zod validation — includes field-level errors |
CONFLICT | Row was modified since expectedUpdatedAt |
RATE_LIMITED | Too many mutations — includes retryAfter (ms) |
LIMIT_EXCEEDED | Bulk operation exceeds 100-item cap |
DUPLICATE | Unique 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 pattern | Uses 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 query | Yes | Millions |
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 modeFor 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
authtier or omitpubentirely. - Skipping
requireOrgMemberin custom org queries. If you write a custom query that reads org data, callrequireOrgMemberyourself. 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
userIdargument from the client for ownership purposes. - Subscribing to entire large tables without filtering (SpacetimeDB). Every subscribed row is held in client memory.