Valet

Security Model

Authentication, authorization, and data isolation.

Valet's security model has several layers: JWT authentication, table name validation, sync filters for row-level security, and deploy key protection for admin endpoints. This page documents how they fit together.


Authentication flow

Client
WebSocket connect
Hello { protocol_version: 4 }
Auth { token }

Validate JWT: Development mode (trust token) or HMAC mode (verify signature, check exp/iss/aud). Extract AuthContext { userId, claims }.

AuthOk { user_id }
Now in authenticated message loop
Server

Auth configuration

Env varPurpose
VALET_AUTH_SECRETHMAC secret for JWT validation. If unset, runs in development mode (any token accepted).
VALET_AUTH_ISSUERExpected iss claim (optional).
VALET_AUTH_AUDIENCEExpected aud claim (optional).

Development mode is intended for local development only. In production, always set VALET_AUTH_SECRET.

Built-in auth service

When VALET_AUTH_SECRET is set, the server also exposes HTTP auth endpoints:

POST /auth/signUp { email, password, name? }{ token, user, expiresAt }
POST /auth/signIn { email, password }{ token, user, expiresAt }
POST /auth/signOut { sessionId }{ success }

Auth data is stored in four internal tables (_auth_users, _auth_accounts, _auth_sessions, _auth_verification_codes). These tables do not have change tracking triggers and are not synced to clients.


Table name validation

validate_table_name() in document.rs is the security boundary for all DocumentOps calls (handler ctx.db operations and sync writes).

Rules:
├── Must start with a letter (a-z, A-Z)
├── 1-64 characters, alphanumeric + underscore only
├── Rejects reserved prefixes:
├── "sqlite_" — SQLite internal tables
└── "valet" — Valet internal tables (_valet_changes, _valet_version, _valet_meta)
└── Prevents SQL injection (no quotes, spaces, or special characters)

This means handler code (ctx.db.insert(...)) cannot access internal tables. The built-in auth service bypasses DocumentOps entirely — it uses raw SQL against the _auth_* tables directly.


Row-level security (sync filters)

Sync filters are the primary mechanism for data isolation between users. They control which rows are synced to each client.

How sync filters work

Defined in schema:

defineTable({
  userId: v.string(),
  text: v.string(),
}).sync({
  mode: 'full',
  filter: (q, ctx) => q.eq('userId', ctx.auth.userId),
})

Codegen serializes the filter with $var references:

{ "field": "userId", "op": "eq", "value": { "$var": "auth.userId" } }

At runtime, the server resolves $var references against the client's auth context:

resolve_var_path("auth.userId", auth_context)
├── Walks the dotted path through the auth context JSON
├── "auth" → { "userId": "user_123", "claims": {...} }
├── "userId" → "user_123"
└── Returns Value::String("user_123")

Filter composition

When a client subscribes to a table, the server composes filters:

effective_filter = table_sync_filter AND subscription_filter

The table-level sync filter (from schema) is ANDed with any per-subscription filter (from the query). This means the sync filter acts as a mandatory security baseline — subscriptions can only narrow the results, never widen them.

Without sync filters

If a table has no sync filter (sync: { mode: 'full' }), all rows are synced to every connected client. This is appropriate for shared/public data but dangerous for user-specific data.


Mutation authorization

All mutations are server-authoritative. Every mutation follows the same flow:

  1. The handler runs optimistically on the client for an instant UI update (when offline or using SyncEngine)
  2. The mutation is sent to the server as function + args
  3. The server re-executes the handler in the boa JS runtime
  4. The server result is the authoritative outcome

Because the handler always re-executes on the server, mutation handlers should include authorization checks using ctx.auth. Do not rely solely on sync filters for mutation authorization — the handler code is your primary enforcement point.


Orchestrator deploy keys

The orchestrator validates deploy keys at the proxy layer before requests reach individual valet-server instances.

Client request

Orchestrator proxy (proxy.rs)
├── Parse route → /projects/<id>/...
├── Extract Authorization: Bearer <key>
├── registry.validate_deploy_key(project_id, key)
├── Valid: proxy to valet-server
└── Invalid: 401 Unauthorized
└── Auth routes (/auth/*) bypass deploy key check

Deploy keys are 256-bit random hex values, generated when a project is created via POST /projects. They authenticate the deployer (CI/CD, CLI), not end users. End user authentication goes through the JWT flow described above.


Security boundaries summary

Orchestrator
Deploy key validation (proxy.rs)
├── Protects all non-auth routes
└── Per-project key from ProjectRegistry
↓ proxied to
valet-server
JWT validation (auth.rs)
├── Required for WebSocket connections
└── Produces AuthContext for the session
Table name validation (document.rs)
├── Blocks sqlite_, valet prefixes
└── Applies to all DocumentOps (ctx.db)
Sync filters (matcher.rs)
├── Per-table row-level filtering
├── $var resolution against AuthContext
└── ANDed with subscription filters
Auth tables (auth_tables.rs)
└── Accessed only via raw SQL, not DocumentOps

On this page