Skip to main content

Security Model

How EdgeBase prevents unauthorized access to isolated data — even when clients choose the DB instance ID in the URL path.

The Challenge​

With the workspace:{id} DB block, the client explicitly passes the workspace ID:

const docs = await client.db('workspace', 'ws_abc123').table('documents').getList();

The ID is passed as a URL path parameter (/api/db/workspace/ws_abc123/tables/documents). A natural question arises: what stops a malicious client from using any workspace ID?

The answer is a 3-stage membership verification system backed by a KV blacklist.

Membership Verification​

Every request to an isolated table passes through the Rules Middleware, which sits between Auth and Rules in the middleware chain:

Auth Middleware ── verifies JWT, extracts user identity
│
▼
Rules Middleware ── verifies membership for isolated tables
│
▼
Rules Middleware ── evaluates declarative access rules

Two Isolation Modes​

The Rules Middleware handles auth.id and external keys differently:

auth.id — Zero-Trust from JWT​

For the user:{id} DB block, the server never trusts the client:

  • The user ID is extracted exclusively from the JWT sub claim (cryptographically signed)
  • auth.id in the URL path is always verified against the JWT sub claim
  • Even if a modified client sends auth.id in the header, the server discards it

No verification needed — the JWT signature is the proof of identity.

External Keys — 3-Stage Verification​

For the workspace:{id} DB block (or any dynamic block), the server runs:

Request: GET /api/db/workspace/ws_abc123/tables/documents
JWT: user_42

│
▼
Stage 0: KV Blacklist Check
└─ revoked:user_42:ws_abc123 exists? → 403 (instant block)
│
▼
Stage 1: JWT Custom Claims
└─ JWT contains memberships:[{workspaceId:"ws_abc123", role:"admin"}]?
→ ✅ Access granted (0 DO calls, fastest path)
│
▼
Stage 2: KV Cache
└─ membership:user_42:ws_abc123 cached?
→ ✅ Access granted (0 DO calls)
│
▼
Stage 3: Membership Collection Query
└─ Query membership table via access() rule:
SELECT * FROM workspaceIdMembers
WHERE workspaceId = 'ws_abc123' AND userId = 'user_42'
LIMIT 1
→ Found? Cache in KV (300s TTL) → ✅ Access granted
→ Not found? → 403 "You are not a member"

Why 3 Stages?​

Each stage is a performance optimization with a fallback:

StageLatencyDO CallsWhen Used
0. Blacklist~1ms0Revoked users blocked instantly
1. JWT Claims0ms0Most requests (within Access Token TTL)
2. KV Cache~1ms0After JWT expires but membership cached
3. DO Query~5-10ms1First access or cache miss

In practice, most requests resolve at Stage 1 with zero additional latency.

Attack Scenarios​

Unauthorized Workspace Access​

// Attacker tries to access a workspace they don't belong to
client.db('workspace', 'ws_not_mine').table('documents').getList(); // → 403
await admin.db('shared').table('documents').getList();

Result: Stage 3 queries the membership table, finds no record for this user + workspace combination. Returns 403 Access denied.

The DO is never even contacted — the Rules Middleware blocks the request before routing.

Unlimited Tenant Creation​

Concern: Can an attacker create unlimited DOs by sending arbitrary workspace IDs?

Answer: No. Specifying an ID in the URL path is a request to access an existing DO, it does not create one. The access() rule is evaluated before any DO routing occurs — it returns false and the request is rejected with 403. A DO is only created when canCreate returns true.

Impersonation via Header Manipulation​

Concern: Can an attacker use another user's auth.id as the DB instance ID?

Answer: No. For the user:{id} block, the access rule enforces auth?.id === id, where auth.id comes exclusively from the JWT sub claim — not from the URL. The server rejects any mismatch with 403.

Delayed Membership Revocation​

Concern: If a user is removed from a workspace, can they still access it with their existing JWT?

Answer: Temporarily, but mitigated:

  1. KV Blacklist (revoked:{userId}:{keyValue}, TTL 900s) — When a member is expelled, they are added to the blacklist. This blocks access at Stage 0, even if their JWT still contains membership claims.
  2. Access Token TTL (15 minutes) — JWT claims naturally expire.
  3. KV Cache Invalidation — Revoking a membership deletes the KV cache entry, forcing Stage 3 re-verification.

Maximum exposure window: 0 seconds (blacklist blocks immediately).

Captcha (Bot Protection)​

Auth endpoints are protected by Cloudflare Turnstile when captcha: true is set in the config. Captcha runs after rate limiting and before the auth handler:

Request → CORS → Rate Limit → Captcha → Auth Handler
  • Captcha is not a global middleware — it's applied internally within auth routes (signup, signin, anonymous, password-reset, OAuth) and optionally on HTTP functions
  • Service Keys bypass captcha — server-to-server calls are trusted
  • failMode: open (default) allows requests through if the Turnstile API is unreachable
  • Action verification prevents token reuse across endpoints (a signup token can't be used for signin)

See Captcha Guide for full configuration and SDK details.

Declarative Access Rules​

Beyond membership verification, every data operation passes through a deny-by-default rules engine:

databases: {
shared: {
tables: {
posts: {
access: {
read(auth, row) { return auth !== null },
insert(auth) { return auth !== null },
update(auth, row) { return auth?.id === row.authorId },
delete(auth, row) { return auth?.id === row.authorId || auth?.role === 'admin' },
},
schema: { /* ... */ },
},
},
},
}

TypeScript Functions — Not eval()​

Cloudflare Workers block eval() and new Function() for security reasons. EdgeBase uses native TypeScript functions for access rules — bundled at build time via esbuild:

edgebase.config.ts
└─ access: { read(auth, row) { ... } }
↓
bundled into the Worker / DO runtime

There is no runtime eval() step for normal configs. If tooling ever strips function bodies and falls back to a serialized form, unsupported expressions fail closed rather than widening access.

Fail-Closed by Default​

If no rules are defined for an operation, access is denied (when release: true):

// No 'delete' rule defined → delete is blocked
posts: {
access: {
read(auth) { return auth !== null },
insert(auth) { return auth !== null },
// update: not defined → 403
// delete: not defined → 403
},
}

This eliminates an entire class of security bugs — forgetting to add a rule never accidentally grants access.

Development Mode

During development (release: false, the default), operations are allowed without rules on configured resources. Set release: true before production deployment.

Service Key Behavior​

The Service Key bypasses access rules but not membership verification:

Service Key + dynamic DB block:

Auth Middleware: ✅ Service Key recognized
Rules Middleware: ❌ Still checks membership (Service Key doesn't help)
Rules Middleware: ✅ Bypassed (Service Key has admin privileges)

This means a server-side SDK with a Service Key still needs a valid JWT and membership to access isolated tables. The separation ensures that rules and membership are independent security concerns.

Summary​

LayerWhat It ProtectsBypass Possible?
JWT VerificationUser identityNo — cryptographic signature
Rules MiddlewareTenant boundaryNo — membership DB is the source of truth
KV BlacklistRevoked accessNo — checked before all other stages
Captcha (Turnstile)Bot protection on auth endpointsOnly with Service Key (by design)
Rules EnginePer-record authorizationOnly with Service Key (by design)
Fail-Closed DefaultUndefined operationsNo — undefined = denied (when release: true)