Guide
Permissions & ACL
This is the part of Parse Server worth building on. Per-row authorisation, enforced by the database layer on every query, is weeks of work in any framework that does not have it — and it is the thing most people never build properly.
Two layers, and both must pass
Parse checks permissions twice, at different granularities. Understanding which layer is refusing you is most of the debugging.
| CLP — class level | ACL — row level | |
|---|---|---|
| Question | May this kind of caller touch this class at all? | May this caller touch this record? |
| Scope | The whole class | One object |
| Stored in | The Parse schema | A column on every row |
| Declared | @ParseClass({clp}), once | obj.setACL(...), per record |
| Answer when refused | Permission error | Usually invisible — see below |
A request must satisfy both. CLP is the coarse gate: employees may read invoices. The ACL is the fine one: …but only invoices belonging to their own branch. The master key bypasses both entirely.
Class-level permissions
Six operations, declared once on the model and written into the schema at boot.
import {
ParseClass, roleKey, implementACL, cloneAcl, CloudFunction, catchError,
BeforeSave, validateOrThrow, syncImageAcl,
} from 'parse-server-kit';
export enum Roles {
ADMIN = 'Admin',
EDITOR = 'Editor',
MEMBER = 'Member',
}
@ParseClass('Article', {
clp: {
find: { requiresAuthentication: true },
get: { requiresAuthentication: true },
count: { requiresAuthentication: true },
create: { [roleKey(Roles.EDITOR)]: true },
update: { [roleKey(Roles.EDITOR)]: true },
delete: { [roleKey(Roles.ADMIN)]: true },
},
})
| Operation | Covers |
|---|---|
find | Querying for many objects |
get | Fetching one by id |
count | Counting. Separate because a count can leak size |
create | Inserting |
update | Modifying an existing row |
delete | Removing |
The four forms
| Written as | Means |
|---|---|
{} | Master key only. No client reaches it — the strictest setting |
{requiresAuthentication: true} | Any signed-in user |
{[roleKey('Editor')]: true} | Members of that role |
'*' | Everyone, including anonymous. Use knowingly |
Several roles combine as alternatives — {[roleKey('Editor')]: true,
[roleKey('Admin')]: true} means either. There is no "all of" at the CLP
level; for that, check roles inside the endpoint with
requireAllRoles.
Use roleKey rather than writing 'role:Editor' by hand. It is
generic over the role name and returns that exact literal type, so a typo in a
permission key is a compile error rather than a permission that silently never matches.
Hiding individual fields
Sometimes the row should be readable but one column should not. protectedFields
removes fields per role, without a second class or a mapping layer.
@ParseClass('Employee', {
clp: { /* … */ },
protectedFields: {
'*': ['salary', 'nationalId'], // hidden from everyone…
[roleKey(Roles.ADMIN)]: [], // …except admins
},
})
The default ACL template — read this one
@ParseClass also writes a default ACL template into the schema. It is
applied to objects created without an explicit ACL, and if you do not set it, it is:
{ '*': { read: true, write: true } } // public read AND write
Set the template explicitly on any class holding data that is not genuinely public:
@ParseClass('Invoice', {
clp: { /* … */ },
// Applied to new rows that arrive without an ACL of their own.
ACL: {
['role:' + Roles.ADMIN]: { read: true, write: true },
},
})
Treat it as a floor, not as the mechanism. The real ACL should be set deliberately when the record is created — the template is what catches the paths you forgot.
Row-level ACLs
An ACL is a map from an identity to what it may do. Three kinds of key:
| Key | Identity |
|---|---|
* | Everyone, signed in or not |
role:Editor | Every member of that role, resolved at request time |
xY7kQp2mAb | One user, by objectId |
Each carries read and write. There is no separate delete
permission — write covers update and delete together. If you need
"may edit but not remove", that belongs in the endpoint or a
@BeforeDelete trigger, not the ACL.
implementACL
Takes one params object and returns an ACL. It does not take the object as an argument — a signature worth remembering, because the wrong shape is a common mistake.
article.setACL(implementACL({
roleRules: [
{ role: Roles.ADMIN, read: true, write: true },
{ role: Roles.EDITOR, read: true, write: true },
{ role: Roles.MEMBER, read: true }, // read only
],
owner: [{ user: authorId, read: true, write: true }],
publicRead: status === 'published',
}));
| Parameter | Effect |
|---|---|
publicRead / publicWrite | The * entry. Both default to false |
roleRules | {role, read?, write?} per role |
owner | {user, read?, write?} — takes a user id or object |
excludedRoles | Role names whose rule is skipped — see below. Useful when the rules are built from data |
| second argument | An existing ACL to modify instead of building a fresh one. Modified in place |
read is not "leave as is" — it is read: false, and
the access is explicitly removed. Likewise publicRead defaults to
false and is applied on every call. That is what you want when building an
ACL from scratch. When passing an existing ACL as the second argument, be aware
you are rewriting every rule you mention, not adding to them.
// An existing ACL that grants public read...
existing.setPublicReadAccess(true);
// ...loses it here, because publicRead was not restated.
implementACL({ roleRules: [{ role: 'Admin', read: true }] }, existing);
// => { "role:Admin": { "read": true } }
In a beforeSave trigger this is the right behaviour and not a trap, because
the trigger runs on every save and restates the whole rule —
publicRead: status === 'published' re-derives it each time.
implementACL(params, existing) returns the same object it was
given. If the original has to survive untouched, hand it a copy:
const updated = implementACL(params, cloneAcl(existing));
// `existing` is unchanged; `updated` is a separate ACL.
// Legacy already has read + write on this row.
implementACL({
roleRules: [{ role: 'Legacy', read: false, write: false }],
excludedRoles: ['Legacy'], // rule skipped entirely...
}, existing);
// => { "role:Legacy": { "read": true, "write": true } } - still there
// To actually take access away, name the role with nothing allowed
// and do NOT exclude it:
implementACL({ roleRules: [{ role: 'Legacy' }] }, existing);
// => { }
Patterns
Most applications need four or five of these. They compose.
The record belongs to its creator
@CloudFunction({ methods: ['POST'], validation: { requireUser: true } })
static async createNote(req: Parse.Cloud.FunctionRequest) {
const user = req.user!;
const note = Note.fromParams(req.params);
note.setACL(implementACL({
owner: [{ user: user.id, read: true, write: true }],
roleRules: [{ role: Roles.ADMIN, read: true, write: true }],
}));
const [err, saved] = await catchError(
note.save(null, { sessionToken: user.getSessionToken() })
);
if (err) throw err;
return saved;
}
Every later query is now filtered for free. listNotes needs no
equalTo('owner', user) clause — pass the session token and the database
returns only what that user may see. That is the part worth the whole platform: the
filter cannot be forgotten in one endpoint, because it is not in any endpoint.
Visibility that follows a status
function articleAcl(authorId: string, status: string) {
return implementACL({
owner: [{ user: authorId, read: true, write: true }],
roleRules: [{ role: Roles.ADMIN, read: true, write: true }],
// The single line that publishes it.
publicRead: status === 'published',
});
}
Because implementACL rewrites the rules it is given, calling this again on
a status change both grants and revokes correctly — unpublishing genuinely removes
public access rather than leaving it behind.
Scoping to a team or organisation
Parse roles can contain other roles, so a per-tenant role gives you multi-tenancy
without a where clause anywhere in the codebase.
// One role per organisation: 'org:acme'. Members are added on invite.
document.setACL(implementACL({
roleRules: [
{ role: `org:${orgSlug}`, read: true, write: true },
{ role: Roles.ADMIN, read: true, write: true },
],
}));
Cross-tenant leakage then requires someone to be in the wrong role, rather than requiring every query in the application to be written correctly.
Enforcing it on every save path
An ACL set in one endpoint protects that endpoint. Setting it in
@BeforeSave protects the dashboard, scripts, imports and any endpoint added
later.
@BeforeSave()
static async onBeforeSave(req: Parse.Cloud.BeforeSaveRequest<Article>) {
const article = req.object as Article;
// Only on create - do not fight a deliberate change later.
if (!article.existed() && !article.getACL() && req.user) {
article.setACL(implementACL({
owner: [{ user: req.user.id, read: true, write: true }],
roleRules: [{ role: Roles.ADMIN, read: true, write: true }],
}));
}
validateOrThrow(article);
}
Nested objects that must follow the parent
A pointed-to record keeps whatever ACL it was created with. Publish the parent and its image stays invisible; hide the parent and its image stays readable.
offer.setACL(implementACL({ roleRules, publicRead: status === 'active' }));
syncImageAcl(offer, ['image', 'gallery']); // after setACL, before save
await offer.save(null, { sessionToken });
syncImageAcl copies the parent's ACL onto each pointed-to record — single
pointer or array — and dirties them so the parent's save cascades the change. Call it
after the parent's final ACL is set and before saving.
Where visibility is derived inside a beforeSave trigger, pass that
publicRead into implementACL at the call site:
syncImageAcl runs before the trigger does, so it would otherwise copy the
ACL as it was a moment too early.
The master key, and why to use it sparingly
{useMasterKey: true} bypasses CLP and ACL completely. It is the right tool
for administrative work, migrations and system jobs — and the wrong one for ordinary
request handling, because it throws away the protection you set up.
| Use | When |
|---|---|
{sessionToken} | Almost always. The database enforces the caller's permissions for you |
{useMasterKey: true} | Migrations, seeds, cron jobs, deliberate cross-user administration |
A codebase where every query uses the master key has, in effect, no row-level security at all — the ACLs are written but never consulted. If you find yourself reaching for it to make something work, that is usually the ACL telling you it is set wrongly.
Common mistakes
| Symptom | Usually |
|---|---|
| A record is visible to everyone | No ACL was ever set, so the class's default template applied — and unless you declared one, that is public read and write |
| A query returns nothing | The ACL filtered every row. Retry with the master key: if rows appear, it is the ACL, not the query |
| A query returns fewer rows than the count | count and find have separate CLP entries, and one of them is more permissive |
| Refused with "object not found" | Parse reports a CLP or ACL refusal as missing rather than forbidden, deliberately — it will not confirm that a record exists |
| A user cannot see their own record | The ACL was set before the object had an id, or built from user.id when the user was not yet saved |
| Unpublishing leaves it public | The ACL was set at creation and never recomputed on the status change |
| A role grants nothing | The role name does not exist in _Role, or the key was written by hand and misspelled. roleKey prevents the second |
| An image is invisible while its record is public | syncImageAcl was not called after the parent's ACL was set |
Working out which layer refused you
- Repeat the call with the master key. If it works, permissions are the cause; if not, look at the query.
- Read the row's ACL —
obj.getACL()?.toJSON()shows exactly which identities have which access. - Check the user's roles —
await getUserRoles(user). A role the ACL names but the user lacks explains most refusals. - Compare against the CLP in
@ParseClass. Iffindis stricter than you remember, the ACL is not the problem.
if (doc.ownerId !== user.id) throw to forget in the twelfth endpoint, no
filter to omit from a new query, and no admin panel quietly reading everything because
it was written last and nobody checked. The database applies the rule, on every path,
including the ones you have not written yet.
For the semantics of Parse.ACL, Parse.Role and role hierarchy
themselves, the Parse
Platform security guide is the reference. This page covers what this library adds on
top of it.