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
Validate JWT: Development mode (trust token) or HMAC mode (verify signature, check exp/iss/aud). Extract AuthContext { userId, claims }.
Auth configuration
| Env var | Purpose |
|---|---|
VALET_AUTH_SECRET | HMAC secret for JWT validation. If unset, runs in development mode (any token accepted). |
VALET_AUTH_ISSUER | Expected iss claim (optional). |
VALET_AUTH_AUDIENCE | Expected 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:
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).
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:
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:
- The handler runs optimistically on the client for an instant UI update (when offline or using SyncEngine)
- The mutation is sent to the server as
function+args - The server re-executes the handler in the boa JS runtime
- 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
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.