Skip to main content

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 BlockInstancesUse Case
app1 global databaseBlog posts, products, categories — data everyone shares
workspace1 per workspace IDTeam documents, project data — each team gets its own database
user1 per userPersonal 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.

  • access decides whether an operation is allowed.
  • handlers.hooks is for interception-style logic such as before*, after*, enrich, and delivery hooks.
  • Plugin tables are merged into their target DB block before config materialization, so the same grammar applies there too.
Release Mode

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

TypeSQLite TypeDescription
stringTEXTShort text (max 500 chars default)
textTEXTLong text (no length limit)
numberREALNumber (integer or float)
booleanINTEGERBoolean (stored as 0 or 1)
datetimeTEXTISO 8601 datetime string
jsonTEXTJSON object (stored as serialized text)

Field Options

OptionTypeDescription
requiredbooleanField must be present on create
defaultanyDefault value if not provided
uniquebooleanUnique constraint
referencesstringForeign key reference to another table
minnumberMinimum value (number) or minimum length (string)
maxnumberMaximum value (number) or maximum length (string)
patternstringRegex validation pattern
enumstring[]Restrict to a set of allowed values
checkstringRaw 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:

FieldTypeBehavior
idstringUUID v7 (monotonic, sortable by creation time). Auto-generated if not provided; client can supply its own value for offline-first scenarios.
createdAtstringISO 8601 timestamp. Set once on creation. Server-enforced -- client-supplied values are ignored.
updatedAtstringISO 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. Receives auth (the authenticated user) and id (the instance ID).
  • canCreate -- Evaluated when a request would create a new Durable Object (first request to a new namespace + ID combination).
Cross-DB Queries

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

OptionTypeDefaultDescription
emailAuthboolean--Enable email/password authentication
anonymousAuthbooleanfalseEnable anonymous authentication
phoneAuthbooleanfalseEnable phone/SMS OTP authentication
allowedOAuthProvidersstring[][]List of enabled OAuth provider names (credentials are set via env vars)
allowedRedirectUrlsstring[][]Allowlist for OAuth and email-action redirectUrl / redirectTo overrides
anonymousRetentionDaysnumber30Days before inactive anonymous accounts are cleaned up
cleanupOrphanDataboolean--Delete user DB (user:{id}) when a user is deleted
captchaboolean--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
},
}

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)
}
}
OptionTypeDefaultDescription
minLengthnumber8Minimum password length
requireUppercasebooleanfalseRequire at least one uppercase letter (A-Z)
requireLowercasebooleanfalseRequire at least one lowercase letter (a-z)
requireNumberbooleanfalseRequire at least one digit (0-9)
requireSpecialbooleanfalseRequire at least one special character
checkLeakedbooleanfalseCheck 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 }));
},
},
},
},
},
});
OptionTypeDefaultDescription
maxPlayersnumber100Max concurrent connections per room
reconnectTimeoutnumber (ms)30000Grace period before onLeave fires. 0 = immediate
maxStateSizenumber (bytes)1048576Max combined state size (shared + all player states)
stateSaveIntervalnumber (ms)60000Auto-save interval to DO Storage
stateTTLnumber (ms)86400000Time before persisted state is auto-deleted (24h default)
rateLimit{ actions: number }{ actions: 10 }Rate limit for send() calls (per second, token bucket)

Lifecycle hooks: onCreateonJoin (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',
}
TierDescription
rootFull access — bypasses all rules and scopes
scopedRestricted 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

GroupDefaultKeyDescription
global10,000,000 / 60sIPOverall safety net
db100 / 60sIPDatabase operations
storage50 / 60sIPFile uploads/downloads
functions50 / 60sIPFunction invocations
auth30 / 60sIPAll auth endpoints
authSignin10 / 1memailSign-in brute force protection
authSignup10 / 60sIPSign-up spam protection
events100 / 60sIPAnalytics/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,
},
// ...
});
OptionTypeDefaultDescription
originstring | string[]'*'Allowed origins. Supports wildcard subdomains (e.g., *.example.com).
methodsstring[]['GET', 'POST', 'PATCH', 'DELETE']Allowed HTTP methods.
credentialsbooleanfalseWhether 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:

  • description
  • docsUrl
  • configTemplate

Next Steps