Appwrite security: 6 collection-level checks before going public.
--read=any / --write=any "just to make it work" and forget to lock it down. The six checks: collection read permissions, collection write permissions, per-document rules, attribute-level visibility, function execution permissions, and the account deletion endpoint.
Appwrite's permission model is strong on paper. Each collection has separate read / write / create / update / delete permissions. Each document can override the collection defaults. Each attribute can be hidden from queries. Each function decides which roles can invoke it. The flexibility is the win and the risk — there are six places to misconfigure, and "any" is a one-liner.
1. Collection read permissions
The most common bug. A collection meant for authenticated users has its read permission set to any during prototyping and never locked down before launch.
Test from outside:
curl 'https://<your-endpoint>/v1/databases/default/collections/messages/documents' \
-H "X-Appwrite-Project: <your-project-id>"
# If this returns documents without an X-Appwrite-Session header, the
# collection is public-readable. The project ID is in your client bundle.
Fix: in the Appwrite Console → Databases → your-db → Collections → your-collection → Settings → Permissions. Remove the any read permission. Add users for "any logged-in user", or user:USER_ID for per-document scoping.
If you manage permissions in code:
// node-appwrite (server-side only)
import { Permission, Role } from 'node-appwrite';
await databases.updateCollection('default', 'messages', 'Messages', [
Permission.read(Role.users()), // any logged-in user can read
Permission.create(Role.users()), // any logged-in user can create
Permission.update(Role.user('userId')), // owner-scoped — see below
Permission.delete(Role.user('userId')),
]);
2. Collection write permissions
If the collection-level write permission is any, anyone can insert documents — fake reviews, spam content, abusive payloads. Even users isn't always right; you usually want write scoped to the owner.
Best practice: use document-level permissions for ownership-shaped data, and reserve collection-level permissions for the verb (create vs. update vs. delete).
// When creating a document, set its permissions to scope to the creator:
await databases.createDocument('default', 'messages', ID.unique(), {
body: 'hello',
authorId: user.$id,
}, [
Permission.read(Role.user(user.$id)),
Permission.update(Role.user(user.$id)),
Permission.delete(Role.user(user.$id)),
]);
3. Per-document rules
Document-level permissions override collection defaults. If a document was created without per-doc rules, it inherits the collection's any read permission and ships public — even if you've since locked the collection down.
Test: in the Console → Databases → your-collection → Documents → click any document → Permissions tab. If the permissions are empty, the document inherits the collection's. If the collection used to be open, those documents are still open until you re-set them.
Fix: back-fill permissions on existing documents:
// Migration script (server-side only):
const docs = await databases.listDocuments('default', 'messages', [
Query.limit(100), Query.cursor(lastId),
]);
for (const doc of docs.documents) {
await databases.updateDocument('default', 'messages', doc.$id, doc, [
Permission.read(Role.user(doc.authorId)),
Permission.update(Role.user(doc.authorId)),
Permission.delete(Role.user(doc.authorId)),
]);
}
4. Attribute-level visibility
Some attributes (e.g. email, phone, stripe_customer_id) shouldn't be readable even by the document's owner — they're for backend bookkeeping. Appwrite supports per-attribute encryption and (via SDKs) per-attribute selection.
Currently Appwrite doesn't have a true "private attribute" feature; the workaround is to store sensitive attributes in a separate collection with stricter permissions (e.g. team:internal), and join only on the server side.
Pattern:
// Collection: profiles (public)
// - userId, displayName, avatarUrl
// Collection: profiles_internal (read: team:internal only)
// - userId, email, phone, stripe_customer_id
// Server function joins both when needed.
5. Function execution permissions
Appwrite Functions run server-side and can use the admin SDK. By default, a function's execute permission determines who can invoke it — and the default is permissive during creation.
Test: Console → Functions → your-function → Settings → Permissions. The execute permission should be users (any logged-in user) at minimum, or user:USER_ID for owner-only flows. any means anyone can call it.
Why it matters: functions often skip auth checks because they assume the execute permission is the gate. If execute is any AND the function uses the admin SDK, it's a remote-code-execution-shaped vulnerability — anyone can call send_admin_notification(message) with whatever message they want.
Always verify auth inside the function regardless:
// Appwrite function entry (Node.js runtime):
export default async ({ req, res, log }) => {
// req.headers['x-appwrite-user-id'] is set by the runtime IF the caller
// had a session. If null, the call was unauthenticated.
if (!req.headers['x-appwrite-user-id']) {
return res.json({ error: 'Unauthenticated' }, 401);
}
// ... rest of function
};
6. Storage bucket permissions
Same as Supabase Storage: buckets meant for avatars are fine as read: any. Buckets containing user uploads (documents, ID scans, screenshots) need per-file scoping.
In Appwrite this means setting file permissions on creation:
const file = await storage.createFile(
'private-uploads', // bucket id
ID.unique(),
inputFile,
[
Permission.read(Role.user(user.$id)),
Permission.delete(Role.user(user.$id)),
]
);
Test: for each bucket, try the public URL pattern:
curl -I 'https://<endpoint>/v1/storage/buckets/<bucket-id>/files/<file-id>/view' \
-H "X-Appwrite-Project: <project-id>"
If this returns 200 without a session header for a file that should be private, the bucket or file permissions are wrong.
The deployed-side check
vibecheck's BaaS detector probes Appwrite endpoints for unauthenticated collection listing on common collection names (users, posts, messages, items, products, orders). Findings are linked to /fix/supabase_anon_only_no_rls — the principle is identical to Supabase RLS, just with a different permission model underneath.
The 80% of Appwrite security issues we see are item #1 (collection read) and item #5 (function execute). If you only have time for two checks, start with those.
Inspect your Appwrite app