Your Frontend Talks Straight to the Database. Here Is How to Lock It Down [Full Repo]

Your JavaScript ships an App ID and a client key by design; what they can do is up to the backend. We measured an open class (everything), closed it with four locks and measured again, including the 22 minutes the lock was only half shut.

Twelve requests from four identities against one class, before and after locking it down on September 17, 2026: with the class open every request succeeds; with class-level permissions and an owner ACL, anonymous and other users get 404 code 101 and only the owner and the master key get through

Open your app in a browser, open the developer tools, and look at the network tab. Two headers go out with every request to your backend: an App ID and a key. Anyone reading your page has both. That is not a leak. It is how a frontend that talks to a managed backend is supposed to work.

What those two headers can do is the whole question. On a class with default permissions the answer is: everything. List every row, change any row, delete all of them. We measured it, twelve requests from four identities, and then closed the class with four locks and measured again after each one.

The four locks are class-level permissions, a per-row ACL stamped by a backend hook, a role checked inside a Cloud Function, and a master key that never leaves the server. Everything below was measured on September 17, 2026, on a Back4app backend, including the 22 minutes when lock 1 was only partly closed.

Get the working code: the companion repo is at github.com/templates-back4app/lockdown-lab — probe.sh, seed.sh, lockdown.sh, cloud/main.js and migrate-acl.sh. Run the probe before and after each lock and you get the tables in this post for your own backend.

What can anyone with your JavaScript key do right now?

We made a Note class the way most apps do: the first client write created it. Then we seeded two users, ana and bob, and a moderator role containing bob, and ran twelve requests from four identities. Anonymous means the App ID and JavaScript key alone. The master key is what a server would hold.

Architecture: four identities reach the Note class through four locks in order: class-level permissions, a beforeSave hook that stamps an owner-only ACL, a role check inside a Cloud Function, and a master key that never leaves the backend

This is state 0, the class as it was born, at 15:08 UTC:

Identity → requestHTTP / code
anonymous (JS key) → list notes200
anonymous (JS key) → create note201
anonymous (JS key) → delete ana’s note200
ana (session token) → list, read, update, create200 / 200 / 200 / 201
bob (session token) → read ana’s note200
bob (session token) → update ana’s note200
bob (session token) → delete ana’s note200
master key → read, delete ana’s note200 / 200

Every row green. The third line is the one to sit with: someone who has never logged in, holding only what your page hands out, deleted another user’s data and got a 200 for it. Nothing was misconfigured. This is the default.

Measured · September 17, 2026 · Note class, default permissions
12 / 12requests allowed, incl. anonymous delete
→
6 / 12allowed after four locks: owner + master key
22 minuntil lock 1 held everywhere

How we measured: probe.sh, twelve curl requests from four identities against fresh rows, destructive requests last, run at least once per state (three times for the open class) on September 17, 2026. The propagation table comes from a sampler of ten anonymous list requests every 30 seconds; the answering server is the x-backend-server header.

Why is the key in your frontend not the problem?

Because it was never a secret. The JavaScript key, the Client key and the REST key are client keys: they say which app is calling so the backend can find your data and apply your rules. They are meant to be in an APK, in a page, in a curl command. Rotating them buys nothing if the rules are open.

The master key is the opposite. It bypasses every permission in this post. It belongs in Cloud Code, in a server’s environment variables, or in a script on your machine, and in no client of any kind. The repo’s .env holds it for the probes and is git-ignored for that reason.

The key says which app is calling. The permissions say what it may do. Only one of those is your job.

So the work is not hiding the key. The work is telling the backend, per class and per row, what each identity may do. That happens in three places: the class’s permissions, each row’s ACL, and code that runs on the backend.

How do class-level permissions turn anonymous requests away?

Class-level permissions (CLP) are the door to the class. For each operation (find, get, create, update, delete, add field) they say who may knock: everyone, only authenticated users, specific users and roles, or nobody. Lock 1 is authenticated users only on every operation, which turns the anonymous rows red and leaves everything else alone.

The dashboard sets it under Database → the class’s menu → Security → Class Level Permission: uncheck Public, check Authenticated for Read and Write. We did the same thing from the terminal through the schema API, because a script is repeatable and a screenshot is not:

# Stack: bash + curl (schema API, master key) | File: lockdown.sh
CLP='{"find":{"requiresAuthentication":true},"get":{"requiresAuthentication":true},"create":{"requiresAuthentication":true},"update":{"requiresAuthentication":true},"delete":{"requiresAuthentication":true},"addField":{}}'
curl -s -H "X-Parse-Application-Id: $APP_ID" -H "X-Parse-Master-Key: $MASTER_KEY" -H "Content-Type: application/json" \
  -X PUT "https://parseapi.back4app.com/schemas/Note" -d "{\"classLevelPermissions\":$CLP}"

The Edit Class Level Permissions dialog for the Note class in the dashboard: Public unchecked for Read, Write and Add field, Authenticated checked for Read and Write, and an Add Role field

With the CLP in place, an anonymous create was refused immediately: 404, code 101, Permission denied, user needs to be authenticated. An anonymous count got a different pair, 400 / 119. Two codes for “not allowed”, depending on the operation; branch on both.

How long does a permission change take to reach every server?

Longer than one request, and that was the surprise of the day. We changed the CLP at 15:08:40 UTC and probed at 15:10: anonymous create was refused, anonymous list was still answered. Twenty list requests in a row gave 14 × 200 and 6 × 404, from the same key, in the same second. GET /schemas/Note showed the new permissions the whole time. The request path did not.

We left a sampler running: ten anonymous list requests every 30 seconds. Between 15:14 and 15:30 the number allowed swung between 10 of 10 and 0 of 10 and back. The x-backend-server response header explained it: every 200 came from one server, shared752, while the denials carried no such header. The app runs on several instances, and one of them kept the old rule.

Restart App on the Overview page at 15:25 reported restarted successfully and changed nothing: 0 of 10 at 15:28, then 8 of 10 again. What ended it were two Cloud Code deploys at about 15:30, of a file that had not changed. The next sample read 2 of 10, and every sample after 15:31:26 read 0 of 10, for the rest of the afternoon.

Time (UTC)Anonymous list requests allowed, of 10
15:14 – 15:1610, 10, 10, 10
15:16 – 15:306, 9, 8, 8, 7, 6, 10, 10, 10, 10, 7, 6, 8, 7, 10, 8, 9, 6, 5, 4, 0, 8, 8, 7, 9
15:30:53 (after the deploys)2
15:31 onwards0, 0, 0, … (24 samples, until we stopped at 15:44)

How does a backend hook give every row an owner-only ACL?

Lock 1 keeps strangers out. It does nothing about bob, who is logged in and can still read, edit and delete ana’s note. That is lock 2, the ACL: a per-row list of who may read and who may write. The classic mistake is to have the client set it; the client can forget, and a curl command will. So the backend sets it, before the row exists:

// Stack: Node.js 22.x | Parse Server 8.x | File: cloud/main.js
// Lock 2 of 4: every Note gets an ACL that only its owner can read or write. The client cannot forget to set it,
// because the client never sets it — the backend does, before the row exists.
Parse.Cloud.beforeSave("Note", (request) => {
  const note = request.object;
  if (!note.isNew()) return;                                   // ACL is decided once, at creation
  const owner = request.user ?? note.get("owner");             // request.user = whoever holds the session token
  if (!owner) throw new Parse.Error(Parse.Error.SESSION_MISSING, "log in to create a note.");
  const acl = new Parse.ACL(owner);                            // owner: read + write; everyone else: nothing
  note.setACL(acl);
  note.set("owner", owner);
});

request.user is whoever sent the session token, resolved by the backend; the client cannot claim to be someone else. new Parse.ACL(owner) grants read and write to that user and nothing to anyone, including the public. The owner pointer is set in the same breath, so a query can filter on it and the moderator function can show who wrote what.

The Cloud Code editor with cloud/main.js open in the lockdown-lab backend: the beforeSave Note hook that stamps the owner ACL, and the moderatorList Cloud Function below it

After the deploy, every note ana creates carries ACL: { "<ana's id>": { read: true, write: true } }, which the Database Browser shows as her object id in the ACL column. The rows did not come from a form we wrote; they were written by the probes, and the hook did the rest.

The Database Browser showing the Note class with fourteen objects: every row's ACL column reads ana's object id, EBkE7EJ1sb, and the owner column points to the same user

What does bob see when he asks for ana’s note?

Nothing, and not a refusal. With the ACL in place, bob reading ana’s note by id got 404 / 101 Object not found. The same status and code a made-up id gets. Updating and deleting it: the same. The ACL does not say you may not; it makes the row invisible, and a list request simply omits it.

That is the behaviour you want from a security feature and the behaviour that confuses a developer at 2 a.m. If your logs show 101 on a row you can see in the dashboard, check the ACL before you check the id.

Identity → requestOpen classCLP + ACL + role
anonymous → list, create, delete200 / 201 / 200404 / 101 on all three
ana → read, update her note200 / 200200 / 200
ana → list, create200 / 201200 / 201
bob → read, update, delete ana’s note200 / 200 / 200404 / 101 on all three
master key → read, delete ana’s note200 / 200200 / 200

A note on the one thing we saw and could not reproduce. In the first probe run, seconds after seed.sh created the users, ana’s writes returned 209 Invalid session token while her reads returned 200. A minute later three consecutive runs had no 209s. Treat a 209 right after signup as a reason to re-run, not as a rule.

How does a role let moderators read everything without opening the class?

Lock 3. A moderator needs to see every note, and the wrong way to give them that is to loosen the CLP or add the role to every row’s ACL. The right way is a Cloud Function that checks the role and does the read on the backend, with the master key, and hands back exactly what a moderator should see:

// Stack: Node.js 22.x | Parse Server 8.x | File: cloud/main.js (continued)
// Lock 3 of 4: a role. Moderators can read everything through this function; nobody can through the class.
Parse.Cloud.define("moderatorList", async (request) => {
  if (!request.user) throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, "log in first.");
  const isMod = await new Parse.Query(Parse.Role).equalTo("name", "moderator").equalTo("users", request.user).first({ useMasterKey: true });
  if (!isMod) throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, "moderators only.");
  const notes = await new Parse.Query("Note").find({ useMasterKey: true });   // lock 4: the master key stays here
  return notes.map((n) => ({ id: n.id, text: n.get("text"), owner: n.get("owner")?.id }));
});

Measured: ana calling moderatorList got 119 moderators only.; bob, who is in the role, got the full list, owners included. The class itself stayed as locked as before. The role was created by seed.sh with the master key, because creating a role is itself a privileged write; the _Role class in the dashboard shows one row, moderator, with bob in its users relation.

The useMasterKey: true on the two queries is lock 4 in one line: the master key is used, inside the backend, by code you deployed, for a query you chose. No client ever holds it, and a moderator who leaves the role loses the list on their next call.

Which permissions does a new class get by default?

All of them, for everyone. We proved it a second time on purpose: we created a Draft class the lazy way, by writing to it from an anonymous request, and opened the Database Browser. Three rows, and every ACL reads Public Read + Write. The CLP was the same: public everything, addField included.

The Database Browser showing a Draft class with three objects, each with an ACL column reading Public Read + Write, created by anonymous requests

Nothing in the dashboard warns you. The class simply appears, open, next to the one you locked an hour ago. If the class exists so that clients can create rows, the order is: create the class and its CLP first (the dashboard’s Add Class or the schema API), deploy the hook, then let the first client write happen.

What happens to the rows created before the lock?

They keep the ACL they were born with. A beforeSave hook runs on saves after it is deployed, and the fourteen notes our probes created while the class was open still said Public Read + Write. Anyone with the key could still read those, lock or no lock. The repo’s migrate-acl.sh walks the class once with the master key:

# Stack: bash + curl (master key) | File: migrate-acl.sh (core loop)
# rows with an owner → owner-only ACL; ownerless rows (anonymous drive-bys) → deleted
if [ "$action" = fix ]; then
  curl -s -o /dev/null "${MK[@]}" -X PUT "$BASE/classes/Note/$id" -d "{\"ACL\":{\"$owner\":{\"read\":true,\"write\":true}}}"
else
  curl -s -o /dev/null "${MK[@]}" -X DELETE "$BASE/classes/Note/$id"
fi

It fixed every owned row, deleted the ownerless ones (the drive-by notes anonymous probes had created) and reported 14 remaining rows, all with an owner. Run it once, after the hook is live and proven, and keep it in the repo: the next class you lock will need it too.

What does the twelve-request table look like after all four locks?

This is state 2, measured at 15:33 UTC, after the CLP had settled and the hook and role were live:

identity → request                              HTTP / Parse code
anonymous (JS key)  → list notes           404 / 101
anonymous (JS key)  → create note          404 / 101
ana (session token) → list notes           200
ana (session token) → read her note        200
ana (session token) → update her note      200
ana (session token) → create note          201
bob (session token) → read ana's note      404 / 101
bob (session token) → update ana's note    404 / 101
master key          → read ana's note      200
anonymous (JS key)  → delete ana's note    404 / 101
bob (session token) → delete ana's note    404 / 101
master key          → delete ana's note    200

Same key in the page, same twelve requests. Six of them now fail, and every one of the six is a request your app should never have been able to make. The four that pass for ana are her own rows. The two that pass for the master key run from a machine you control.

probe.sh prints this table in one run. Keep it next to the class. When a new feature needs a new permission, the diff between two runs is the review.

When is a backend with client keys the wrong design?

When one identity must never be able to read a row even by accident, and the rule is too complex to state as a CLP plus an ACL: multi-tenant data with cross-tenant joins, or rows whose visibility depends on a calculation.

Then the client should not query the class at all. It should call a Cloud Function that runs the query with the master key and returns a shaped result, which is what moderatorList already does for one case.

The other case is a secret the backend must hold on the client’s behalf: a payment provider key, a third-party token. Those go in Cloud Code’s environment or a container, never in a class the client can reach, however locked.

For a class of rows that belong to users, the four locks are enough, and the whole of them is one PUT to the schema, 20 lines of Cloud Code and one migration.

What would you add to the lockdown lab next?

  • A shared note. Add a sharedWith relation and extend the hook to grant read to those users. The probe gains a row: carol reads a note shared with her → 200.
  • A CLP per role. Give the moderator role write on the class through the CLP’s Add Role field and see which of bob’s rows turn green. Then decide whether you like that better than the function.
  • The same probe on _User. By default a logged-in user can read other users’ rows. Lock _User with Enforce private users under Advanced Options (paid) or a CLP, and probe it.
  • A deploy-time check. Run probe.sh from CI against a staging backend and fail the build if any line that should be red turns green.
  • Users first. If the app does not have accounts yet, that is the previous post: How to Add Authentication to a Web or Flutter App Without Writing an Auth Server.

Tagssecuritypermissionsaclrolesclass-level-permissionsmaster-keyjavascriptparse-server

Frequently asked questions

Is it safe to put the App ID and JavaScript key in my frontend code?

Yes, that is what client keys are for: they identify the app, not the user, and anyone with them can only do what the backend's permissions allow. What must never ship is the master key, which bypasses every permission. The risk is not the key in the page; it is a class left with its default permissions, where anyone holding the key can list, edit and delete every row. We measured exactly that on September 17, 2026.

What is the difference between class-level permissions and an ACL?

Class-level permissions (CLP) are the door to the class: who may find, get, create, update, delete or add fields at all. An ACL is a per-row list of which users and roles may read or write that row. A request must pass both. In our test the CLP turned anonymous requests away (404, code 101) and the owner-only ACL hid ana's note from bob (also 404, code 101) while ana and the master key still got through.

Why should the ACL be set by a Cloud Code hook instead of by the client?

Because the client can forget, or be replaced by a curl command that does not bother. A beforeSave hook on the class runs on the backend for every new row, whoever sent it, and stamps an ACL that grants read and write to request.user alone. Rows created before the hook keep their old ACL, so the repo ships a migration script for them.

How long does a permission change take to apply?

Longer than one request. After we changed the class-level permissions through the schema API, anonymous requests were answered inconsistently for 22 minutes: some servers applied the new rule, one kept applying the old one, and Restart App did not fix it. Two Cloud Code deploys did. Probe until zero of N requests get through, not until the first denial.

How do I let an admin read every row without opening the class?

With a role and a Cloud Function. The function checks that request.user is in the role (a role query with the master key), then runs the query with useMasterKey inside the backend and returns the rows. The master key never leaves the server; a non-member gets error 119. In our test ana got 119 and bob, a moderator, got the full list.

Further reading

Reviewed & verified by Back4app Engineering, Editorial review at Back4app

Tested Sep 17, 2026 — Backend: Back4app, free plan, USA East · Probes: bash + curl on macOS · Rules: Cloud Code deployed from the dashboard editor.

More from Back4app Engineering

Backend · tutorial

How to Add Authentication to a Web or Flutter App Without Writing an Auth Server [Full Repo]

Containers · tutorial

How to Deploy a Next.js SSR App With Docker and a Backend [Full Repo]

Backend · tutorial

Build and Deploy a Node.js REST API in 15 Minutes — Without Owning a Server [Full Code Inside]