Configuration
EdgeBase is configured through a single edgebase.config.ts file at your project root. This file defines your databases, authentication, storage, realtime, rooms, push notifications, native resources, access rules, handlers, service keys, rate limiting, and CORS settings.
The Big Picture
Before diving in, here's how the main pieces of edgebase.config.ts fit together:
edgebase.config.ts
├── databases ← Tables and their schemas, access, handlers
│ ├── app ← One global database (posts, products, etc.)
│ ├── workspace ← One database per workspace ID
│ └── user ← One database per user (auto-isolated)
├── auth ← Login methods: email, OAuth, phone, passkeys, MFA
├── storage ← File buckets with upload/download rules
├── realtime ← Presence and broadcast channel access rules
├── rooms ← Server-authoritative real-time rooms (game state, etc.)
├── push ← Push notification (FCM) configuration
├── kv / d1 / vectorize← Native Cloudflare resources
├── functions ← Timeout settings (functions live in functions/ dir)
├── plugins ← First-party and community plugin extensions
├── cors ← Cross-origin settings
└── rateLimiting ← Per-group rate limits
The most important concept to understand is DB blocks — each key under databases creates a separate namespace. Single-instance blocks default to D1, and dynamic blocks use Durable Objects + SQLite for physical isolation:
| DB Block | Instances | Use Case |
|---|---|---|
app | 1 global database | Blog posts, products, categories — data everyone shares |
workspace | 1 per workspace ID | Team documents, project data — each team gets its own database |
user | 1 per user | Personal notes, preferences — auto-isolated by JWT, great for GDPR compliance |
Tables in the same DB block can JOIN each other because they share one backing database. Tables in different DB blocks cannot — use App Functions to combine data across blocks.
Basic Structure
All table definitions live inside the databases object. Each key in databases defines a DB block -- an isolated namespace that routes either to D1 (single-instance) or to Durable Objects + SQLite (dynamic / explicitly isolated).
import { defineConfig } from '@edgebase/shared';
export default defineConfig({
databases: {
app: {
tables: {
posts: {
schema: {
title: { type: 'string', required: true, min: 1, max: 200 },
content: { type: 'text' },
status: { type: 'string', enum: ['draft', 'published'], default: 'draft' },
views: { type: 'number', default: 0 },
authorId: { type: 'string' },
},
indexes: [{ fields: ['status'] }, { fields: ['authorId', 'createdAt'] }],
fts: ['title', 'content'],
access: {
read(auth, row) {
return true;
},
insert(auth) {
return auth !== null;
},
update(auth, row) {
return auth !== null && auth.id === row.authorId;
},
delete(auth, row) {
return auth !== null && auth.id === row.authorId;
},
},
},
},
},
workspace: {
tables: {
documents: {
schema: {
title: { type: 'string', required: true },
content: { type: 'text' },
},
},
},
},
user: {
tables: {
notes: {
schema: {
title: { type: 'string', required: true },
body: { type: 'text' },
},
},
},
},
},
auth: {
allowedOAuthProviders: ['google', 'github'],
anonymousAuth: true,
},
storage: {
buckets: {
avatars: {
access: {
read(auth, file) {
return true;
},
write(auth, file) {
return auth !== null;
},
delete(auth, file) {
return auth !== null;
},
},
},
},
},
cors: {
origin: ['https://my-app.com', 'http://localhost:3000'],
credentials: true,
},
});
Preferred Config Grammar
For runtime product surfaces, EdgeBase now prefers access + handlers.
accessdecides whether an operation is allowed.handlers.hooksis for interception-style logic such asbefore*,after*,enrich, and delivery hooks.- Plugin tables are merged into their target DB block before config materialization, so the same grammar applies there too.
By default, release is false -- access rules are not required during development. All configured tables and storage buckets are accessible without explicit rules, letting you prototype freely.
Set release: true before production deployment to enforce deny-by-default -- any table or bucket without explicit access rules will reject all requests:
export default defineConfig({
release: true,
databases: {
/* ... */
},
});
Schema is also optional -- tables: { posts: {} } is valid for schemaless CRUD.
For rooms, release mode is also fail-closed: metadata, join, and action require either an explicit access rule or a public.* opt-in.
Schema Field Types
| Type | SQLite Type | Description |
|---|---|---|
string | TEXT | Short text (max 500 chars default) |
text | TEXT | Long text (no length limit) |
number | REAL | Number (integer or float) |
boolean | INTEGER | Boolean (stored as 0 or 1) |
datetime | TEXT | ISO 8601 datetime string |
json | TEXT | JSON object (stored as serialized text) |
Field Options
| Option | Type | Description |
|---|---|---|
required | boolean | Field must be present on create |
default | any | Default value if not provided |
unique | boolean | Unique constraint |
references | string | Foreign key reference to another table |
min | number | Minimum value (number) or minimum length (string) |
max | number | Maximum value (number) or maximum length (string) |
pattern | string | Regex validation pattern |
enum | string[] | Restrict to a set of allowed values |
check | string | Raw SQLite CHECK expression |
onUpdate | 'now' | Automatically set to the current ISO 8601 timestamp on every update |
schema: {
email: { type: 'string', required: true, unique: true, pattern: '^[^@]+@[^@]+$' },
role: { type: 'string', enum: ['admin', 'editor', 'viewer'], default: 'viewer' },
score: { type: 'number', min: 0, max: 100 },
profileId: { type: 'string', references: 'profiles' },
lastActiveAt: { type: 'datetime', onUpdate: 'now' },
}
Auto-Generated Fields
Every record automatically includes three server-managed fields:
| Field | Type | Behavior |
|---|---|---|
id | string | UUID v7 (monotonic, sortable by creation time). Auto-generated if not provided; client can supply its own value for offline-first scenarios. |
createdAt | string | ISO 8601 timestamp. Set once on creation. Server-enforced -- client-supplied values are ignored. |
updatedAt | string | ISO 8601 timestamp. Automatically updated on every write. Server-enforced. |
These fields are injected automatically if not defined in the schema. You cannot override their types, but you can disable any of them by setting the field to false:
tables: {
events: {
schema: {
id: false, // Disable auto-generated UUID
createdAt: false, // Disable auto-generated creation timestamp
name: { type: 'string', required: true },
},
},
}
Tables in the Same DB Block
All tables within the same DB block share a single backing database, which means they can use SQL JOIN queries:
databases: {
app: {
tables: {
orders: {
schema: {
customerId: { type: 'string', required: true },
total: { type: 'number', required: true },
status: { type: 'string', enum: ['pending', 'shipped', 'delivered'], default: 'pending' },
},
},
orderItems: {
schema: {
orderId: { type: 'string', references: 'orders', required: true },
productName: { type: 'string', required: true },
quantity: { type: 'number', required: true },
price: { type: 'number', required: true },
},
},
},
},
}
Both orders and orderItems are in the same backing database, enabling JOINs between them.
Data Isolation (DB Block Namespace)
Each key in databases defines a DB block namespace that controls how single-instance storage or isolated Durable Objects are created:
app -- Single Instance (D1 by default)
All tables in an app block live in one backing database. By default, EdgeBase routes single-instance blocks like app to D1. If you need SQLite inside a Durable Object instead, set provider: 'do'.
// SDK usage
const posts = await client.db('app').table('posts').getList();
workspace:{id} -- Per-Workspace Isolation
Each workspace ID creates a separate Durable Object. The client provides the ID explicitly:
// SDK usage -- client provides the workspace ID
const docs = await client.db('workspace', 'ws-456').table('documents').getList();
user:{id} -- Per-User Isolation
Each user gets their own Durable Object. The client passes the user ID explicitly, and your DB-level access rule verifies that it matches the authenticated user:
// SDK usage -- pass the user ID, then verify it in access(auth, id)
const notes = await client.db('user', userId).table('notes').getList();
This makes per-user data deletion straightforward (GDPR compliance).
DB Block Access Rules
For dynamic namespaces (workspace, user, etc.), you can control who is allowed to access or create Durable Objects using DB-level access rules:
databases: {
user: {
access: {
access(auth, id) { return auth !== null && auth.id === id },
},
tables: {
notes: { schema: { /* ... */ } },
},
},
workspace: {
access: {
access(auth, id) { return auth !== null },
canCreate(auth, id) { return auth !== null },
},
tables: {
documents: { schema: { /* ... */ } },
},
},
}
access-- Evaluated when a client tries to read or write to an existing Durable Object. Receivesauth(the authenticated user) andid(the instance ID).canCreate-- Evaluated when a request would create a new Durable Object (first request to a new namespace + ID combination).
Direct SQL queries across different DB blocks are not supported -- each Durable Object has its own independent SQLite database. If you need to aggregate data across namespaces, use App Functions to query multiple DB blocks and combine the results.
Auth
Configure authentication providers and options:
export default defineConfig({
auth: {
allowedOAuthProviders: ['google', 'github', 'apple', 'discord'],
anonymousAuth: true,
allowedRedirectUrls: [
'https://app.example.com/auth/*',
'http://localhost:3000/auth/*',
],
},
captcha: true, // Auto-provisions Cloudflare Turnstile on deploy
// ...
});
OAuth provider credentials (clientId and clientSecret) are configured via environment variables or the dashboard, not in the config file.
Supported OAuth Providers
Google, GitHub, Apple, Discord, Microsoft, Facebook, Kakao, Naver, X (Twitter), Line, Slack, Spotify, and Twitch -- 13 providers total. List only the ones you need in auth.allowedOAuthProviders.
Options
| Option | Type | Default | Description |
|---|---|---|---|
emailAuth | boolean | -- | Enable email/password authentication |
anonymousAuth | boolean | false | Enable anonymous authentication |
phoneAuth | boolean | false | Enable phone/SMS OTP authentication |
allowedOAuthProviders | string[] | [] | List of enabled OAuth provider names (credentials are set via env vars) |
allowedRedirectUrls | string[] | [] | Allowlist for OAuth and email-action redirectUrl / redirectTo overrides |
anonymousRetentionDays | number | 30 | Days before inactive anonymous accounts are cleaned up |
cleanupOrphanData | boolean | -- | Delete user DB (user:{id}) when a user is deleted |
captcha | boolean | -- | Enable Cloudflare Turnstile CAPTCHA on auth endpoints (top-level config option) |
Use allowedRedirectUrls if your app passes request-specific redirect targets for OAuth, magic link, password reset, or email change flows.
Session
auth: {
session: {
accessTokenTTL: '15m',
refreshTokenTTL: '7d',
maxActiveSessions: 5, // 0 or omit = unlimited
},
}
Magic Link
Passwordless email login via one-time link:
auth: {
magicLink: {
enabled: true,
autoCreate: true, // Create account if email is not registered (default: true)
tokenTTL: '15m', // Token time-to-live (default: '15m')
},
}
Email OTP
Passwordless email code authentication:
auth: {
emailOtp: {
enabled: true,
autoCreate: true, // Create account if email is not registered (default: true)
},
}
MFA (TOTP)
auth: {
mfa: { totp: true },
}
Passkeys (WebAuthn)
auth: {
passkeys: {
enabled: true,
rpName: 'My App',
rpID: 'example.com',
origin: 'https://example.com',
},
}
Password Policy
Configure password strength requirements. The policy is enforced on sign-up, password change, and password reset.
auth: {
passwordPolicy: {
minLength: 8, // default: 8
requireUppercase: false, // require at least one uppercase letter
requireLowercase: false, // require at least one lowercase letter
requireNumber: false, // require at least one digit
requireSpecial: false, // require at least one special character
checkLeaked: false, // check against Have I Been Pwned (fail-open)
}
}
| Option | Type | Default | Description |
|---|---|---|---|
minLength | number | 8 | Minimum password length |
requireUppercase | boolean | false | Require at least one uppercase letter (A-Z) |
requireLowercase | boolean | false | Require at least one lowercase letter (a-z) |
requireNumber | boolean | false | Require at least one digit (0-9) |
requireSpecial | boolean | false | Require at least one special character |
checkLeaked | boolean | false | Check against Have I Been Pwned using k-anonymity (fail-open with 3-second timeout) |
See Password Policy for detailed documentation including HIBP privacy model and hash format support.
Storage
Configure R2-backed file storage buckets with per-bucket access rules:
export default defineConfig({
storage: {
buckets: {
avatars: {
access: {
read(auth, file) {
return true;
},
write(auth, file) {
return auth !== null;
},
delete(auth, file) {
return auth !== null;
},
},
},
documents: {
access: {
read(auth, file) {
return auth !== null;
},
write(auth, file) {
return auth !== null;
},
delete(auth, file) {
return auth !== null && auth.role === 'admin';
},
},
},
},
},
// ...
});
Storage access rules support read, write, and delete operations. Each rule is a function that receives auth (the authenticated user, or null) and file (file metadata). With release: false, buckets without access rules are accessible to everyone; with release: true, buckets without access rules deny all access.
Storage features include signed URLs (download and upload), multipart upload with resume support, and $0 egress via R2.
Realtime
Configure access rules for Presence and Broadcast channels using wildcard namespace patterns:
export default defineConfig({
realtime: {
namespaces: {
'presence:*': {
access: {
subscribe(auth) {
return auth !== null;
},
},
},
'broadcast:public-*': {
access: {
subscribe() {
return true;
},
publish(auth) {
return auth !== null;
},
},
},
},
},
});
Realtime channels default to authenticated users only — even in development mode. Database subscriptions (onSnapshot) reuse the table's read rule automatically.
See Realtime Access Rules for wildcard matching, permission re-evaluation, and instant kick.
Room
Define server-authoritative real-time rooms with lifecycle hooks and state management:
export default defineConfig({
rooms: {
game: {
maxPlayers: 10,
access: {
metadata: (auth) => !!auth,
join: (auth) => !!auth,
action: (auth) => !!auth,
},
handlers: {
lifecycle: {
onCreate(room) {
room.setSharedState(() => ({ turn: 0, score: 0 }));
},
onJoin(sender, room) {
if (sender.role === 'banned') {
throw new Error('You are banned'); // Rejects the join
}
room.setPlayerState(sender.userId, () => ({ hp: 100 }));
},
},
actions: {
MOVE: (payload, room) => {
room.setSharedState((s) => ({ ...s, position: payload }));
},
},
},
},
},
});
| Option | Type | Default | Description |
|---|---|---|---|
maxPlayers | number | 100 | Max concurrent connections per room |
reconnectTimeout | number (ms) | 30000 | Grace period before onLeave fires. 0 = immediate |
maxStateSize | number (bytes) | 1048576 | Max combined state size (shared + all player states) |
stateSaveInterval | number (ms) | 60000 | Auto-save interval to DO Storage |
stateTTL | number (ms) | 86400000 | Time before persisted state is auto-deleted (24h default) |
rateLimit | { actions: number } | { actions: 10 } | Rate limit for send() calls (per second, token bucket) |
Lifecycle hooks: onCreate → onJoin (throw to reject) → onAction[type] → onLeave (reason: 'leave' | 'disconnect' | 'kicked') → onDestroy. Timer handlers are defined in onTimer.
In release: true, room metadata, join, and action are fail-closed unless you define access.* or explicitly opt in with public.metadata, public.join, or public.action.
See Room Server Guide for lifecycle hooks, state management, and Room Access Rules for onJoin rejection patterns.
Push Notifications
Configure Firebase Cloud Messaging for push notifications:
export default defineConfig({
push: {
fcm: {
projectId: 'my-firebase-project',
},
access: {
send(auth, target) {
return auth !== null;
},
},
handlers: {
hooks: {
beforeSend: async (_auth, input) => input,
afterSend: async (_auth, input, output) => {
console.log(input.kind, output.sent);
},
},
},
},
});
The FCM service account JSON is set via the PUSH_FCM_SERVICE_ACCOUNT environment variable, not in the config file.
Push dispatch is server-only — Client SDKs can only register/unregister device tokens. Use push.access.send to gate delivery calls and push.handlers.hooks.beforeSend/afterSend to transform or observe outbound sends.
See Push Configuration for FCM setup and Push Access Rules for the full access model.
Native Resources (KV, D1, Vectorize)
Declare Cloudflare-native storage resources for use cases beyond built-in collections:
export default defineConfig({
kv: {
cache: {
binding: 'CACHE_KV',
rules: {
read(auth) {
return auth !== null;
},
write(auth) {
return auth !== null && auth.role === 'admin';
},
},
},
},
d1: {
analytics: { binding: 'ANALYTICS_D1' },
},
vectorize: {
embeddings: { dimensions: 1536, metric: 'cosine' },
},
});
All native resource APIs require a Service Key. See Native Resources for full documentation.
Email
Configure an email provider for verification emails, password resets, and magic links:
export default defineConfig({
email: {
provider: 'resend', // 'resend' | 'sendgrid' | 'mailgun' | 'ses'
apiKey: 'RESEND_API_KEY',
from: 'noreply@example.com',
appName: 'My App',
verifyUrl: 'https://app.com/auth/verify?token={token}',
resetUrl: 'https://app.com/auth/reset?token={token}',
magicLinkUrl: 'https://app.com/auth/magic-link?token={token}',
emailChangeUrl: 'https://app.com/auth/verify-email-change?token={token}',
},
});
These are default templates. The Web SDK and REST API can override them per request with redirectUrl or redirectTo.
SMS
Configure an SMS provider for phone OTP authentication:
export default defineConfig({
sms: {
provider: 'twilio', // 'twilio' | 'messagebird' | 'vonage'
accountSid: 'TWILIO_ACCOUNT_SID',
authToken: 'TWILIO_AUTH_TOKEN',
from: '+15551234567',
},
});
Auth delivery hooks live under auth.handlers.email.onSend and auth.handlers.sms.onSend.
Auth Enrich Hook
Inject request-scoped data into auth.meta before access rules are evaluated — useful for workspace roles, org memberships, and feature flags:
export default defineConfig({
auth: {
handlers: {
hooks: {
enrich: async (auth) => ({
workspaceRole: await lookupRole(auth.id),
}),
},
},
},
databases: {
workspace: {
access: {
access(auth) {
return auth?.meta?.workspaceRole === 'member';
},
},
tables: {
/* ... */
},
},
},
});
The hook runs after JWT verification with a 50ms timeout. On error/timeout, auth.meta is set to {} (fail-safe). Configure it with auth.handlers.hooks.enrich. See Auth Enrich Hook for details.
Service Keys
Server-side API keys that bypass access rules for backend operations:
These same Service Keys are consumed by all Admin SDKs.
export default defineConfig({
serviceKeys: {
keys: [
{
kid: 'backend',
tier: 'root',
scopes: ['*'],
secretSource: 'dashboard',
secretRef: 'SERVICE_KEY_BACKEND',
},
],
},
});
For admin recovery and other internal root-tier operations, keep one unconstrained root key that points at the canonical SERVICE_KEY secret:
{
kid: 'root',
tier: 'root',
scopes: ['*'],
secretSource: 'dashboard',
secretRef: 'SERVICE_KEY',
}
| Tier | Description |
|---|---|
root | Full access — bypasses all rules and scopes |
scoped | Restricted to listed scopes only (e.g., db:table:events:write) |
See Service Keys for scoped keys, constraints, and key rotation.
Rate Limiting
Control request rates per group. Each group has a default that you can override:
export default defineConfig({
rateLimiting: {
db: {
requests: 200,
window: '60s',
binding: { limit: 250, period: 60, namespaceId: '2002' },
},
storage: { requests: 50, window: '60s' },
functions: { requests: 50, window: '60s' },
auth: { requests: 30, window: '60s' },
authSignin: { requests: 10, window: '1m' },
authSignup: { requests: 10, window: '60s' },
events: { requests: 100, window: '60s' },
},
// ...
});
binding is optional. Use it when you also want edgebase dev and edgebase deploy to generate matching Cloudflare Rate Limiting Bindings for a built-in group.
Treat these values as abuse-protection knobs, not billing or hard quota settings.
Default Limits
| Group | Default | Key | Description |
|---|---|---|---|
global | 10,000,000 / 60s | IP | Overall safety net |
db | 100 / 60s | IP | Database operations |
storage | 50 / 60s | IP | File uploads/downloads |
functions | 50 / 60s | IP | Function invocations |
auth | 30 / 60s | IP | All auth endpoints |
authSignin | 10 / 1m | Sign-in brute force protection | |
authSignup | 10 / 60s | IP | Sign-up spam protection |
events | 100 / 60s | IP | Analytics/event ingestion |
Exceeding a limit returns 429 Too Many Requests with a Retry-After header.
CORS
Configure Cross-Origin Resource Sharing:
export default defineConfig({
cors: {
origin: ['https://my-app.com', 'https://*.my-app.com'],
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
credentials: true,
},
// ...
});
| Option | Type | Default | Description |
|---|---|---|---|
origin | string | string[] | '*' | Allowed origins. Supports wildcard subdomains (e.g., *.example.com). |
methods | string[] | ['GET', 'POST', 'PATCH', 'DELETE'] | Allowed HTTP methods. |
credentials | boolean | false | Whether to include credentials. Cannot be true when origin is '*'. |
When origin is not set, the default is '*' (all origins) for development convenience. For production, always specify explicit origins.
App Functions
Configure timeouts for hooks and scheduled functions:
export default defineConfig({
functions: {
hookTimeout: '10s',
scheduleFunctionTimeout: '30s',
},
cloudflare: {
extraCrons: ['15 * * * *'],
},
});
cloudflare.extraCrons adds raw Wrangler cron triggers on top of EdgeBase-managed schedule function crons and the built-in cleanup cron. Use it only when you need additional scheduled() wake-ups that are not tied to a specific App Function.
EdgeBase treats the managed cron set as the source of truth during deploy. In practice, that means wrangler.toml's [triggers] section is replaced from config at deploy time rather than merged manually.
cloudflare.extraCrons does not bind a cron to a specific App Function. It only causes Cloudflare to invoke the Worker's scheduled() handler at those times, so any extra behavior must be handled inside your scheduled runtime logic.
App Functions are defined in the functions/ directory, not in the config file. See App Functions for details.
Plugins
Add first-party or community plugins:
import { stripePlugin } from '@edgebase/plugin-stripe';
export default defineConfig({
plugins: [stripePlugin({ secretKey: process.env.STRIPE_SECRET_KEY! })],
});
Each plugin can register its own tables, functions, and auth hooks under a namespaced prefix. Plugin tables are merged into their target DB block before config materialization, so they can use the same access + handlers grammar as first-party tables.
definePlugin() also injects the current public pluginApiVersion automatically, so deploy can reject plugins built against an incompatible plugin contract.
Plugins can also expose serializable manifest metadata from definePlugin() for CLI/docs tooling:
descriptiondocsUrlconfigTemplate
Next Steps
- Deployment -- Deploy your project
- Access Rules -- Overview of all access rules across features
- Database Client SDK -- Start building with the database API
- Config Reference -- Full config option reference