Abrova
☰ Docs menu · Security rules
Coming soon. Abrova Sync is coming soon. This page describes how it will work; it can’t be used yet.

Security Rules

Protect Abrova Sync data with JSON-based security rules. Databases start locked, auth.uid and custom claims come from a signed user token, rules can look up other documents for teams and roles, and queries must match the rules.

Last updated: 27 Oct 2026

Rules decide who may read and write each part of your database. They are evaluated on every request from a client SDK, using the identity of the user making it — reads, queries, listeners, writes and batches alike.

A new database is locked.

With no rules saved, a database refuses every read and write from a client SDK (a request carrying your server secret still gets through). Write rules before your app can use it. You can always browse and edit your data in the Abrova Console — a signed-in Console session is not subject to rules, so you cannot lock yourself out.

Rules anatomy#

KeyAllows
.readReading: one document (.get) and queries / listeners on a collection (.list).
.get · .listThe two halves of .read, when they need different rules.
.writeWriting: creating (.create), changing (.update) and deleting (.delete) a document.
.create · .update · .deleteThe three halves of .write.
.validateA check every write must also pass.
$wildcardMatches any path segment and binds it as a variable for the rules below it.

How they combine:

  • Any rule that allows, allows. The root, every level on the way down and both the general and the specific key at a level (.read and .get) are checked; one that holds is enough. No rule that holds = denied.
  • Every .validate on the path must hold, from the root down, for add(), set(), update() and every write in a batch.
  • A create is a write to a document that doesn't exist yet (data is null); a delete is a write with newData null; anything else is an update.

Example ruleset#

json
{
  "rules": {
    "posts": {
      ".read": true,
      ".write": "auth.uid === 'editor-1'"
    },

    "notes": {
      "$noteId": {
        ".get": "data.ownerId === auth.uid",
        ".list": "data.ownerId === auth.uid",
        ".create": "newData.ownerId === auth.uid",
        ".update": "data.ownerId === auth.uid && newData.ownerId === auth.uid",
        ".delete": "data.ownerId === auth.uid",
        ".validate": "newData.hasChildren(['title'])"
      }
    },

    "users": {
      "$userId": {
        ".read": "auth.uid === $userId",
        ".write": "auth.uid === $userId"
      }
    }
  }
}

Save rules in the Abrova Console under your database. The Console checks them as you type, and refuses to publish a rule that can't be read (a typo, an unknown function, a $name that isn't on its path).

Queries and listeners: rules are not filters#

A query or a listener is allowed only when the rules allow every document it could return. If they don't, the whole query is refused — it is never quietly trimmed to the documents you may see.

The rules are checked against the query itself: a field the query compares with == (or in, up to 10 values) is known, any other field is not. So with the notes rules above:

QueryResult
notes where ownerId == <the user's id>Allowed: every result has ownerId === auth.uid.
notes (no filter)Refused (query_not_allowed): it could return other users' notes.
notes where ownerId == 'someone-else'Refused.

A refused query fails with 403 query_not_allowed; the error names the fields the rules read, so you know what to filter on. Filters the rules don't mention (ranges, !=, sorting, limits) only narrow the results and never stop a query that is otherwise allowed. Rules can also read request.query.limit to cap page sizes.

A rule that needs the document's own id (auth.uid === $userId) can't allow a query — the query doesn't have one. Such rules suit .get; give .list a rule on a field instead.

Filter queries on the field your rule reads.

A query or listener without the filter the rules need is refused with query_not_allowed — even if every document it would return happens to be yours. When a rule reads data.ownerId === auth.uid, query with where('ownerId', '==', <the signed-in user's id>); when it looks up a membership by data.teamId, query with where('teamId', '==', teamId).

Teams, groups and roles: rules that look up documents#

Rules can read other documents of the same database:

FunctionReturns
exists('collection/' + id)true when the document exists.
get('collection/' + id).data.fieldA field of that document (.id is its id). A document that does not exist is an error, so the rule denies.
getAfter('collection/' + id).data.fieldThe same, as the document will be once the current write or batch is saved.

Build the path from text written in the rule and values joined with +: $wildcards, auth.uid, document fields. A value can only ever be one id — a value with / in it, or an empty one, makes the lookup fail and the rule deny — so data a user chose can't point the lookup at another collection.

A team with members and roles, kept by your app alone (no server needed):

json
{
  "rules": {
    "teams": {
      "$teamId": {
        ".get": "exists('team_members/' + $teamId + '_' + auth.uid)",
        ".create": "newData.createdBy === auth.uid",
        ".update": "get('team_members/' + $teamId + '_' + auth.uid).data.role === 'admin'",
        ".delete": "get('team_members/' + $teamId + '_' + auth.uid).data.role === 'admin'"
      }
    },
    "team_members": {
      "$memberId": {
        ".read": "data.userId === auth.uid || exists('team_members/' + data.teamId + '_' + auth.uid)",
        ".create": "$memberId === newData.teamId + '_' + newData.userId && ((newData.userId === auth.uid && newData.role === 'admin' && !exists('teams/' + newData.teamId) && getAfter('teams/' + newData.teamId).data.createdBy === auth.uid) || get('team_members/' + newData.teamId + '_' + auth.uid).data.role === 'admin')",
        ".update": "get('team_members/' + data.teamId + '_' + auth.uid).data.role === 'admin' && newData.teamId === data.teamId && newData.userId === data.userId",
        ".delete": "data.userId === auth.uid || get('team_members/' + data.teamId + '_' + auth.uid).data.role === 'admin'"
      }
    },
    "lists": {
      "$listId": {
        ".read": "exists('team_members/' + data.teamId + '_' + auth.uid)",
        ".create": "get('team_members/' + newData.teamId + '_' + auth.uid).data.role in ['admin', 'editor']",
        ".update": "newData.teamId === data.teamId && get('team_members/' + data.teamId + '_' + auth.uid).data.role in ['admin', 'editor']",
        ".delete": "get('team_members/' + data.teamId + '_' + auth.uid).data.role in ['admin', 'editor']"
      }
    }
  }
}
  • Create a team in one batch: the team document and its creator's admin member document (<teamId>_<userId>). getAfter() sees the team the same batch creates; !exists(…) makes sure it is new.
  • Members read their team with queries that filter on what the rules check: team_members where teamId == T (allowed for members of T) or where userId == <me> ("my teams"), lists where teamId == T.
  • Nobody moves a document to another team: .update requires teamId to stay the same.
  • Removing a member takes effect at once, also for that member's open listeners (SDK 0.3.0 and later): they are checked again and end.
  • Invites: add an invitations collection whose documents the invited user marks used in the same batch that creates their member document, and check it with get() / getAfter(). Use long random invite ids (at least 128 bits) and give invitations a .get rule but no .list, so they can't be listed.

A batch is checked and saved as one: two people using the same invite at the same moment can't both get in, and a member removed a moment before can't still write.

Limits and cost

  • At most 10 documents looked up per request or query, 20 per batch (and 10 per operation in it). Beyond that the request is denied.
  • Each document looked up is read once per request and billed as a document read.
  • Lookups read only the same database, and are not themselves subject to rules.

Where auth.uid comes from#

Rules are only as trustworthy as the identity behind them. Your API key is public — it ships inside every app bundle — so it cannot decide who the user is.

CredentialWhere it livesWhat it proves
API key (ab_live_…)Inside your app. Public.Which project the request belongs to.
Server secret (ab_srv_…)Your server only. Never in an app.That a request comes from your backend.
User tokenYour app, one per signed-in user.Which user is acting. This is what sets auth.uid.

Your backend mints a user token with the server secret and hands it to your app:

js
// Your backend, behind your own session check
const response = await fetch('https://sync.abrova.ir/users/token', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'x-api-key': process.env.ABROVA_API_KEY,
    'x-server-secret': process.env.ABROVA_SERVER_SECRET,
  },
  body: JSON.stringify({ userId: session.userId }),  // your id for the user
});

const { token, expiresIn } = await response.json(); // expiresIn: 3600 by default

Every SDK takes it through a token provider and refreshes it before it expires — see Initialization. With Abrova Auth your app gets this token without a server of its own.

One token for every Abrova product

If your app also uses Abrova Chat, Abrova Push or Abrova A/B Testing, mint one token for all of them from the Abrova auth endpoint instead:

bash
curl -X POST https://auth.abrova.ir/users/token \
  -H "x-api-key: $ABROVA_API_KEY" \
  -H "x-server-secret: $ABROVA_SERVER_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"userId": "user-123", "expiresIn": 3600}'

# → { "token": "eyJhbGciOi…", "userId": "user-123", "expiresIn": 3600, "expiresAt": "2026-09-21T10:00:00.000Z" }

Give that same token to every SDK's tokenProvider — Sync, Chat, Push and A/B Testing. expiresIn is in seconds: 3600 (1 hour) by default, 86400 (24 hours) maximum. Call it from your server, never from an app.

Keep it on the device.

A token is valid until it expires, so store it with the user's session (Keychain, Keystore or secure storage) and reuse it across app launches. Your app then only calls your backend when the token is close to expiry — not on every start.

Custom claims (roles)

Your server can add facts about the user to the token it mints — a role, a membership level, the teams they belong to. Rules read them as auth.token.<name>, and a string role claim is also available as auth.role:

js
body: JSON.stringify({ userId: session.userId, claims: { role: 'admin', membership: 'gold', teams: ['red'] } }),
json
".write": "auth.role === 'admin' || auth.token.teams.includes('red')"

Both token endpoints accept claims: up to 20 names (letters, digits and _), each a text of up to 256 characters, a number, true / false, or a list of up to 50 texts — 1 KB in all. Reserved token names (sub, exp, iat, …) are refused. Claims are signed into the token, so an app can neither add nor change them; a user whose token has no such claim fails the check. Mint a new token when a user's role changes — or keep roles in documents and look them up (step 4), which needs no new token.

How the user signed in

With Abrova Auth, auth.amr says how the user signed in: anonymous, apple, google, password, magic_link or telegram. auth.anonymous is true only for a guest — use auth.anonymous === false to keep guests out of shared data. A token your server mints names a real user: it has no auth.amr, and auth.anonymous is false.

Signing a user out everywhere

In the Console, Sync › Security › Sign a user out everywhere refuses every token issued to that user before that moment, including live connections and listeners. Their apps get 401 token_expired, ask your server for a new token through the token provider, and get one only if your server still issues it.

Signed user tokens.

Only a user token says who the user is. A plain user id sent by an app is not verified, so auth.uid would be a claim rather than a fact — signed user tokens are being required for every project, and Console › Sync › Security shows what your apps still send. Once they are, a request with no user at all is anonymous to the rules: auth is null, so auth !== null refuses it.

An app where no one is signed in yet has no token to send. The client SDKs (0.2.1 and later) handle that for you: the connection waits and the SDK connects after sign-in — see Initialization.

Expression reference#

ExpressionMeaning
true / falseAlways allow / always deny.
auth !== nullA user is acting.
auth.uid === 'value'The acting user is exactly this id.
auth.uid === $userIdA path segment captured by a $ name (either order).
data.ownerId === auth.uidA field of the stored document (either order).
newData.ownerId === auth.uidA field of the document as it will be saved.
data.status === 'paid'Compare with text in quotes, a number, true / false or null. Also !==.
newData.total <= 100Numbers: >, >=, <, <=.
data.address.cityNested fields, with dots.
data.role in ['admin', 'editor']One of a list.
data.members.includes(auth.uid)A list field contains a value.
'owner' in data.rolesA map field has this key.
data.first + ' ' + data.lastJoin texts (or add numbers).
auth.role === 'admin'A role claim from the user token; auth.token.<name> reads any claim.
auth.amr, auth.anonymousHow the user signed in with Abrova Auth.
data === null / newData === nullThe document does not exist yet (a create) / is being deleted.
newData.hasChildren(['a','b'])The document has these fields — useful in .validate.
exists(…), get(…).data.f, getAfter(…)Other documents (step 4).
request.timeThe server time, in milliseconds (e.g. data.expiresAt > request.time).
request.query.limitThe query's limit, in .list / .read rules.
!expr, a && b, a || b, c ? a : b, ( … )Not, and, or, if-then-else, grouping. || binds looser than &&.

How they behave:

  • == is the same as ===: values are never converted, so 5 does not equal '5'.
  • A field the document does not have counts as null.
  • Errors deny. Some expressions can't be decided: a comparison with an auth value that is missing (no user, no such claim), < on something that isn't a number, get() of a document that doesn't exist, a field of a query document the query doesn't filter on. Such a part is neither true nor false: error || true still allows, error && false is false, !error stays an error, and a rule that ends in an error denies. So neither data.ownerId !== auth.uid nor !(data.ownerId === auth.uid) lets a request without a user in.
  • A field used on its own as a condition (data.public) must be true or false.

Who is not subject to rules#

CallerRules apply?
Client SDKs (Web, Flutter, Android, iOS, React Native)Yes — every read, query, listener and write.
Node.js SDK or REST, using your server secretYes, once the database has rules — evaluated like any other caller, with auth.uid set to the userId it sends. A database with no rules saved is not locked to it.
Abrova Console, signed inNo — you own the data, so the data browser always works.
The Node.js SDK is not a rules bypass.

Once a database has rules they are evaluated for it too, so give it an identity with userId in its config and write rules that account for your backend. Always set a userId (or a user token); without one, auth.uid is not your user. For privileged work with no rule to satisfy, use the Console.

Was this page helpful?
Report a problem