createAuth() gives you a complete authentication system: sign-up, sign-in, JWT sessions with refresh token rotation, session management, rate limiting, and CSRF protection. One function call, no boilerplate.
Quick start
How sessions work
Vertz uses a dual-token model for sessions:| Token | Cookie | TTL | Purpose |
|---|---|---|---|
| JWT | vertz.sid | 60 seconds | Stateless authentication — verified by signature, no DB lookup |
| Refresh token | vertz.ref | 7 days | Long-lived, stored hashed in the session store. Used to get a new JWT. |
HttpOnly, Secure, SameSite=Lax by default.
Why 60-second JWTs? Short-lived JWTs are stateless (fast to verify, no DB hit) but limit the window of exposure if a token leaks. The refresh token handles continuity — your client refreshes transparently before the JWT expires.
Refresh token rotation
Every time a refresh token is used, it’s rotated — a new refresh token is issued and the old one is invalidated. This means a stolen refresh token can only be used once. A 10-second grace period handles concurrent requests: if two tabs hit/api/auth/refresh simultaneously, the second request still works within the grace window using the previous token hash.
Configuration
Key pair & algorithm
JWT operations use asymmetric keys. The algorithm defaults to RS256 but can be changed to ES256:| Algorithm | Key type | Use case |
|---|---|---|
RS256 (default) | RSA 2048 | General-purpose, widest compatibility |
ES256 | EC P-256 | Smaller signatures (64 vs 256 bytes), faster on edge runtimes |
- Production: You must provide
privateKeyandpublicKey(PEM strings matching the algorithm). The auth system throws if they’re missing or if the key type doesn’t match the algorithm. - Development: A key pair is auto-generated and saved to
.vertz/jwt-private.pemand.vertz/jwt-public.pem. Add.vertz/to your.gitignore. If you change the algorithm, the dev keys are automatically regenerated.
GET /api/auth/.well-known/jwks.json for external verification.
Server-side API
Theauth.api object lets you call auth operations from server code — useful in custom actions, services, or tests.
Session management
List, revoke, and manage active sessions from server code. Every method requires the caller’sHeaders to authenticate the request — the user can only manage their own sessions.
Email verification
Enable email verification to require users to confirm their email address after sign-up. When enabled, new users are created withemailVerified: false, and a verification token is sent via your onSend callback.
EmailVerificationStore
InMemoryEmailVerificationStore works for development. Provide your own store for production persistence.
Password reset
Enable password reset to let users recover their account when they forget their password. Tokens are single-use and expire after the configured TTL.revokeSessionsOnReset is true (the default), all active sessions for the user are revoked after the password is changed. This forces re-authentication on all devices.
PasswordResetStore
InMemoryPasswordResetStore works for development. Provide your own store for production persistence.
Middleware
The auth middleware injectsctx.user and ctx.session into your request context:
Rules builder API
Therules object provides declarative access rule builders for entity access definitions. Each builder returns a plain data structure (no evaluation logic) that the access context evaluates at runtime.
rules.public
Marks an endpoint as public — no authentication required. This is a constant (not a function call).
rules.authenticated()
Requires the user to be authenticated. No specific role is needed.
rules.role(...roles)
Requires the user to have at least one of the specified roles (OR logic).
rules.entitlement(name)
Requires the user to have the specified entitlement. This resolves roles, plans, and feature flags from your defineAccess() configuration.
rules.where(conditions)
Row-level access control. Adds conditions that are checked against the entity row. Use rules.user.id and rules.user.tenantId as dynamic placeholders that resolve to the current user’s values at evaluation time.
Database-level enforcement
rules.where() conditions are pushed directly into the SQL query for all operations — list, get, update, and delete. The row is never fetched from the database unless it matches the access rule.
- Zero row leakage — the database filters rows before they reach application code. A user can’t infer whether a row exists based on 403 vs 404 responses.
- TOCTOU protection — for
updateanddelete, the where condition is applied to both the initial lookup and the mutation query, preventing race conditions where a concurrent write could change the row between check and action.
rules.where() inside rules.any() is still evaluated in memory, not at the database level.
Database extraction only works with AND logic (rules.all()), because OR logic could bypass
enforcement.rules.all(...rules)
Combines multiple rules with AND logic. All sub-rules must pass.
rules.any(...rules)
Combines multiple rules with OR logic. At least one sub-rule must pass.
rules.fva(maxAge)
Requires the user to have completed MFA verification within the specified number of seconds. This is used for step-up authentication on sensitive operations.
rules.user
Declarative user markers resolved at evaluation time. Available markers:
| Marker | Resolves to |
|---|---|
rules.user.id | The current user’s ID |
rules.user.tenantId | The current user’s tenant ID |
rules.where() for row-level access checks (see example above).
AuthorizationError
AuthorizationError is thrown by ctx.authorize() (and accessContext.authorize()) when the user lacks the required entitlement. It extends Error with two additional properties.
| Property | Type | Description |
|---|---|---|
entitlement | string | The entitlement that was denied |
userId | string | undefined | The user ID that was denied (undefined if unauthenticated) |
message | string | Human-readable error message |
name | string | Always 'AuthorizationError' |
DB-backed stores
When you pass bothdb and auth to createServer(), Vertz auto-wires DB-backed stores and returns a ServerInstance with .auth and .initialize():
role, emailVerified, id, and timestamps are not
assignable through signUp(). Set privilege-bearing fields through your own trusted admin or
database flows instead.
The authModels export provides table definitions for all auth stores: sessions, users, OAuth accounts, role assignments, closure table entries, plan assignments, flags, and more. These are registered in your createDb() call alongside your application models.
Individual DB stores
If you need finer control, you can use the DB-backed store classes directly:Pluggable stores
All storage is abstracted behind interfaces. The defaults use in-memory stores — swap them for database-backed implementations in production.SessionStore
UserStore
RateLimitStore
Security
Built-in protections — no configuration required:| Protection | How |
|---|---|
| CSRF | Validates Origin/Referer headers + requires X-VTZ-Request: 1 on mutations |
| Rate limiting | Per-endpoint limits (see table below) |
| Timing-safe | Constant-time hash comparison prevents timing attacks on passwords and tokens |
| User enumeration | Dummy bcrypt comparison on unknown emails — response timing is identical |
| Session limits | Max 50 sessions per user — oldest auto-revoked on overflow |
| Secure cookies | HttpOnly, Secure, SameSite=Lax by default |
| Cache headers | All auth responses include Cache-Control: no-store |
Rate limits per endpoint
| Endpoint | Max attempts | Window | Key |
|---|---|---|---|
POST /signup | 3 | 1 hour | Per email |
POST /signin | 5 | 15 min | Per email |
POST /refresh | 10 | 1 min | Per IP |
GET /oauth/:provider | 10 | 5 min | Per IP |
POST /mfa/challenge | 5 | 15 min | Per IP |
POST /mfa/step-up | 5 | 15 min | Per IP |
POST /resend-verification | 3 | 1 hour | Per user ID |
POST /forgot-password | 3 | 1 hour | Per email |
emailPassword.rateLimit. All other limits are fixed defaults. When a rate limit is hit, the endpoint returns 429 Too Many Requests (except /forgot-password, which always returns 200 to prevent email enumeration).
Access control with defineAccess()
Beyond basic ctx.authenticated() and ctx.role() checks, Vertz provides a full RBAC system with resource hierarchies, role inheritance, plan-based entitlements, and usage limits.
Defining the access model
defineAccess() declares your entire authorization model in one place. The API is entity-centric — each entity is a self-contained group with its own roles. Hierarchy is inferred from inherits declarations.
inherits declarations. If a user is an owner on an organization, they inherit admin on all workspace children (via 'organization:owner': 'admin'), which inherits manager on all project grandchildren. No need to assign roles at every level.
5-layer resolution
When you callcan() or check(), the access context evaluates 5 layers in order:
| Layer | What it checks | Denial reason |
|---|---|---|
| 1. Feature flags | Whether the feature is enabled (stub — always passes for now) | flag_disabled |
| 2. RBAC | Does the user have a required role on the resource? | role_required |
| 3. Hierarchy | Does the role propagate via the closure table? (implicit via Layer 2) | hierarchy_denied |
| 4. Plan | Is the org on a plan that includes this entitlement? | plan_required |
| 5. Wallet | Has the org exceeded its usage limit for the billing period? | limit_reached |
can() short-circuits on the first denial (cheapest first). check() evaluates all layers and returns every reason, ordered by actionability.
Creating an access context
At request time, create an access context with the user’s identity and your stores:Checking entitlements
Access set for the client
computeAccessSet() builds a global snapshot of all entitlements for a user. This is embedded in the JWT and sent to the client for UI-advisory checks (the server always re-validates before mutations).
decodeAccessSet() restores the full shape (missing entitlements default to denied).
Plans and billing
ExtenddefineAccess() with plans to gate entitlements by subscription tier and enforce usage limits.
Defining plans
Plans use a feature-based shape. Each plan declares which entitlements it unlocks (features) and what usage limits apply. Plans are organized into groups — a tenant can only have one base plan per group active at a time.
'month', 'day', 'hour', 'quarter', or 'year'. Periods are anchored to the org’s plan start date, not calendar months.
Plan groups and add-ons
Base plans have agroup — only one base plan per group can be active. Add-ons are supplementary plans that can be stacked on top of a base plan:
SubscriptionStore
Assign plans to tenants:defaultPlan (or 'free' if not configured). If the fallback plan doesn’t exist in the definition, all plan-gated entitlements are denied.
WalletStore
Track per-tenant usage within billing periods:canAndConsume() and check(). You don’t call it directly in normal usage.
canAndConsume() — atomic check + consume
For operations that consume quota (AI generations, API calls, invites), use canAndConsume() instead of can(). It runs all access layers and atomically increments the usage counter:
canAndConsume() avoids TOCTOU (time-of-check-time-of-use) races by skipping the read-only wallet check and going straight to an atomic consume that fails if the limit would be exceeded.
Entity access metadata
For entity lists where each item has different permissions (e.g., some projects are editable, others read-only), pre-compute access metadata on the server so the client can check without extra requests.__access metadata that the client-side can() function reads automatically. See the client-side access control guide for usage.
Next steps
Multi-Tenancy
Tenant switching, listing, auto-resolve, and tenant-scoped JWTs.
Code Generation
Type-safe entitlements and RLS policy generation from defineAccess().
Client-Side Auth
AuthProvider, useAuth(), sign-in forms, and token refresh.
Client-Side Access Control
Use
can() and AccessGate in your UI components.OAuth Providers
Add Google, GitHub, or Discord sign-in.
Entities
Use access rules to protect entity operations.
Environment
Validate JWT secrets and OAuth keys with
createEnv().