Abre tu app en un navegador, abre las herramientas de desarrollo y mira la pestaña de red. Con cada solicitud a tu backend salen dos headers: un App ID y una clave. Cualquiera que lea tu página tiene los dos. Eso no es una fuga. Así es como debe funcionar un frontend que habla con un backend gestionado.
Lo que esos dos headers pueden hacer es toda la cuestión. En una clase con los permisos por defecto, la respuesta es: todo. Listar todas las filas, modificar cualquiera, borrarlas todas. Lo medimos, doce solicitudes de cuatro identidades, y después cerramos la clase con cuatro cerrojos y medimos de nuevo tras cada uno.
Los cuatro cerrojos son los permisos a nivel de clase, una ACL por fila que fija un hook en el backend, un role que se comprueba dentro de una Cloud Function y una master key (clave maestra) que nunca sale del servidor. Todo lo que sigue se midió el 17 de septiembre de 2026 en un backend de Back4app, incluidos los 22 minutos en los que el cerrojo 1 estuvo solo a medio cerrar.
Consigue el código funcionando: el repositorio companion está en github.com/templates-back4app/lockdown-lab — probe.sh, seed.sh, lockdown.sh, cloud/main.js y migrate-acl.sh. Ejecuta la sonda antes y después de cada cerrojo y obtendrás las tablas de este post para tu propio backend.
¿Qué puede hacer ahora mismo cualquiera con tu JavaScript key?
Creamos una clase Note como lo hace la mayoría de las apps: la primera escritura del cliente la creó. Después sembramos dos usuarios, ana y bob, y un role moderator que contiene a bob, y ejecutamos doce solicitudes de cuatro identidades. Anónimo significa solo el App ID y la JavaScript key. La master key es lo que tendría un servidor.
Este es el estado 0, la clase tal como nació, a las 15:08 UTC:
| Identidad → solicitud | HTTP / código |
|---|---|
| anónimo (JS key) → listar notas | 200 |
| anónimo (JS key) → crear nota | 201 |
| anónimo (JS key) → borrar la nota de ana | 200 |
| ana (session token) → listar, leer, actualizar, crear | 200 / 200 / 200 / 201 |
| bob (session token) → leer la nota de ana | 200 |
| bob (session token) → actualizar la nota de ana | 200 |
| bob (session token) → borrar la nota de ana | 200 |
| master key → leer, borrar la nota de ana | 200 / 200 |
Todas las filas en verde. La tercera es la que merece una pausa: alguien que nunca ha iniciado sesión, con solo lo que tu página reparte, borró los datos de otro usuario y recibió un 200 por ello. Nada estaba mal configurado. Este es el comportamiento por defecto.
Cómo lo medimos: probe.sh, doce solicitudes curl de cuatro identidades contra filas recién creadas, las destructivas al final, ejecutado al menos una vez por estado (tres veces para la clase abierta) el 17 de septiembre de 2026.
La tabla de propagación sale de un muestreador de diez solicitudes anónimas de listado cada 30 segundos; el servidor que responde es el header x-backend-server.
¿Por qué la clave en tu frontend no es el problema?
Porque nunca fue un secreto. La JavaScript key, la Client key y la REST key son claves de cliente: dicen qué app está llamando para que el backend encuentre tus datos y aplique tus reglas. Están hechas para ir en un APK, en una página, en un comando curl. Rotarlas no sirve de nada si las reglas están abiertas.
La master key es lo contrario. Se salta todos los permisos de este post. Su sitio es Cloud Code, las variables de entorno de un servidor o un script en tu máquina, y ningún cliente de ningún tipo. El .env del repositorio la guarda para las sondas y está en el gitignore por esa razón.
La clave dice qué app está llamando. Los permisos dicen qué puede hacer. Solo una de esas dos cosas es trabajo tuyo.
Así que el trabajo no es esconder la clave. El trabajo es decirle al backend, por clase y por fila, qué puede hacer cada identidad. Eso ocurre en tres sitios: en los permisos de la clase, en la ACL de cada fila y en el código que se ejecuta en el backend.
¿Cómo rechazan los permisos a nivel de clase las solicitudes anónimas?
Los permisos a nivel de clase (CLP) son la puerta de la clase. Para cada operación (find, get, create, update, delete, add field) dicen quién puede llamar: todo el mundo, solo usuarios autenticados, usuarios y roles concretos, o nadie. El cerrojo 1 es Authenticated (solo usuarios autenticados) en todas las operaciones, lo que pone en rojo las filas anónimas y no toca nada más.
La dashboard lo configura en Database → menú de la clase → Security → Class Level Permission: desmarca Public, marca Authenticated en Read y Write. Nosotros hicimos lo mismo desde la terminal, por la schema API, porque un script es repetible y una captura de pantalla no:
# 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}"

Con la CLP en su sitio, un create anónimo fue rechazado al instante: 404, código 101, Permission denied, user needs to be authenticated. Un count anónimo recibió un par distinto, 400 / 119. Dos códigos para “no permitido”, según la operación; contempla los dos.
¿Cuánto tarda un cambio de permisos en llegar a todos los servidores?
Más de una solicitud, y esa fue la sorpresa del día. Cambiamos la CLP a las 15:08:40 UTC y sondeamos a las 15:10: el create anónimo fue rechazado, el listado anónimo seguía respondiéndose. Veinte solicitudes de listado seguidas dieron 14 × 200 y 6 × 404, con la misma clave, en el mismo segundo.
GET /schemas/Note mostró los permisos nuevos todo el tiempo. La ruta de la solicitud, no.
Dejamos un muestreador en marcha: diez solicitudes anónimas de listado cada 30 segundos. Entre las 15:14 y las 15:30 el número de permitidas osciló entre 10 de 10 y 0 de 10, y volvió.
El header de respuesta x-backend-server lo explicó: todos los 200 venían de un mismo servidor, shared752, mientras que las negativas no traían ese header. La app corre en varias instancias, y una de ellas conservó la regla antigua.
Restart App en la página Overview a las 15:25 informó restarted successfully y no cambió nada: 0 de 10 a las 15:28, después 8 de 10 otra vez. Lo que lo terminó fueron dos deploys de Cloud Code hacia las 15:30, de un archivo que no había cambiado. La muestra siguiente marcó 2 de 10, y todas las muestras después de las 15:31:26 marcaron 0 de 10, durante el resto de la tarde.
| Hora (UTC) | Solicitudes anónimas de listado permitidas, de 10 |
|---|---|
| 15:14 – 15:16 | 10, 10, 10, 10 |
| 15:16 – 15:30 | 6, 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 (después de los deploys) | 2 |
| Desde las 15:31 | 0, 0, 0, … (24 muestras, hasta que paramos a las 15:44) |
¿Cómo le da un hook en el backend a cada fila una ACL de solo propietario?
El cerrojo 1 deja fuera a los desconocidos. No hace nada con bob, que ha iniciado sesión y todavía puede leer, editar y borrar la nota de ana. Ese es el cerrojo 2, la ACL: una lista por fila de quién puede leer y quién puede escribir.
El error clásico es dejar que la fije el cliente; el cliente puede olvidarse, y un comando curl se olvidará. Así que la fija el backend, antes de que la fila exista:
// 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 es quien envió el session token, resuelto por el backend; el cliente no puede hacerse pasar por otra persona. new Parse.ACL(owner) concede lectura y escritura a ese usuario y nada a nadie más, público incluido.
El pointer owner se fija en el mismo gesto, para que una query pueda filtrar por él y la función de moderador pueda mostrar quién escribió qué.

Después del deploy, cada nota que crea ana lleva ACL: { "<id de ana>": { read: true, write: true } }, que el Database Browser muestra como su object id en la columna ACL. Las filas no salieron de un formulario que escribimos nosotros; las escribieron las sondas, y el hook hizo el resto.

¿Qué ve bob cuando pide la nota de ana?
Nada, y no una negativa. Con la ACL en su sitio, bob leyendo la nota de ana por id recibió 404 / 101 Object not found. El mismo status y el mismo código que recibe un id inventado. Actualizarla y borrarla: lo mismo. La ACL no dice no puedes; hace invisible la fila, y una solicitud de listado simplemente la omite.
Ese es el comportamiento que quieres de una función de seguridad y el que confunde a un desarrollador a las 2 de la madrugada. Si tus logs muestran 101 en una fila que ves en la dashboard, revisa la ACL antes de revisar el id.
| Identidad → solicitud | Clase abierta | CLP + ACL + role |
|---|---|---|
| anónimo → listar, crear, borrar | 200 / 201 / 200 | 404 / 101 en las tres |
| ana → leer, actualizar su nota | 200 / 200 | 200 / 200 |
| ana → listar, crear | 200 / 201 | 200 / 201 |
| bob → leer, actualizar, borrar la nota de ana | 200 / 200 / 200 | 404 / 101 en las tres |
| master key → leer, borrar la nota de ana | 200 / 200 | 200 / 200 |
Una nota sobre lo único que vimos y no pudimos reproducir. En la primera ejecución de la sonda, segundos después de que seed.sh creara los usuarios, las escrituras de ana devolvieron 209 Invalid session token mientras sus lecturas devolvían 200.
Un minuto después, tres ejecuciones consecutivas no tuvieron ningún 209. Trata un 209 justo después del registro como motivo para volver a ejecutar, no como regla.
¿Cómo deja un role que los moderadores lean todo sin abrir la clase?
Cerrojo 3. Un moderator necesita ver todas las notas, y la forma equivocada de dárselo es aflojar la CLP o añadir el role a la ACL de cada fila. La forma correcta es una Cloud Function que comprueba el role, hace la lectura en el backend con la master key y devuelve exactamente lo que un moderador debe ver:
// 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 }));
});
Medido: ana llamando a moderatorList recibió 119 moderators only.; bob, que está en el role, recibió la lista completa, propietarios incluidos. La clase en sí siguió tan cerrada como antes.
El role lo creó seed.sh con la master key, porque crear un role es en sí una escritura privilegiada; la clase _Role en la dashboard muestra una fila, moderator, con bob en su relation users.
El useMasterKey: true de las dos queries es el cerrojo 4 en una línea: la master key se usa dentro del backend, en código que desplegaste tú, para una query que elegiste tú. Ningún cliente la tiene nunca, y un moderador que sale del role pierde la lista en su siguiente llamada.
¿Qué permisos recibe una clase nueva por defecto?
Todos, para todo el mundo. Lo demostramos una segunda vez, a propósito: creamos una clase Draft de la forma perezosa, escribiendo en ella desde una solicitud anónima, y abrimos el Database Browser. Tres filas, y todas las ACL dicen Public Read + Write. La CLP era igual: todo público, addField incluido.

Nada en la dashboard te avisa. La clase simplemente aparece, abierta, junto a la que cerraste hace una hora. Si la clase existe para que los clientes creen filas, el orden es: crea primero la clase y su CLP (el Add Class de la dashboard o la schema API), despliega el hook y solo entonces deja que ocurra la primera escritura del cliente.
¿Qué pasa con las filas creadas antes del cerrojo?
Conservan la ACL con la que nacieron. Un hook beforeSave se ejecuta en los saves posteriores a su deploy, y las catorce notas que nuestras sondas crearon mientras la clase estaba abierta seguían diciendo Public Read + Write. Cualquiera con la clave todavía podía leerlas, con cerrojo o sin él.
El migrate-acl.sh del repositorio recorre la clase una vez con la 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
Corrigió todas las filas con propietario, borró las que no lo tenían (las notas de paso que habían creado las sondas anónimas) e informó de 14 filas restantes, todas con propietario. Ejecútalo una vez, cuando el hook esté desplegado y comprobado, y guárdalo en el repositorio: la próxima clase que cierres también lo necesitará.
¿Cómo queda la tabla de doce solicitudes después de los cuatro cerrojos?
Este es el estado 2, medido a las 15:33 UTC, después de que la CLP se asentara y el hook y el role estuvieran desplegados:
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
La misma clave en la página, las mismas doce solicitudes. Seis de ellas ahora fallan, y cada una de las seis es una solicitud que tu app nunca debería haber podido hacer. Las cuatro que pasan para ana son sus propias filas. Las dos que pasan para la master key se ejecutan desde una máquina que controlas tú.
probe.sh imprime esta tabla en una ejecución. Guárdala junto a la clase. Cuando una funcionalidad nueva necesite un permiso nuevo, el diff entre dos ejecuciones es la revisión.
¿Cuándo es un backend con claves de cliente el diseño equivocado?
Cuando una identidad no debe poder leer una fila ni por accidente, y la regla es demasiado compleja para expresarse como una CLP más una ACL: datos multi-tenant con joins entre tenants, o filas cuya visibilidad depende de un cálculo.
Entonces el cliente no debería consultar la clase en absoluto. Debería llamar a una Cloud Function que ejecute la query con la master key y devuelva un resultado ya moldeado, que es lo que moderatorList hace ya para un caso.
El otro caso es un secreto que el backend debe guardar en nombre del cliente: la clave de un proveedor de pagos, un token de terceros. Esos van en el entorno de Cloud Code o en un container, nunca en una clase a la que el cliente pueda llegar, por muy cerrada que esté.
Para una clase de filas que pertenecen a usuarios, los cuatro cerrojos bastan, y en total son un PUT al schema, 20 líneas de Cloud Code y una migración.
¿Qué añadirías al lockdown lab a continuación?
- Una nota compartida. Añade una relation
sharedWithy amplía el hook para conceder lectura a esos usuarios. La sonda gana una fila: carol lee una nota compartida con ella → 200. - Una CLP por role. Dale al role
moderatorescritura en la clase a través del campo Add Role de la CLP y mira qué filas de bob se ponen en verde. Después decide si lo prefieres a la función. - La misma sonda en
_User. Por defecto, un usuario con sesión puede leer las filas de otros usuarios. Cierra_Usercon Enforce private users en Advanced Options (de pago) o con una CLP, y sondéalo. - Una comprobación en el deploy. Ejecuta
probe.shdesde CI contra un backend de staging y haz fallar el build si alguna línea que debería estar en rojo se pone en verde. - Usuarios primero. Si la app todavía no tiene cuentas, ese es el post anterior: Cómo añadir autenticación a una app web o Flutter sin escribir un servidor de auth.