Appwrite Document-Level Permissions for Multi-Tenant Data Isolation
Appwrite's document-level permissions enforce multi-tenant isolation without custom middleware. Each document stores its own permission array evaluated on every request including realtime. Covers team-based pattern, worked example, and five common mistakes.
25 Aug 2025, 19:35 UTC

The Problem: Secure Multi-Tenant Data Without Custom Backend Logic
Building a multi-tenant SaaS application means ensuring tenant A never sees tenant B's data. Traditional approaches require middleware layers, row-level security policies in PostgreSQL, or custom API gateways — each adding latency and maintenance burden. Appwrite's Database solves this at the document level: every document carries its own permission array evaluated server-side on every read, write, and realtime subscription. No custom backend code required.
How Document Permissions Work
When you create or update a document in an Appwrite collection, you pass a permissions array. Each entry follows the syntax "()" where:
- action:
read,write,create,update,delete - target:
role:,user:,team:, or*(wildcard for public)
Permissions are OR-combined. If a request matches any entry, it's allowed. Missing permissions default to deny. The permission set lives on the document itself in the $permissions system field.
Worked Example: Team-Based Multi-Tenant Isolation
This pattern uses Appwrite Teams as tenant containers. Each tenant gets a team; users join teams via invitation. Documents belong to a tenant by granting permissions to that team.
Step 1: Create a Tenant Team and Add Members
Where to run: Server-side (Node.js, Python, etc.) using an API key with teams.write scope.
// Node.js server SDK
const { Client, Teams } = require('node-appwrite');
const client = new Client()
.setEndpoint('https://cloud.appwrite.io/v1')
.setProject('YOUR_PROJECT_ID')
.setKey('YOUR_API_KEY'); // Must have teams.write scope
const teams = new Teams(client);
// Create tenant team
const tenantTeam = await teams.create(
'unique()', // teamId
'Acme Corp' // name
);
// Add user to tenant (user must exist in Auth)
await teams.createMembership(
tenantTeam.$id,
['read', 'write'], // roles within team
'user_123abc' // userId to invite
);
Required permissions: API key with teams.write scope. Check: Verify team appears in Console > Teams. Risk: Invited users receive email; pending invitations don't grant permissions until accepted.
Step 2: Create Documents Scoped to the Tenant Team
Where to run: Client-side (browser, Flutter, Apple) or server-side. Client SDKs enforce permissions; server SDKs with API keys bypass them.
// Web client SDK (runs in user's browser)
const { Client, Databases, Permission, Role } = require('appwrite');
const client = new Client()
.setEndpoint('https://cloud.appwrite.io/v1')
.setProject('YOUR_PROJECT_ID');
const databases = new Databases(client);
// Current user must be member of 'team_tenantAcme'
const document = await databases.createDocument(
'main', // databaseId
'orders', // collectionId
'unique()', // documentId
{
product: 'Widget Pro',
quantity: 100,
tenantId: 'team_tenantAcme' // optional: for querying
},
[
Permission.read(Role.team('team_tenantAcme')),
Permission.write(Role.team('team_tenantAcme')),
Permission.delete(Role.team('team_tenantAcme'))
]
);
The Permission and Role helpers generate strings like "read(team:team_tenantAcme)". The resulting document stores:
{
"$id": "doc_456",
"product": "Widget Pro",
"$permissions": [
"read(team:team_tenantAcme)",
"write(team:team_tenantAcme)",
"delete(team:team_tenantAcme)"
]
}
Step 3: Read Documents — Cross-Tenant Requests Are Denied
When a user from Tenant B (team_tenantBeta) tries to list orders:
// User from Tenant B (different team)
const orders = await databases.listDocuments('main', 'orders');
// Returns only documents where $permissions includes read(team:team_tenantBeta)
// Tenant A's documents return 403 and are excluded from the response
Appwrite filters at the database level. The user never sees Tenant A's document IDs, counts, or realtime events.
Step 4: Realtime Subscriptions Respect Permissions
// Client subscribes to collection changes
const unsubscribe = databases.subscribe(
'main',
'orders',
(response) => {
console.log('Event:', response.events); // Only events for permitted documents
console.log('Payload:', response.payload);
}
);
// Client subscribes to collection changes
const unsubscribe = databases.subscribe(
'main',
'orders',
(response) => {
console.log('Event:', response.events); // Only events for permitted documents
console.log('Payload:', response.payload);
}
);
Subscription authorization is evaluated at subscribe time. If permissions change later, the client continues receiving events until it reconnects (see Limits below).
Limits and Common Mistakes
1. Permissions Are Immutable Per Update
Every createDocument or updateDocument call must include the full desired permission array. Omitting an entry revokes it immediately.
// WRONG: This removes write permission for the team
await databases.updateDocument('main', 'orders', 'doc_456',
{ quantity: 200 },
[Permission.read(Role.team('team_tenantAcme'))] // write missing!
);
// CORRECT: Include all permissions
await databases.updateDocument('main', 'orders', 'doc_456',
{ quantity: 200 },
[
Permission.read(Role.team('team_tenantAcme')),
Permission.write(Role.team('team_tenantAcme')),
Permission.delete(Role.team('team_tenantAcme'))
]
);
Mitigation: Create a helper that reads existing $permissions, merges changes, and writes back the complete array.
2. Team Membership Changes Are Instant
When a user is removed from a team (or leaves), they lose access to all documents scoped to that team immediately. Pending invitations grant zero permissions. This can cause race conditions in UI — a user clicks "Delete" but the membership was revoked milliseconds earlier.
Mitigation: Handle 403 responses gracefully. Check membership status before destructive actions using teams.getMembership().
3. Wildcard Permissions Bypass All Auth
Permission.read(Role.any()) generates "read(*)", making the document readable by unauthenticated requests. Never use in production for tenant data.
// DANGEROUS in multi-tenant apps
[Permission.read(Role.any()), Permission.write(Role.user('user_123'))]
Verification: In Console > Database > Documents, inspect the $permissions field. Ensure no * targets exist on sensitive collections.
4. Realtime Permission Changes Require Re-subscription
If you update a document's permissions after a client has subscribed, the client keeps receiving events until it disconnects and reconnects. Appwrite does not push permission revocation to active sockets.
Pattern: Use a Database webhook (or Appwrite Function triggered by databases.*.document.update) to detect permission changes and broadcast a "reconnect" message via a separate realtime channel or push notification.
5. No Permission Inheritance for Related Documents
Child documents (e.g., order items under an order) do not inherit parent permissions. Each must declare its own array. For hierarchical data, either:
- Duplicate the permission array on every child (boilerplate), or
- Use a server-side function to create children with correct permissions atomically.
Verification Checklist
- Console inspection: Open Appwrite Console > Database > your collection > any document. Confirm
$permissionsmatches your intended array exactly. - Cross-tenant read test: Create two users in different teams. Insert a document with
["read(team:team_A)"]. Authenticate as user from team_B and calllistDocuments. Expect empty result (not 403 on the call itself — Appwrite filters silently). - Direct document access test: As user from team_B, call
getDocument('doc_from_team_A'). Expect 401/403. - Server bypass test: Use a server SDK with API key (database scope) to read the same document. Should succeed — this confirms server-side bypass works for admin operations.
- Debug logs (optional): Set
APP_DEBUG=truein your self-hosted.envto log permission evaluation decisions in server logs.
When This Pattern Fits
- Serverless frontends (Next.js, Flutter, React Native) talking directly to Appwrite
- Multi-tenant SaaS where tenants map cleanly to teams
- Realtime features (dashboards, collaborative editing) needing zero-latency auth
If you need complex policies (e.g., "user can read if they own the document OR are admin OR document is public"), you'll still evaluate logic in a Function — but the document permissions enforce the result.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.