Client SDK Guide
How to use parse-stack-next as an unprivileged Parse client — the way a
mobile app, browser, untrusted worker, or any process you don't trust with
the master key would use it.
This guide is the complement to the rest of the documentation, which
generally assumes the process holds master-key credentials. Here we assume
the opposite: the SDK is configured without a master key, all requests
go over REST, and authorization is carried by the user's sessionToken.
Every claim below is locked in by the integration tests under
test/lib/parse/client_*_integration_test.rb.
Why a separate guide?
The default Parse Stack docs lean on convenience surfaces (Song.find,
Song.create!, Song.first) that resolve credentials implicitly through
Parse.client. Those calls work transparently because the configured
client carries the master key — Parse Server treats the request as an
admin operation, ACL/CLP/protectedFields checks are bypassed, and you
get whatever you asked for.
A client-mode process is the opposite world:
- No master key in the process. Ever. (If it's there, the operator made a mistake — the SDK should never paper over it.)
- Authorization is per-call: every save, fetch, query, file upload, and
cloud-function invocation has to carry the caller's
sessionToken. - Parse Server is the enforcement boundary. CLP rejects the call; ACL
filters rows;
protectedFieldsstrips columns. The SDK's job is to thread the auth context through honestly and surface the server's verdict — not to retry-with-master or invent a happy path. - Several surfaces are simply unavailable:
/aggregate,/schemas, full/sessionsenumeration,/configwrites. They're master-key-only on Parse Server, and the SDK fails closed when you call them without it.
If you've used Parse Stack with the master key and find that "the same calls just stop working" when you remove it — that's not a regression. That's Parse Server doing what it's documented to do, and this guide is the field manual for working within it.
1. Configuration
1.1 No-master-key client
require "parse/stack"
Parse.setup(
server_url: "https://parse.example.com/parse",
app_id: "MY_APP_ID",
api_key: "MY_REST_API_KEY",
master_key: nil, # explicit; do NOT set this from env in client builds
logging: false,
)
Parse.client.master_key # => nil
That's the whole knob. Once master_key is nil, every call that
resolves through Parse.client (which is essentially all of them) goes
out as a regular REST request. The server has no admin escape hatch to
fall back on.
1.2 Building a one-off client
If you need a side client (e.g. a worker that handles uploads on behalf of a logged-in user) and don't want to touch the global one:
client = Parse::Client.new(
server_url: "https://parse.example.com/parse",
app_id: "MY_APP_ID",
api_key: "MY_REST_API_KEY",
master_key: nil,
)
Most SDK surfaces operate on the global Parse.client; the one-off form
is mostly useful for tests and adapters.
1.3 Verifying you're really in client mode
The easiest mistake to make is "I thought I dropped the master key but something is still threading it through." Pin it down explicitly:
raise "client builds must not ship master key" if Parse.client.master_key.present?
The test harness ships an assert_client_mode! helper that does exactly
this; production code should be just as paranoid.
1.3.1 v5.0: Parse::Query master-key default flipped to nil
Before v5.0, Parse::Query#initialize set @use_master_key = true. That
silently broke Parse.client_mode = true and every Parse.with_session
block: the truthy default propagated into _opts on every find, so the
request layer saw an explicit use_master_key: true and skipped the
client-mode resolution path entirely. Effect: queries went out
master-key-stamped regardless of operator intent.
v5.0 changes the init value to nil (tri-state: "no caller preference"):
- Server-mode unchanged. With a master key configured and no client_mode, the request layer still defaults to sending it when nothing else expresses a preference.
- Client-mode honored.
Parse.client_mode = truenow actually suppresses the master-key header for queries, the way the rest of the surface already did. - Ambient session honored. Inside
Parse.with_session(user) { … }, a plainSong.all(...)now picks up the ambient instead of being short-circuited by the oldtruedefault. - Explicit wins.
query.use_master_key = trueorSong.all(..., use_master_key: true)still forces the header.
Mongo-direct gate: Parse::Query#assert_mongo_direct_routable! treats
a configured master key on the client as an ambient credential in
server mode. Direct-only constraints (Atlas Search-shaped operators,
etc.) route through the mongo-direct path as long as Parse.client_mode
is false and use_master_key was not explicitly set to false — server
apps don't have to thread use_master_key: true through every query
that hits a direct-only constraint. The gate raises
Parse::Query::MongoDirectRequired for client-mode processes or queries
that explicitly opt out of the master key without supplying a
session_token / .scope_to_user(user) / .scope_to_role(role).
2. Authentication
2.1 Sign up
A user is created by POST /users — no auth required. Parse::User#save
on a brand-new user does signup-on-save and the response carries the
fresh sessionToken:
user = Parse::User.new(
username: "ada",
password: "p4ssw0rd!",
email: "ada@example.com",
)
user.save # => true
user.session_token # => "r:abcd…"
user.id # => "oP3Q…"
Equivalent explicit form:
user.signup!
signup! raises on failure (duplicate username, missing required field);
save returns false and populates user.errors.
2.2 Log in
me = Parse::User.login("ada", "p4ssw0rd!")
me.session_token # => "r:abcd…"
me.logged_in? # => true
Parse::User.login returns nil on bad credentials — it does not raise.
If you need the underlying error info, drop down to the client:
response = Parse.client.login("ada", "wrong")
response.success? # => false
response.error # => "Invalid username/password."
This duality is intentional. The high-level convenience method matches what mobile SDKs do; the raw client preserves the response so you can log or reroute.
2.3 Validate / refresh a session
response = Parse.client.current_user(token)
response.success? # => true
response.result["objectId"] # => "oP3Q…"
current_user calls GET /users/me. A revoked or bogus token raises
Parse::Error::InvalidSessionTokenError — catch it if you want a
graceful "please log in again" UX, otherwise let it bubble up.
2.4 Log out
Parse.client.logout(token)
Subsequent current_user(token) calls will raise. There's no separate
client-side "session cache" to clear — the token was just a string you
were holding.
2.5 Multi-factor auth
If you've loaded the optional parse/two_factor_auth/user_extension
module on the server side and configured the matching cloud-code hook,
Parse::User#mfa_enabled? and related methods become available. The
plain login path still works for users who haven't enrolled; for users
who have, the MFA challenge flow is the standard Parse Server one and
the SDK threads it through.
This guide doesn't reproduce the MFA setup — see the two_factor_auth
module source for the full surface.
2.6 Anonymous users and upgrading them in place
Some apps want to give a visitor a real session before they pick a username — so their first writes (a draft post, a cart, a configured preference) attach to a row that survives across reloads and tabs and then later promotes to a named account without losing anything.
Parse::User.anonymous_signup creates a fully-formed _User row with
an authData.anonymous provider entry and returns it pre-logged-in.
A client-generated UUID is supplied for the provider payload via
SecureRandom.uuid; the SDK constructs the authData shape so the
caller doesn't have to:
guest = Parse::User.anonymous_signup
guest.session_token # => "r:abcd…"
guest.anonymous? # => true
guest.username # server-assigned random username
draft = Post.new(body: "first thoughts", author: guest)
draft.save(session: guest.session_token)
The token is a real session token — every CRUD/query example in §3
works against guest.session_token the same way it would for a named
user. ACL stamping under acl_policy :owner_else_* picks up the
anonymous user's objectId, so the row remains writable by whoever
holds the upgraded credentials later.
When the visitor signs up for real, don't create a second _User
row — upgrade the anonymous one in place:
Parse.with_session(guest.session_token) do
guest.upgrade_anonymous!(
username: "ada",
password: "p4ssw0rd!",
email: "ada@example.com",
)
end
guest.anonymous? # => false
guest.username # => "ada"
guest.session_token # rotated by the server, applied automatically
upgrade_anonymous! issues a single PUT /users/:id that sets the
new credentials AND explicitly unlinks the anonymous provider in the
same request (authData: { anonymous: nil }). The unlink is not
optional — leaving authData.anonymous attached after a username is
assigned would let anyone who learned the original anonymous UUID
silently log in as the freshly-named account. This is a documented
Parse foot-gun and the SDK closes it in one round trip.
Guards on upgrade_anonymous!:
- Requires
Parse.with_session(self.session_token)(or a directly-set@session_tokenon the instance) — the call writes via the user's own session, not the master key. - Refuses to run on a non-anonymous user, on a detached
Parse::User.newwith no objectId, and on an instance with no session token. All three raiseParse::Error::AuthenticationErrorrather than performing an unauthorized PUT. - On success, clears
passwordfrom memory, applies the server-rotated session token (when the server returns one), and runschanges_applied!so a subsequentsavedoesn't re-transmit credentials.
The Parse Server error codes for username_taken / email_taken /
email_invalid / missing-field surface as the existing
Parse::Error::* exception family — your existing signup error
handling works unchanged.
3. CRUD with a session token
The cardinal rule: every save, fetch, query, and destroy needs to know which session it's running as. With no master key, the SDK has no implicit "do whatever" path; you have to be explicit about who you are.
3.1 Save
post = Post.new(title: "hello", author: me)
post.save(session: me.session_token)
Or, using the lower-level API:
Parse.client.create_object(
"Post", { "title" => "hello" },
session_token: me.session_token, use_master_key: false,
)
use_master_key: false is the safety belt — it makes the call fail
loudly if some upstream code accidentally re-introduced a master key.
Get in the habit of writing it on every client-mode call.
Gotcha — kwarg absorption. The SDK's
requestmethod uses a**optssplat, which silently absorbs a keyword namedopts:into{opts: {...}}and DROPS your session token. Always pass auth as direct keywords (session_token: …, use_master_key: false), not asopts: { … }.
3.2 Update
post.title = "v2"
post.save(session: me.session_token)
Or:
Parse.client.update_object(
"Post", post.id, { "title" => "v2" },
session_token: me.session_token, use_master_key: false,
)
3.3 Destroy
post.destroy(session: me.session_token)
If the ACL doesn't grant write to the caller, the destroy returns false
(or raises Parse::RecordNotSaved depending on the code path) — the
row is left intact. Parse Server reports this as "Object not found"
which is its uniform shape for "you can't see it OR you can't touch it."
3.4 Fetch and query
The class-level convenience methods (Post.find, Post.all) do not
take a session: argument because they predate client mode. Use
Parse::Query and stamp the token on the query object:
q = Post.query
q.session_token = me.session_token
posts = q.where(:likes.gte => 10).order(:likes.desc).limit(20).results
For one-off find_by_id against a class:
Parse.client.fetch_object(
"Post", id,
session_token: me.session_token, use_master_key: false,
)
count works the same way:
q.where(:likes.gt => 0).count
3.5 Pointer includes
q = Comment.query
q.session_token = me.session_token
comment = q.where(text: "nice").include(:post, :author).first
comment.post.title # populated via REST `?include=post`
comment..id # populated via `?include=author`
The server applies ACL to the included rows independently. If the
caller can read the comment but not the included post, comment.post
comes back as a bare pointer (just objectId + className) rather
than a hydrated object.
3.6 The snake_case ↔ camelCase trap
Ruby properties declared as property :public_field, :string are sent
on the wire as publicField. If you build a CLP schema, protectedFields
list, or raw query body, you must use the camelCase form:
# WRONG — queries a column that doesn't exist server-side
Parse::Query.new("ClientClpProbe").where(public_field: "x") # SDK rewrites OK
# but:
{ "publicField" => "x" } # is what hits the wire — make sure the schema matches
The Parse Stack query DSL handles the rewrite for you. Raw find_objects
/ create_object calls do not — pass camelCase keys when you're talking
to the low-level API.
3.7 Model callbacks run locally — NOT as Parse Cloud webhooks
This is the most-missed thing on the SDK→server transition. ActiveModel
callbacks declared on your Parse::Object subclasses (before_save,
after_save, before_create, after_destroy, attribute normalizers,
validations, etc.) execute in the Ruby process before the write hits
Parse Server. They are not registered as Parse Cloud Code triggers
(Parse.Cloud.beforeSave('Contact', ...)).
class Contact < Parse::Object
property :email, :string
before_save do
self.email = email.downcase if email.present?
end
end
Concretely:
Contact.new(email: "Foo@BAR.com").savefrom your Ruby app — thebefore_savefires,emailis lowercased, andFoo@bar.comlands on the server as"foo@bar.com". Good.- A record
Contactcreated by the iOS SDK, the JS SDK, a webhook, the REST API directly, or the Parse Dashboard does not see your Ruby callback. The server stores whatever it was given, mixed case and all. - A separate Ruby process that imports the Parse Server schema but does
not define a
ContactRuby model also bypasses the callback. - If you
update_object("Contact", id, { email: "Foo@BAR.com" })directly via the raw client (skipping the model), there is no Ruby instance to run the callback on. The raw write goes through unchanged.
If you need invariants enforced on every write regardless of which
client sent it, that's Parse Cloud Code on the server (a
Parse.Cloud.beforeSave('Contact', ...) trigger in your cloud code
bundle) — not a Ruby model callback. Use Ruby callbacks for app-side
ergonomics (defaults, derived fields, post-save notifications from
this app), and use server-side Cloud Code triggers for cross-client
data integrity.
The same caveat applies to after_save — and this one bites harder,
because after_save is the natural home for "send the welcome email",
"enqueue the embedding job", "post to the activity feed", "invalidate
the cache". All of those only fire when the save originates from a Ruby
process holding a Contact model instance and calling .save on it.
A Contact created by:
- the iOS or JS SDK
- a separate Ruby service that doesn't define the
Contactmodel - the Parse Dashboard
- a direct REST call (
POST /parse/classes/Contact) - a Cloud Code
Parse.Cloud.run(...)that constructs the row via the JS Parse SDK
...will not trigger your Ruby after_save. The row appears in the
database and your "every Contact gets a welcome email" promise quietly
breaks. If a side effect must fire on every save regardless of
client, put it in a Parse Cloud Code afterSave trigger (server-side
JS) — or in an external worker that subscribes to a LiveQuery on the
class. Ruby after_save is for side effects scoped to this app's
saves only.
The same caveat applies to ACL defaults, derived fields, soft-delete flags, audit columns — anything you wire into a Ruby callback expecting it to "always run" only runs when the write originates from this Ruby process through this model class.
Same-stack deployments: don't double-fire non-idempotent hooks
A pure no-master-key client (what this guide covers) doesn't host Parse Cloud Code webhooks, so the only place a callback can run is in your Ruby model. No double-fire risk on this side of the wire.
That changes the moment the same Ruby process is also the master-key
server hosting the Parse::Webhooks Rack handler. In that dual-role
deployment, a single contact.save from your app can produce two
hook-firing opportunities — the local after_save in the calling
thread, and the Parse Cloud afterSave webhook trigger dispatched
back into the same process. Non-idempotent side effects (welcome
emails, billing increments, outbound API calls) will double up.
The mitigation lives on the server-side / webhook docs: the
master-key request origin is what lets a webhook handler short-circuit
when it sees a same-stack save. It is not a feature of the client
package and there is nothing to configure here. The principle to carry
across is just: pick one site per non-idempotent side effect (Ruby
model callback or Cloud Code webhook, never both), and if you're
about to run a Ruby after_save AND a Parse::Webhooks.route(:after_save,
...) handler that do the same work, that's the bug. See the webhooks
section of the main README and lib/parse/webhooks.rb for the
server-side guidance.
4. ACL — the row-level boundary
For the full ACL + CLP reference, including aggregate-query enforcement asymmetry, Atlas Search, mongo-direct, role hierarchy direction,
protectedFieldssemantics including the_Userowner-exempt trap, and field-guard write protection, seeacl_clp_guide.md. The sections below are a client-mode quickstart.
Parse Server enforces ACL on every read and write against a non-master caller. The SDK's job is to (a) thread the session token in so the server has someone to check against, and (b) compose ACLs correctly on the wire so the right people get the right access.
4.1 ACL policies on a class
class Post < Parse::Object
parse_class "Post"
acl_policy :public # everyone can read/write by default
property :title, :string
end
class Note < Parse::Object
parse_class "Note"
acl_policy :owner_else_private # default — see below
property :body, :string
belongs_to :author, as: :user
end
| Policy | What gets stamped on save | When to use |
|---|---|---|
:public |
{"*": {"read": true, "write": true}} |
Public/anon-readable feeds |
:public_read |
{"*": {"read": true}} |
Read-only catalogs, lookup tables |
:private |
{} (master-key-only) |
System rows, audit logs |
:owner_else_private |
Owner ACL if :author resolves, else {} (master) |
Default — safe by default |
:owner_else_public |
Owner ACL if :author resolves, else public |
Public content authored by user |
:owner_but_public_read |
Owner R/W + {"*": {"read": true}} (public-read fallback when no owner) |
Public posts authored by one user |
:public_read is read-anywhere, master-key-write — no client can mutate
the row through ACL. :owner_but_public_read is the "public posts with
one author" case: the resolved owner gets R/W while the rest of the world
gets read-only access; when no owner resolves it degrades to
:public_read semantics rather than master-key-only.
:owner_else_private is the SDK's default for a reason: if your model
forgets to declare an owner field, your rows are stamped master-only and
become invisible to clients. That's exactly what you want — a noisy
failure mode beats a silent permission leak.
4.2 Building an ACL on a record
post = Post.new(title: "draft")
post.acl.everyone(false, false) # turn off public
post.acl.apply(me.id, true, true) # owner: read + write
post.acl.apply_role("Editors", true, true) # role-grant read+write
post.save(session: me.session_token)
Wire shape after everyone(false, false) + apply(me.id, true, true):
{ "<me.id>": { "read": true, "write": true } }
The * entry is suppressed entirely (or persisted as nil, which Parse
Server treats as absent). There's no {"*": {"read": false}} on the
wire — that'd be redundant.
4.3 What clients see
acl.everyone(true, false)→ public-read, public-write-denied. Other authenticated users and anonymous clients can fetch the row, but their saves on the row are rejected.acl.everyone(false, false) + acl.apply(me.id, true, true)→ strictly owner-only. Other users getnilon fetch (Parse Server filters by ACL on the query result; the row simply isn't in their result set).:owner_else_privatewith no resolved owner → empty ACL{}. Master key only. Even the user who created the row can't see it from a client session unless you also stamp an ACL.
4.4 The _User row
A user's own _User row is ACL'd to themselves at signup. They can
update their own email/password from a client session:
Parse.client.update_object(
"_User", me.id, { "email" => "new@example.com" },
session_token: me.session_token, use_master_key: false,
)
But cannot modify another user's _User row — Parse Server returns
"Insufficient auth." on the cross-user write attempt. This is enforced
server-side; the SDK just relays the rejection.
5. Roles — and a direction gotcha
Role grants apply at the row level the same way per-user grants do —
acl.apply_role("Admin", true, true) puts the Admin role on the row's
ACL and any user in Admin (or any role that inherits Admin) gets access.
5.1 Membership
admin_role = Parse::Role.find_or_create("Admin")
admin_role.add_users(alice, bob).save
This must run under the master key. Parse Server defaults _Role CLP
to master-only writes — a non-master client cannot rename a role, add
users to it, or create one. Calling update_object("_Role", …) from
client mode returns an auth error; the SDK does not silently strip the
write.
5.2 Hierarchy — read this carefully
This is the single most counter-intuitive piece of Parse Server role semantics. The shorthand "role hierarchy" can mean two opposite things and the SDK exposes both, with sharply different names.
Per Parse Server's getAllRolesForUser expansion: a role's roles
relation contains child roles whose users inherit access through this
role. Put another way: if you want SuperAdmin to inherit Admin's
capabilities, you put SuperAdmin into Admin's roles relation —
not the reverse.
The SDK exposes a direction-explicit method to avoid mistakes:
super_role = Parse::Role.find_or_create("SuperAdmin")
super_role.add_users(super_user).save
# "SuperAdmin should inherit everything Admin can do."
super_role.inherits_capabilities_from!(admin_role)
Under the hood this adds SuperAdmin to Admin's roles relation. Now any
row ACL'd to role:Admin is readable by SuperAdmin members too, because
the server's role-graph expansion traverses Admin → SuperAdmin when
resolving the caller's effective roles.
The older add_child_role method goes the other direction and is
preserved for backwards compatibility. If you find yourself reaching for
it: stop, and use inherits_capabilities_from! instead. Getting the
direction wrong is a privilege-escalation bug, not just a confusion.
6. CLP — the class-level boundary
Class-Level Permissions live one layer above ACL. They gate what operations are even allowed on the class before ACL is consulted on individual rows.
CLP is master-key-only to configure. From client mode you observe its effects; you can't change it.
6.1 The common shape
schema = {
"className" => "Note",
"fields" => {
"body" => { "type" => "String" },
"secretField" => { "type" => "String" },
},
"classLevelPermissions" => {
"find" => { "requiresAuthentication" => true },
"get" => { "requiresAuthentication" => true },
"count" => { "requiresAuthentication" => true },
"create" => { "requiresAuthentication" => true },
"update" => { "requiresAuthentication" => true },
"delete" => { "requiresAuthentication" => true },
"addField" => {}, # master-key-only
"protectedFields" => {
"*" => ["secretField"], # strip for everyone but master
},
},
}
Parse.client.update_schema("Note", schema)
With requiresAuthentication: true on find/get/create, an anonymous
(no-token) client call gets rejected before ACL is even consulted —
the response carries code: 101 and an error like
"Permission denied, user needs to be authenticated.". CLP errors
do not raise in the SDK; check response.success? and read
response.error.
6.2 protectedFields — write-but-not-read
"protectedFields" => { "*" => ["secretField"] }
This is the canonical "client sets it but cannot read it back" pattern.
A client-mode caller can write secretField on create/update (Parse
Server accepts the field in the POST body), but the GET/find readback
omits the column. Master-key fetch still sees the value, confirming it
was persisted — not silently dropped.
Both Parse.client.fetch_object and Parse::Query#results strip the
protected field; the SDK doesn't try to re-synthesize it from any
cache. If you see it in your client-side result, your CLP is wrong.
Reads are stripped today; the write response historically still
echoed the value back in the create/update reply. Parse Server's
protectedFieldsSaveResponseExempt option closes that — its default
will change to false in a future version, which strips
protectedFields from write responses too. Set
protectedFieldsSaveResponseExempt: false in your server config to opt
in early. The SDK needs no changes: save merges the response onto the
object (it only overwrites fields the reply contains), so a stripped
protected field keeps its locally-assigned value rather than being
nulled out.
6.3 _Installation is special — CLP can't override the hardcoded gates
Parse Server hardcodes the access policy for _Installation at the REST
layer. CLP on this class is a thin overlay on top of behavior that is
already constrained, so set_clp (or a server-side CLP edit via the
Dashboard) can only tighten the operations Parse Server lets you
configure — it cannot loosen the ones the server pins to master-only.
| Operation | What's actually enforced |
|---|---|
find |
Master key only. Hardcoded. CLP changes are ignored by the server. |
delete |
Master key only. Hardcoded. CLP changes are ignored by the server. |
create |
Open to anonymous clients — X-Parse-Installation-Id is the credential. |
update |
Open when the request's installationId matches the record; else master key. |
get |
CLP applies normally. |
count |
CLP applies normally. |
addField |
CLP applies normally. |
Safe to tighten:
get→requiresAuthentication: trueor master-only. SDKs don't normally GET their own installation from the server; they cachecurrentInstallationlocally.count→ master-only. The push flow doesn't need it, and it removes a small enumeration signal.addField→ master-only. Good hardening default for any class.protectedFields→ hidedeviceToken,GCMSenderId,pushTypefrom non-master reads. These are write-only from the client's perspective in normal SDK flows.
Do NOT tighten:
createrequiring authentication — breaks first-launch device registration for users who haven't logged in yet. If your app pushes to anonymous users, this kills it.updaterequiring authentication — breaks silent device-token refresh and channel subscribe/unsubscribe before login.- Pointer-based
readUserFields/writeUserFieldson_Installation— a device has no stable owning user (it can outlive a session and change users), so user-pointer ACLing is unreliable. - Anything on
find/delete— the server ignores it.
If your app genuinely requires login before any installation write, put
the policy in a beforeSave('_Installation') Cloud Code trigger rather
than in CLP:
Parse.Cloud.beforeSave('_Installation', ({ user, master }) => {
if (!master && !user) throw 'login required';
});
The trigger fires under master-key context and can inspect request.user
directly without disturbing the anonymous registration handshake that
the client SDKs depend on.
6.4 ACL still applies under CLP
CLP says "is this class operation allowed at all?". ACL says "given the
operation is allowed, which rows does this caller see / touch?". An
authed user who passed the CLP gate still gets their result set filtered
by ACL — if Alice writes a row with acl.apply(alice.id, true, true)
only, Bob's query for it (under his own session) returns nothing.
6.5 The other system classes — where CLP isn't the whole story
_Installation (section 6.3) isn't unique. Several Parse Server system
classes either ignore CLP entirely or layer it under hardcoded behavior.
Treat this table as the authoritative answer for "what can I actually
configure here?":
| Class | Does CLP do anything? |
|---|---|
_User |
Yes, but layered under hardcoded protections (password never returned, authData stripped from non-master finds, unauth update requires matching session token, email/username lowercasing, owner-exempt protectedFields). |
_Role |
Yes, layered under role-name regex, relation validation, and hierarchy integrity checks. |
_Installation |
Partial — only get, count, addField, and protectedFields are configurable; find and delete are master-only regardless of CLP. See section 6.3. |
_Session |
Mostly redundant — non-master queries are silently rewritten to { user: <current user> } (RestQuery.js), so a caller only ever sees their own sessions. find also requires a session token. |
_JobStatus, _PushStatus, _Hooks, _GlobalConfig, _GraphQLConfig, _JobSchedule, _Audience, _Idempotency, _Join:* (all relation join tables) |
No — master-key-only at the REST layer (SharedRest.js). CLP changes are ignored. |
Practical consequences when you're building a client-mode app:
- Don't query
_JobStatus,_PushStatus,_Audience,_JobSchedule, or any_Join:*table from the client — those calls require master key. In the SDK, the corresponding model classes (Parse::JobStatus,Parse::PushStatus,Parse::Audience,Parse::JobSchedule) are server-side helpers. Auto-promote to a master-key client or expose them through a Cloud Code function. - On
_Session, you don't need ACLs or CLP to scope queries to the caller — Parse Server already does that. You also can't grant a user visibility into another user's sessions through CLP. - On
_User, never assume CLP alone gates a flow. Password changes, email verification, and session-token rotation have their own paths inRestWrite.js; they fire even if your CLP looks restrictive. - On
_Role, the role graph is validated server-side. CLP can gate who can call create/update/delete, but the contents of therolesandusersrelations are still checked for cycles and bad names.
6.6 _User field visibility — master-only vs. self-only
Two patterns come up constantly on _User:
- Master-only fields — admin-side metadata that the user themselves
should not see. Examples:
my_opinion_of_them,risk_score,moderation_notes. - Self-visible fields — private profile data the user should see
on their own row, but nobody else. Examples:
favorite_color,private_notes, fullemail.
Vanilla protectedFields doesn't express either cleanly on _User,
because Parse Server's protectedFieldsOwnerExempt option (historical
default true) silently exempts the owning user from every
protectedFields rule on _User. So if you write protect_fields "*",
[:risk_score], the user still sees their own risk_score. (Parse
Server's default for this option is changing to false in a future
version; until your server adopts that default, set it explicitly as in
step 1.) The fix has two moving parts on the server:
- Start Parse Server with
protectedFieldsOwnerExempt: false. - Add a self-pointer field on
_Userand populate it from a Cloud Code trigger so each row points at itself:
Parse.Cloud.beforeSave(Parse.User, (req) => {
const u = req.object;
if (!u.get('self')) u.set('self', u);
});
With those in place, the SDK exposes a small DSL on Parse::User:
class Parse::User
property :my_opinion_of_them, :string
property :favorite_color, :string
master_only_fields :my_opinion_of_them
self_visible_fields :favorite_color, via: :self # name of the self-pointer
end
That expands to:
protect_fields "*", ["myOpinionOfThem", "favoriteColor"]
protect_fields "userField:self", ["myOpinionOfThem"]
Resolution (recall: matching groups intersect):
| Caller | Matching groups | Hidden (intersection) | Visible |
|---|---|---|---|
| Other user | * |
myOpinionOfThem, favoriteColor |
neither |
| The user itself | * ∩ userField:self |
myOpinionOfThem only |
favoriteColor |
| Master key | none (master bypasses) | nothing | both |
If your code uses raw protect_fields on _User directly, the SDK
emits a one-time advisory pointing at these helpers and reminding you
to set protectedFieldsOwnerExempt: false. You can still use the raw
form — the override calls through — but the warning is there because
the default Parse Server setting will silently negate a lot of what
the raw protect_fields calls look like they're doing.
7. Files
contents = File.read("note.txt")
response = Parse.client.create_file(
"note.txt", contents, "text/plain",
session_token: me.session_token, use_master_key: false,
)
file_name = response.result["name"] # server-assigned, deduplicated
file_url = response.result["url"]
# Attach to a row.
file = Parse::File.new(file_name, nil, "text/plain")
file.url = file_url
post = Post.new(title: "with-file", attachment: file)
post.save(session: me.session_token)
Parse Server's fileUpload configuration controls who's allowed to
upload:
enableForPublic: true— anonymous clients can upload.enableForAnonymousUser: true— clients with an anonymous-user Parse session can upload.enableForAuthenticatedUser: true— clients with a real session can upload.
The SDK does not pre-flight this — if uploads are disabled, the server
returns a File upload by … error and the SDK surfaces it. If you want
authenticated uploads only, set enableForPublic: false and
enableForAnonymousUser: false and require a session token on every
upload call.
Parse::File#save (the convenience surface) runs through Parse.client
without an explicit session, so it inherits whatever session the
default client is configured with — which for client mode means
"anonymous unless your server allows it." Prefer
Parse.client.create_file(…, session_token: …) in client builds.
8. Cloud Code
response = Parse.call_function(
"myFunction", { argument: "value" },
session_token: me.session_token, use_master_key: false,
)
response.result # whatever the cloud function returned
Cloud functions run server-side with whatever auth context you give
them. Parse.User.current inside the cloud function resolves to the
session token's user — the same user who called the function from the
client. Master-key behavior inside cloud functions is at the cloud
function's discretion (it can call Parse.useMasterKey() server-side).
From the SDK's perspective: pass the session token, get back the
function's result.
beforeSave / afterSave hooks fire on client-mode saves the same way
they fire on master-key saves. If you have a hook that promotes
permissions or validates a write, it runs on the client request — the
SDK doesn't bypass cloud-code hooks just because the caller is
unprivileged.
8.1 Push notifications — server-side only via a cloud function
Parse Server's POST /parse/push endpoint is master-key-only.
There is no session-token authorization model on this surface; the
server unconditionally rejects pushes that aren't admin-stamped. The
SDK fails fast on this in client mode rather than letting the call
leave the process anonymous:
Parse.client.push({ where: { deviceType: "ios" }, data: { alert: "hi" } })
# => raises Parse::Error::AuthenticationError("requires master key")
The guard fires at the SDK boundary, before any network request.
Passing use_master_key: true from a client-mode caller still raises
— the guard checks the client's actual master_key, not the per-call
opt. This is intentional: a no-master client cannot send a push under
any flag combination, and the failure is loud enough that callers
notice in dev rather than shipping a silent no-op to production.
The correct pattern is to put push behind a cloud function that the client invokes with its session token. The function decides (a) whether the caller is allowed to trigger this push and (b) which audience the push targets — both decisions happen server-side under admin context:
// In cloud/main.js on the server
Parse.Cloud.define("notifyFollowers", async (req) => {
const user = req.user;
if (!user) throw "Authentication required";
// Server-side authz: only paid accounts can fan-out push
if (!user.get("subscriptionActive")) {
throw "Subscription required to send notifications";
}
await Parse.Push.send(
{
where: new Parse.Query("_Installation").equalTo("followsUser", user),
data: { alert: req.params.message, badge: "Increment" },
},
{ useMasterKey: true } // server-side, never trusted from the client
);
return { sent: true };
});
From the client, the call is an ordinary cloud-function invocation
threaded with the session token — no master key in the client
process, no /push REST call, no chance of audience-targeting being
controlled by an attacker who tampers with the wire payload:
Parse.with_session(me.session_token) do
response = Parse.call_function(
"notifyFollowers",
{ message: "New post" },
use_master_key: false,
)
response.success? # => true / false
end
Two reasons this is the right shape, not just a workaround:
- Audience targeting belongs on the server. A client that
constructs a
where:query and posts it to/pushhas full control over who receives the notification. With a cloud function in front, the server owns theParse.Query("_Installation")construction; the client only supplies the message body. - The same cloud function is a natural choke point for rate
limiting, abuse signals, and audit trails. None of those belong
in a client process, and
/pushdoesn't expose hooks for them.
The same pattern applies to anything else master-key-only that you want a client to trigger — see §12 for the full master-only matrix.
9. Analytics
POST /events/<name> is a public-writable surface and the SDK relays it
without requiring auth. The top-level Parse.track_event shortcut takes
dimensions as a keyword so Ruby 3 keyword-separation doesn't swallow them
into **opts:
Parse.track_event("search",
dimensions: { priceRange: "1000-1500", source: "ios", dayType: "weekday" }
)
Threaded with a session token (or any other request-layer option):
Parse.track_event("search",
dimensions: { source: "ios" },
session_token: me.session_token,
use_master_key: false,
)
If you call Parse.client.send_analytics directly, the dimensions must be
the second positional argument — passing them as bare keywords would
also be absorbed by **opts:
Parse.client.send_analytics(
"search",
{ priceRange: "1000-1500", source: "ios" }, # positional Hash
session_token: me.session_token, use_master_key: false,
)
Parse Server's default analyticsAdapter is a no-op — events are accepted
but neither persisted nor queryable through the SDK. (The legacy parse.com
eight-dimension cap does NOT apply to Parse Server out of the box; if you
configure a custom adapter, it decides whether to cap and how.) For
queryable analytics, define a Parse::Object subclass and write rows;
see the "Analytics" section of docs/usage_guide.md.
Parse Server also accepts at: for backfilling the event timestamp; pass
it inside the dimensions hash so it reaches the POST body:
Parse.track_event("session_start",
dimensions: { at: (Time.now - 60).utc.iso8601, platform: "test_harness" }
)
10. Cloud Config
GET /config returns the app's Cloud Config. Parse Server automatically
strips entries whose masterKeyOnly flag is true when the caller is
not the master key — the client never sees those values.
Parse.client.config! # force fetch
Parse.client.config["theme"] # public key, visible
Parse.client.config["api_secret"] # nil — masterKeyOnly entry, stripped
Parse.client.master_key_only # {} for non-master callers
PUT /config is master-key-only. From client mode Parse.client.update_config(…)
either returns false or raises an auth-class Parse::Error. The SDK
does not silently downgrade or retry the write.
11. LiveQuery
LiveQuery is opt-in in the SDK because it opens a WebSocket egress surface that operators should consciously enable:
Parse.live_query_enabled = true
require "parse/live_query"
client = Parse::LiveQuery::Client.new(
url: "wss://parse.example.com/parse",
application_id: "MY_APP_ID",
client_key: "MY_REST_API_KEY",
master_key: nil, # explicit — see below
auto_connect: true,
)
sub = client.subscribe(
"Post",
where: { author: me },
session_token: me.session_token,
)
sub.on(:create) { |row| handle_new(row) }
sub.on(:update) { |row| handle_update(row) }
Subscriptions are scoped by session_token and ACL is enforced
server-side on every event before it goes out the WebSocket — Bob will
not receive an event for an ACL-private row Alice creates, even if his
subscription matches the where clause.
Master-key authorization is per-CONNECTION, not per-subscription. Parse Server resolves master-key (ACL/CLP-bypass) authorization once, from the connect frame; once set, EVERY subscription on that socket bypasses ACL/CLP. The SDK therefore keeps connections ACL-scoped by default: a configured
master_keydoes NOT elevate the connection. To build an admin (ACL-bypassing) connection — an event tap that sees every row regardless of ACL — opt in explicitly:admin = Parse::LiveQuery::Client.new( url: "wss://parse.example.com/parse", application_id: "MY_APP_ID", master_key: ENV["PARSE_MASTER_KEY"], use_master_key: true, # whole connection bypasses ACL/CLP; warns at connect )There is no per-subscription master key —
subscribe(use_master_key: true)on a scoped connection warns and stays ACL-scoped. For a process that needs both scoped and admin streams, use two separate clients. Useclient.admin_connection?to check whether a connection is elevated.Configuration tip.
Parse::LiveQuery::Client.newreadsmaster_keyfrom configuration if you omit it. Passingmaster_key: nilexplicitly in client builds is still good hygiene (the SDK preserves a sentinel so it can tell "not provided" apart from "explicitly nil"), but note that as of v5.1.0 a present master key alone no longer elevates a LiveQuery connection — onlyuse_master_key: truedoes.
12. Endpoints that fail closed in client mode
These exist for completeness — they ALL require the master key and the SDK will fail loudly (raise or return an unsuccessful response) when you call them without it:
| Endpoint | SDK call | Why master-only |
|---|---|---|
POST /aggregate/<Class> |
Parse.client.aggregate_pipeline(…) |
Bypasses ACL/CLP/protectedFields server-side |
GET /schemas |
Parse.client.schemas |
Schema introspection is admin-only |
PUT /schemas/<Class> |
Parse.client.update_schema(…) |
Schema mutation is admin-only |
PUT /config |
Parse.client.update_config(…) |
Config mutation is admin-only |
_Role mutation |
Parse.client.update_object("_Role", …) |
Default CLP locks _Role writes to master |
Cross-user _User write |
Parse.client.update_object("_User", o) |
ACL on _User rows blocks cross-user writes |
_Session enumeration |
Parse.client.find_objects("_Session") |
Scoped to caller; anon gets rejected; no master = no full list |
Trying to call any of these without master should be treated as a code
smell, not a thing to work around. If you find yourself wanting to: the
correct fix is almost always (a) put the operation behind a cloud
function that runs server-side with useMasterKey, then call that
cloud function from the client, or (b) move the work to a privileged
worker process that's separate from your client deployment.
13. Error handling — the response shape
The SDK has two error paths and you need to be aware of both:
- HTTP-level errors (401/403/5xx). These come back as
Parse::Errorsubclasses andraise. Wrap calls that might hit auth-class failures inbegin/rescue Parse::Error => e. - Parse-protocol errors (
code: 101etc.). These return aParse::Responsewithresponse.success?false and the message onresponse.error. They do not raise. The most common one is the CLP/ACL denial —"Permission denied","Object not found"(Parse Server's uniform shape for "you can't see it OR you can't touch it"), or"Insufficient auth".
Robust client code checks both:
begin
response = Parse.client.update_object(
"Post", id, { "title" => "v2" },
session_token: me.session_token, use_master_key: false,
)
if response.success?
handle_ok(response.result)
else
handle_denied(response.error) # CLP/ACL rejection — not an exception
end
rescue Parse::Error::InvalidSessionTokenError => e
prompt_login_again(e) # token revoked / expired
rescue Parse::Error => e
log_and_surface(e) # HTTP-level or transport failure
end
A bare assert_raises(Parse::Error) around a CLP rejection will be
silently wrong — the call returns an unsuccessful response, doesn't
raise. The test suite codifies this; production code should too.
14. Putting it together
A complete client-side write that respects ACL, threads auth, and handles both error shapes:
require "parse/stack"
Parse.setup(
server_url: ENV.fetch("PARSE_SERVER_URL"),
app_id: ENV.fetch("PARSE_APP_ID"),
api_key: ENV.fetch("PARSE_REST_KEY"),
master_key: nil,
logging: false,
)
raise "client builds must not ship master key" if Parse.client.master_key.present?
class Note < Parse::Object
parse_class "Note"
acl_policy :owner_else_private
property :body, :string
belongs_to :author, as: :user
end
def create_note(username:, password:, body:)
me = Parse::User.login(username, password)
return [:auth_failed, nil] unless me
note = Note.new(body: body, author: me)
# owner_else_private resolves :author → stamps ACL{ me.id => rw }
begin
if note.save(session: me.session_token)
[:ok, note]
else
[:rejected, note.errors]
end
rescue Parse::Error => e
[:error, e]
end
end
That's the full shape. No master key in sight, no implicit ambient auth, every call carries its session, both error paths handled explicitly.
15. Audit logging — what gets redacted, what doesn't
When Parse.logging is enabled (or you've installed a custom logger
on Parse::Client), the request/response middleware writes a record
of every HTTP call the SDK makes. That log is operational data
— it sits in your application log stream, gets shipped to whatever
log aggregator you use, and is readable by anyone with access to
that aggregator. The SDK assumes the log stream is less privileged
than the Parse Server itself and redacts accordingly.
15.1 What is automatically redacted
Parse::Middleware::BodyBuilder runs two passes over every logged
request and response — a key-name-based scrub (scrub_sensitive!)
and a shape-based vector compactor (compact_vectors!).
Body fields — when a hash key matches any of the
SENSITIVE_FIELDS names (case-insensitive), the entire value
under that key is replaced with the literal string "[FILTERED]".
The walker recurses into nested hashes and arrays, so a sensitive
key buried inside a batch envelope or under a deeply-nested
pointer payload is still caught. The walker also detects strings
that look like embedded JSON (e.g. a serialized log line stored
back as a field value) and re-runs the scrub on them.
Sensitive key names — matched case-insensitively:
| Key name | Replaced with |
|---|---|
password |
"[FILTERED]" |
token, sessionToken, session_token |
"[FILTERED]" |
access_token, refreshToken, refresh_token |
"[FILTERED]" |
authData (the entire provider block) |
"[FILTERED]" |
masterKey, master_key |
"[FILTERED]" |
apiKey, api_key |
"[FILTERED]" |
clientKey, client_key |
"[FILTERED]" |
javascriptKey, javascript_key |
"[FILTERED]" |
Two notes on the authData row: (a) the WHOLE provider block is
replaced — authData.anonymous.id, authData.facebook.access_token,
authData.apple.id_token all disappear together, so OAuth tokens
never escape into logs even on a provider the SDK doesn't know
about yet; (b) the redactor catches authData whether it appears
in a login payload, a signup payload, an upgrade_anonymous! PUT,
or a passing-through GET /users/me response.
Parse::Middleware::BodyBuilder.redact(str) is also exposed as a
last-line string-level pass that re-applies a regex over the
already-scrubbed text. The regex catches the small set of cases the
structural walker can miss — password=hunter2 style query strings
in URLs, sensitive values inside array elements, and any embedded
text the structural pass already converted to "[FILTERED]" is
left alone (the regex is a backstop, not a re-redactor).
Request headers — these are always replaced with "[FILTERED]"
in debug logs, matched case-insensitively against the Faraday
header keys:
| Header |
|---|
X-Parse-Master-Key |
X-Parse-REST-API-Key |
X-Parse-Session-Token |
X-Parse-JavaScript-Key |
Authorization |
Cookie |
X-Api-Key |
OpenAI-Organization, OpenAI-Project |
Anthropic-Api-Key |
The OpenAI/Anthropic entries cover the case where embedding-provider
HTTP traffic shares the Parse logging path — the official OpenAI auth
header is Authorization: Bearer … (covered above), but Organization
and Project IDs are account-identifying metadata operators may not
want published.
Vector embeddings — see §15.3.
The redactor operates on a copy of the body so the live request/response objects keep their values; subsequent middleware handlers (retry, cache, error mapping, model hydration) see the real data, only the log line is scrubbed.
15.2 What is not redacted
The redactor is deliberately conservative. These ride through to the log stream as-is, and you should treat your log stream's access controls accordingly:
- Class-level data values — every saved/fetched row's columns
end up in the log when
Parse.loggingis at debug level. If you store PII (email, phone, addresses, profile body text), it lands in logs in the clear. The SDK can't tell PII from non-PII at this layer. - Query bodies — every
where:clause is logged verbatim. A query likePost.where(authorEmail: "ada@…")puts the email in the log. - Cloud function arguments and return values —
Parse.call_functionarguments and the cloud function's response body are logged in full. If your cloud function accepts or returns a secret, redact it before logging. - File names, file URLs, file sizes.
POST /files/<name>and the resultingParse.FileURL are logged. The bytes themselves are not (the body builder uses a…placeholder for binary payloads). - Email addresses on
_Userrows. Email is treated as ordinary column data — not redacted at this layer. Use Parse Server'sprotectedFieldsif you want it stripped on cross-user reads.
15.3 Vector embeddings
Embeddings are a special case worth calling out — they are caught
by shape, not by key name. A 1536-float embedding inlines as
~25 KB per logged row, and embeddings are reversible-by-similarity
against a public model: an attacker who scrapes operator logs can
recover topic, sentiment, and sometimes near-verbatim short text
from the raw vector. The compact_vectors! pass walks the logged
body and replaces any numeric-only Array of length ≥ 32 with the
single placeholder string "<vector dims=N>". Coverage:
$vectorSearch.queryVectorin aggregate request bodies.:vectorfield values inPOST/PUTrequest bodies.Klass.find_similar(vector: …)request bodies.- Batched embedding-provider response shapes (when you've installed your own provider that logs through this middleware).
The 32-element threshold sits well below every common embedding
width (BGE-small 384, Cohere 1024, OpenAI small 1536, OpenAI large
3072) and well above any normal Parse Array property — tags,
role pointer lists, attachment id arrays. The all-Numeric guard
prevents the rule from mangling long string-array or
object-array properties.
15.4 Master-key context — what's logged regardless
A few outbound calls log enough metadata to identify a request even under redaction:
- HTTP method + URL path are always logged.
- Request
objectId(path segment) is always logged. - Response status code and Parse
codefield are always logged.
This is deliberate — without these, an audit trail can't link a user complaint ("I lost my draft at 14:02") to a server-side action. The redactor's job is to keep secrets and reversible identifiers out of the log, not to anonymize the trail itself.
15.5 Custom redaction
If you store sensitive values in column data and need them stripped
before they hit your log aggregator, the cleanest hook is a custom
middleware in front of BodyBuilder — or, if you only need to
filter the final formatted log line, a Logger subclass that
overrides add and applies a regex strip. Don't try to mutate the
Parse::Response body to redact inbound data; downstream model
hydration runs against that body and needs the real values.
class RedactingLogger < Logger
SENSITIVE = /"(stripeCustomerId|ssn|apiKey)":"[^"]+"/
def add(severity, = nil, progname = nil, &block)
if .is_a?(String)
= .gsub(SENSITIVE, '"\1":"<redacted>"')
end
super
end
end
Parse.setup(
server_url: "…", app_id: "…", api_key: "…",
logger: RedactingLogger.new($stdout),
logging: :debug,
)
The custom-field redaction is your responsibility — the SDK only knows about the auth surface and the embedding surface because those are stable across deployments. Anything app-specific (tenant ids, payment metadata, internal account numbers) needs an app-specific filter.
16. Client-mode Parse::Agent (v5.0)
Parse::Agent follows the same posture as the rest of this guide. When
constructed against a no-master client with a session token, it enters
client mode and restricts itself to a session-token REST allowlist
(list_tools, get_object, get_objects, query_class,
count_objects, get_sample_objects, plus the mutation trio
create_object / update_object / delete_object behind an
allow_mutations: gate). Everything that needs master-key REST
(aggregate, atlas_*, get_all_schemas) or a direct MongoDB
connection (mongo-direct aggregations, vector search) is refused at the
dispatch ceiling.
agent = Parse::Agent.new(session_token: me.session_token)
agent.client_mode? # => true
agent.allow_mutations? # => false (default)
agent.execute(:query_class, class_name: "Post", limit: 10) # ACL-enforced by Parse Server
writer = Parse::Agent.new(session_token: me.session_token, allow_mutations: true)
writer.execute(:create_object, class_name: "Post", fields: { title: "Hi" })
acl_user: and acl_role: are refused at construction on a no-master
client — they're SDK-side identity assertions that require the
master-key mongo-direct path to enforce. Use session_token: as the
identity instead. Full reference (custom tools with client_safe: true,
sub-agent inheritance, refusal-message shapes) is in
docs/mcp_guide.md § Client Mode.
17. Cross-references
test/lib/parse/client_rest_auth_integration_test.rb— signup, login, logout, current_user, MFA surfacetest/lib/parse/client_rest_crud_integration_test.rb— save, fetch, update, destroy, query, include, ACLtest/lib/parse/client_rest_acl_integration_test.rb— ACL policies, wire shape, cross-user_Userwritetest/lib/parse/client_rest_roles_integration_test.rb— role membership, hierarchy direction,_Rolewrite blocktest/lib/parse/client_rest_clp_anonymous_integration_test.rb— CLP enforcement andprotectedFieldstest/lib/parse/client_rest_files_integration_test.rb— authed + anonymous file upload behaviortest/lib/parse/client_rest_analytics_integration_test.rb—/eventsround-trip under client modetest/lib/parse/client_rest_cloud_config_integration_test.rb—/configvisibility and write rejectiontest/lib/parse/client_rest_forbidden_paths_integration_test.rb— master-only endpoints fail closedtest/lib/parse/client_livequery_integration_test.rb— LiveQuery handshake without master keytest/support/client_mode_helper.rb— the test harness pattern these tests share