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
subclaim (cryptographically signed) auth.idin the URL path is always verified against the JWTsubclaim- Even if a modified client sends
auth.idin 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:
| Stage | Latency | DO Calls | When Used |
|---|---|---|---|
| 0. Blacklist | ~1ms | 0 | Revoked users blocked instantly |
| 1. JWT Claims | 0ms | 0 | Most requests (within Access Token TTL) |
| 2. KV Cache | ~1ms | 0 | After JWT expires but membership cached |
| 3. DO Query | ~5-10ms | 1 | First 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:
- 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. - Access Token TTL (15 minutes) — JWT claims naturally expire.
- 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.
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
| Layer | What It Protects | Bypass Possible? |
|---|---|---|
| JWT Verification | User identity | No — cryptographic signature |
| Rules Middleware | Tenant boundary | No — membership DB is the source of truth |
| KV Blacklist | Revoked access | No — checked before all other stages |
| Captcha (Turnstile) | Bot protection on auth endpoints | Only with Service Key (by design) |
| Rules Engine | Per-record authorization | Only with Service Key (by design) |
| Fail-Closed Default | Undefined operations | No — undefined = denied (when release: true) |