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 levelACL — row level
QuestionMay this kind of caller touch this class at all?May this caller touch this record?
ScopeThe whole classOne object
Stored inThe Parse schemaA column on every row
Declared@ParseClass({clp}), onceobj.setACL(...), per record
Answer when refusedPermission errorUsually 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.

The ACL filters rather than refuses A query does not fail when the ACL denies you — the rows simply are not in the result. That is the correct behaviour (a refusal would confirm the record exists), but it means "my query returns nothing" and "my query returns fewer rows than expected" are usually ACL problems, not query problems.

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 },
  },
})
OperationCovers
findQuerying for many objects
getFetching one by id
countCounting. Separate because a count can leak size
createInserting
updateModifying an existing row
deleteRemoving

The four forms

Written asMeans
{}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
A row saved with no ACL is world-writable This surprises people. The CLP may be locked down to a single role, but the moment a record is created through a path that sets no ACL — a script, the dashboard, a seed, an endpoint that forgot — the row itself is open to anyone the CLP lets through. CLP and ACL are independent; a strict CLP does not imply a strict ACL.

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:

KeyIdentity
*Everyone, signed in or not
role:EditorEvery member of that role, resolved at request time
xY7kQp2mAbOne user, by objectId

Each carries read and write. There is no separate delete permissionwrite 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',
}));
ParameterEffect
publicRead / publicWriteThe * entry. Both default to false
roleRules{role, read?, write?} per role
owner{user, read?, write?} — takes a user id or object
excludedRolesRole names whose rule is skipped — see below. Useful when the rules are built from data
second argumentAn existing ACL to modify instead of building a fresh one. Modified in place
It revokes as well as grants Omitting 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.
The second argument is modified, not copied 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.
excludedRoles skips the rule — it does not deny the role The name suggests exclusion from access. It means exclusion from processing: the rule is not applied at all, so on an existing ACL, whatever that role was already granted survives.
// 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.

Recompute the ACL on every transition Create, approve, reject, publish, archive, restore. A record whose ACL was set once at creation and never revisited is the most common source of "why can that person still see it".

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.

UseWhen
{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

SymptomUsually
A record is visible to everyoneNo 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 nothingThe 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 countcount 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 recordThe ACL was set before the object had an id, or built from user.id when the user was not yet saved
Unpublishing leaves it publicThe ACL was set at creation and never recomputed on the status change
A role grants nothingThe 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 publicsyncImageAcl was not called after the parent's ACL was set

Working out which layer refused you

  1. Repeat the call with the master key. If it works, permissions are the cause; if not, look at the query.
  2. Read the row's ACLobj.getACL()?.toJSON() shows exactly which identities have which access.
  3. Check the user's rolesawait getUserRoles(user). A role the ACL names but the user lacks explains most refusals.
  4. Compare against the CLP in @ParseClass. If find is stricter than you remember, the ACL is not the problem.
Why this is worth the effort Once the ACL is right, the authorisation logic leaves your endpoints entirely. There is no 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.