Email/password auth, JWT sessions, RBAC with hierarchy, plans & billing, and client-side access checks
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.
POST /api/auth/signup → Create accountPOST /api/auth/signin → Sign inPOST /api/auth/signout → Sign out (clears cookies)GET /api/auth/session → Get current sessionPOST /api/auth/refresh → Refresh JWTGET /api/auth/sessions → List active sessionsDELETE /api/auth/sessions/:id → Revoke a sessionDELETE /api/auth/sessions → Revoke all other sessions
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.
Both cookies are 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.
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.
Smaller signatures (64 vs 256 bytes), faster on edge runtimes
Production: You must provide privateKey and publicKey (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.pem and .vertz/jwt-public.pem. Add .vertz/ to your .gitignore. If you change the algorithm, the dev keys are automatically regenerated.
The public key is served at GET /api/auth/.well-known/jwks.json for external verification.
List, revoke, and manage active sessions from server code. Every method requires the caller’s Headers to authenticate the request — the user can only manage their own sessions.
// List all active sessions for the current userconst listResult = await auth.api.listSessions(request.headers);if (listResult.ok) { for (const session of listResult.data) { console.log(session.id); // Session ID console.log(session.deviceName); // Parsed from user-agent (e.g., "Chrome on macOS") console.log(session.ipAddress); // Client IP console.log(session.isCurrent); // true if this is the caller's session console.log(session.createdAt); // When the session was created console.log(session.lastActiveAt); // Last token refresh }}// Revoke a specific session (must belong to the current user)const revokeResult = await auth.api.revokeSession(sessionId, request.headers);if (!revokeResult.ok) { // revokeResult.error.code === 'SESSION_NOT_FOUND' if session doesn't exist or belongs to another user}// Revoke all sessions except the current oneawait auth.api.revokeAllSessions(request.headers);
These map to the HTTP endpoints:
GET /api/auth/sessions → List active sessionsDELETE /api/auth/sessions/:id → Revoke a specific sessionDELETE /api/auth/sessions → Revoke all other sessions
Enable email verification to require users to confirm their email address after sign-up. When enabled, new users are created with emailVerified: false, and a verification token is sent via your onSend callback.
POST /api/auth/forgot-password → { email } — sends reset email (always returns 200 to prevent enumeration)POST /api/auth/reset-password → { token, password } — resets password and optionally revokes sessions
When 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.
The rules 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.
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.
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.
access: { update: rules.all( rules.authenticated(), rules.where({ ownerId: rules.user.id }), ),}// PATCH /api/tasks/123 →// SELECT * FROM tasks WHERE id = '123' AND owner_id = '<current-user-id>'// If no row found → 404 (no information leakage about row existence)
This provides two security benefits:
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 update and delete, 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.
Requires the user to have completed MFA verification within the specified number of seconds. This is used for step-up authentication on sensitive operations.
access: { delete: rules.all( rules.role('admin'), rules.fva(300), // MFA verified within the last 5 minutes ),}
AuthorizationError is thrown by ctx.authorize() (and accessContext.authorize()) when the user lacks the required entitlement. It extends Error with two additional properties.
When you pass both db and auth to createServer(), Vertz auto-wires DB-backed stores and returns a ServerInstance with .auth and .initialize():
import { authModels, createAuth, createDb, createServer } from '@vertz/server';const db = createDb({ dialect: 'sqlite', models: { ...authModels, // sessions, users, oauth_accounts, etc. // ... your app models },});const auth = createAuth({ session: { strategy: 'jwt', ttl: '60s' },});const app = createServer({ entities: [ /* ... */ ], db, auth,});// Initialize creates auth tables if they don't existawait app.initialize();// Access the auth instanceconst session = await app.auth.api.signUp({ email: 'alice@example.com', password: 'hunter2',});
Reserved auth fields such as 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.
Sign-in and sign-up limits are configurable via 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).
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.
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.
The hierarchy is inferred from 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.
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).
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.
import { InMemorySubscriptionStore } from '@vertz/server';const subscriptionStore = new InMemorySubscriptionStore();// Assign a plan (startedAt defaults to now)await subscriptionStore.assign('tenant-1', 'pro');// With explicit datesawait subscriptionStore.assign('tenant-1', 'pro', new Date('2026-01-01'), new Date('2027-01-01'));// Per-tenant limit overrides (can only increase, never decrease)await subscriptionStore.updateOverrides('tenant-1', { 'ai:generate': { max: 5000 }, // 5x the plan default});// Remove a subscriptionawait subscriptionStore.remove('tenant-1');
When a plan expires, the system falls back to defaultPlan (or 'free' if not configured). If the fallback plan doesn’t exist in the definition, all plan-gated entitlements are denied.
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:
// Check access AND consume 1 unit atomicallyconst allowed = await ctx.canAndConsume('ai:generate', { type: 'project', id: 'proj-1' });if (!allowed) { return { error: 'Upgrade your plan or wait for the next billing period.' };}// Do the actual workconst result = await generateAI(prompt);
If the operation fails after consumption, roll back:
const allowed = await ctx.canAndConsume('ai:generate', { type: 'project', id: 'proj-1' });if (!allowed) return;try { await generateAI(prompt);} catch (err) { // Roll back the consumed unit await ctx.unconsume('ai:generate', { type: 'project', id: 'proj-1' }); throw err;}
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.
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.
import { computeEntityAccess } from '@vertz/server';// In your list handlerconst projects = await db.query('SELECT * FROM projects WHERE workspace_id = ?', [wsId]);const enriched = await Promise.all( projects.map(async (project) => ({ ...project, __access: await computeEntityAccess( ['project:read', 'project:delete'], { type: 'project', id: project.id }, accessContext, ), })),);
Each entity now carries __access metadata that the client-side can() function reads automatically. See the client-side access control guide for usage.