Guards
CloudThe Guards plugin lets you block or redirect requests that fail rules you write yourself — users who have not accepted the current terms, a lapsed subscription, a feature that is not in the customer’s plan.
Permissions answer "may this role touch this resource?". Guards answer a different question: "is this particular user, right now, in a state where the application should serve them at all?" That state lives in your data, changes over time, and has nothing to do with roles — which is why it does not belong in an ACL rule.
Navigation path: Service → Guards
How It Works
Every request is evaluated against your rules in order. The first rule whose condition matches performs its action and the evaluation stops. If no rule matches, the request proceeds exactly as it would without the plugin.
Each rule has:
| Field | Meaning |
|---|---|
|
A short identifier, unique within the service. |
|
A description for your own benefit. Not used at runtime. |
|
A predicate expression. When it matches, the action is performed. |
|
|
|
The response status. Defaults to |
|
The error message returned by |
|
The URL |
|
What to do when the condition cannot be evaluated: |
Guards Restrict, They Never Widen
Authorization runs before guards. A request your ACL rules deny never reaches the rules at all, so a guard can only narrow access — it can never grant something a permission refused.
The reverse is worth noting too: a request that is unauthenticated on purpose, such as sign-up, is permitted by the ACL and therefore does reach the guards. That is what makes a rule like "registration is closed" possible.
The Service Administrator Is Never Guarded
Rules are never applied to requests authenticated as the service administrator. This is deliberate and it is what makes the plugin safe to experiment with: a rule that blocks everything still leaves the Cloud UI working, so you can always go back and fix it.
Writing Conditions
Conditions use the RESTHeart predicate language — the same one used by ACL permissions. The variables available are:
| Variable | Value |
|---|---|
|
The authenticated account. |
|
The document of the user’s currently active team, from the |
|
The role this user has in that team — |
|
Request metadata, including |
A few examples:
not path-prefix('/auth') and not path-prefix('/token') and not path('/users/me') and not equals(@user.latestConsents.tos, '2026-07-01')
method(POST) and not equals(@team.plan, 'pro')
path-prefix('/admin') and not equals(@team.role, 'owner')
|
Tip
|
The Guards page validates every condition against the server’s predicate parser before saving. A condition that does not parse cannot be saved. |
@user Only Sees What Is in the Token
For users authenticating with a JWT, @user is the token’s claims — not the user document. A condition on @user.latestConsents will simply never match unless latestConsents is one of the claims your service issues.
Configure the claim list in Service → Users → JWT Claims (see Managing Users). The Guards page warns you inline when a condition names a field that is not in the configured list.
Users authenticating with username and password are not affected — for them @user is the full user document.
Enabling the Plugin
-
Navigate to Service → Guards in the Cloud UI.
-
Click Enable Guards. The UI installs and activates the plugin.
-
The page switches to the rules editor.
With the plugin enabled and no rules configured, nothing changes: every request proceeds as before.
Adding and Editing Rules
-
Click Add Rule. A new rule opens in the editor.
-
Fill in the id, the condition and the action, plus the message or the location depending on the action.
-
Click Done to collapse the rule, then Save to send the rules to the service.
Use the arrows on each rule to change its position. Order is significant: the first matching rule decides.
|
Note
|
Changes are not sent to the server until you click Save. |
|
Tip
|
Configuration changes may take up to 60 seconds to propagate to all service nodes. |
Locking Users Out
A rule matches requests, and the requests users need in order to get back in are requests like any other. A condition that also matches signing in, accepting the terms, or the OAuth callback leaves users with no way through — the application appears broken and nothing in the logs says "misconfigured rule".
Only /ping, /health/db and CORS preflight are never guarded. Everything else your application depends on — including the endpoints that establish the session — is matched like any other path, and has to be excluded in the condition itself:
not path-prefix('/auth') and not path-prefix('/token') and not path('/users/me') and <your actual condition>
Those three are the ones a blocked user needs:
| Path | Why it must stay reachable |
|---|---|
|
Sign-up, email verification, password reset, the OAuth callback — and, for a rule about consents, whatever endpoint records the acceptance. |
|
Signing in. And, crucially, |
|
How the application learns who the user is and what state they are in. Blocked, the frontend cannot even render the screen that asks them to accept. |
There is no separate allowlist field: the exclusions are part of the condition, so what you read is what is evaluated.
Test a rule on a non-production service before enabling it on the one your users depend on.
When a Condition Cannot Be Evaluated
If a condition fails to evaluate at runtime — a variable that does not resolve, an unexpected data shape — the rule’s on_error decides.
The default is allow: the request goes through and the failure is logged. This is deliberate. An application locked out by a broken rule is an incident; a rule that fails open is a problem you can see and fix.
Set on_error to deny when the rule is a genuine security control and letting a request through would be worse than blocking a legitimate one.
Example: Gating on Consents
A user who has not accepted the current Terms of Service and Privacy Policy is served nothing except the requests that let them accept. Five pieces, in the order you configure them.
The Data Model
Two fields on the user document:
{
"latestConsents": {
"tos": "2026-07-01",
"pp": "2026-07-01",
"acceptedAt": { "$date": 1754438400000 }
},
"consents": [
{ "tos": "2026-07-01", "pp": "2026-07-01", "acceptedAt": { "$date": 1754438400000 } }
]
}
-
latestConsents— a flat sub-document, overwritten at each acceptance. This is what the guard condition and the JWT claims read. -
consents— an append-only array, one record per acceptance. The history, for the day someone asks what a user agreed to and when.
Two flags rather than one, because the terms and the privacy policy have versions of their own. Both are accepted by the same request: the client does not choose what it accepts, which is what keeps this to one permission and one rule. An acceptance that must be splittable — the privacy policy mandatory, the terms optional — is a different design, with a permission and a rule per flag, not an extension of this one.
Neither field is written by POST /auth/register, and that absence is exactly what the guard blocks on. There is one way to accept — the PATCH of step 2 — and it is the same one whether the user has just signed up, arrived through OAuth, or has had an account since before the current version existed.
1. Validate the format
Add a JSON Schema on /users (see JSON Schema Validation) that validates the two fields when present. Do not make them required — their absence is the state the guard exists to catch. The schema also declares OAuth fields (socialAuths, profile.avatarUrl) and team fields (teams, team) as optional, so documents created via OAuth or after team operations pass validation.
In Service → Schemas → New Schema the schema _id goes in its own field, not in the document — the editor takes only the schema body. Through the API it goes in the body like in any other MongoDB document: POST /_schemas with {"_id": "userConsentsSchema", …}.
Schema id: userConsentsSchema
Schema document:
{
"title": "User with consents",
"description": "Validates consents format when present; allows OAuth and team fields",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["_id", "password", "roles", "profile"],
"properties": {
"_id": { "type": "string" },
"_etag": { "type": "object" },
"password": { "type": "string" },
"roles": { "type": "array", "items": { "type": "string" } },
"profile": {
"type": "object",
"required": ["name", "surname"],
"properties": {
"name": { "type": "string" },
"surname": { "type": "string" },
"avatarUrl": { "type": "string" }
}
},
"latestConsents": {
"type": "object",
"properties": {
"tos": { "type": "string" },
"pp": { "type": "string" },
"acceptedAt": { "type": "object", "properties": { "_$date": { "type": "number" } } }
},
"required": ["tos", "pp"]
},
"consents": {
"type": "array",
"items": {
"type": "object",
"properties": {
"tos": { "type": "string" },
"pp": { "type": "string" },
"acceptedAt": { "type": "object", "properties": { "_$date": { "type": "number" } } }
},
"required": ["tos", "pp"]
}
},
"socialAuths": { "type": "array" },
"teams": { "type": "array" },
"team": { "type": "object" }
}
}
The two _id in play are not the same thing: the one in the UI field identifies the schema, the one under properties describes the _id of a user document — the email.
|
Important
|
acceptedAt is a BSON date, which renders as {"$date": <millis>}. Inside a schema document the BSON type keys are written with a leading underscore — _$date — so that the request parser does not read them as actual BSON values while the schema itself is being stored (see JSON Schema Validation). Declaring it as {"type": "string"} rejects every acceptance.
|
|
Important
|
Declare _etag and leave it out of required. RESTHeart adds _etag to every document it writes, so a schema that forbids extra properties without declaring it rejects RESTHeart’s own writes; and it is absent on the very first insert, so requiring it rejects document creation. The same applies to any other field the platform adds — with additionalProperties left at its default (permissive) you only need to declare what you constrain.
|
|
Note
|
The schema is enforced on the resulting document, not on the request body, so a PATCH carrying only the consents validates against the whole user document as it will be once written. A PUT, which replaces the document, must carry everything in required.
|
2. Authorize the acceptance
The user accepts with a PATCH /users/{userId} on their own document. Nothing authorizes that request out of the box, and the guard never gets a say on a request the ACL has already refused: without a permission of your own the acceptance is a 403 and the user is locked out for good.
restheart-accounts does install a rule of its own on /users, but it is a veto — it denies self-service writes to _id, password, roles, team, teams, sub, socialAuths, providerId and the one-shot token fields. A veto never grants. Every other field, consents included, is left to your ACL.
Create the permission in Service → Permissions (see Managing Permissions):
{
"_id": "userCanPatchOwnConsents",
"predicate": "path-template('/users/{userId}') and method(PATCH) and (equals(@user._id, ${userId}) or equals(@user.sub, ${userId})) and bson-request-whitelist(consents)",
"roles": ["user"],
"priority": 1,
"mongo": {
"mergeRequest": {
"latestConsents": { "tos": "2026-07-01", "pp": "2026-07-01", "acceptedAt": "@now" },
"_$push": { "consents": { "tos": "2026-07-01", "pp": "2026-07-01", "acceptedAt": "@now" } }
}
}
}
The request the client sends is then just:
curl -X PATCH 'https://<service>/users/<userid>' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{"consents": []}'
A few things are doing work here:
-
${userId}— dollar-brace — is the value the path template captured. The@user.subbranch covers users authenticating with a JWT, where the account id is insubrather than in_id. -
roles: ["user"]is the role an account carries once verified. Use whatever role your service assigns. -
bson-request-whitelist(consents)narrows the permission to that single field: it grants the acceptance and nothing else. This runs beforemergeRequest, so it sees the client’s body, not the enriched one. -
mergeRequestmoves the decision of what is being accepted to the server. Without it the client states the version and the timestamp itself — it could accept terms it was never shown, or backdateacceptedAt. The body above carriesconsentsonly to satisfy the whitelist; its value is discarded. -
The two entries write the two fields of the model.
latestConsentsis a plain field and becomes a$set._$pushis a MongoDB update operator: written with a leading underscore in the permission document, unescaped to$pushbefore the merge, it appends the record to the history array. -
@nowresolves to a BSON date — the same value in both entries.
|
Important
|
Write latestConsents as a nested object, not with dotted keys. A merged request that sets both latestConsents and a path inside it is rejected by MongoDB with 500 ConflictingUpdateOperators: Updating the path 'latestConsents.tos' would create a conflict at 'latestConsents'. The nested object replaces the sub-document in a single key and cannot conflict.
|
3. Expose the flags as claims
Add both latestConsents/tos and latestConsents/pp to the JWT claims in Service → Users → JWT Claims (see Managing Users), so the condition also holds for users authenticating with a token.
|
Warning
|
If one of the two is missing from the claim list, the equals on it is false for every token-authenticated user forever — including those who have just accepted. The rule then blocks them permanently, and nothing in the condition looks wrong.
|
4. Write the rule
The rule must exempt the very request the user sends to accept — the PATCH of step 2. bson-request-whitelist(consents) scopes the exemption to exactly that request, so the rule does not open up self-service writes to any other field.
{
"id": "consentsGate",
"name": "Block users who have not accepted the current ToS and Privacy Policy",
"condition": "not path-prefix('/auth') and not path-prefix('/token') and not path('/users/me') and not (method(PATCH) and path-template('/users/{userId}') and bson-request-whitelist(consents)) and not (equals(@user.latestConsents.tos, '2026-07-01') and equals(@user.latestConsents.pp, '2026-07-01'))",
"action": "block",
"status_code": 451,
"message": "You must accept the current Terms of Service and Privacy Policy",
"on_error": "allow"
}
The same condition, broken up as it reads:
not path-prefix('/auth')
and not path-prefix('/token')
and not path('/users/me')
and not (method(PATCH) and path-template('/users/{userId}') and bson-request-whitelist(consents))
and not (equals(@user.latestConsents.tos, '2026-07-01') and equals(@user.latestConsents.pp, '2026-07-01'))
-
The first three exclusions are the session itself — signing in, renewing the token after the acceptance, and reading the user. See Locking Users Out for why leaving any of them out is a lockout rather than a stricter rule.
-
The exemption whitelists
consents— the same field, written the same way, as the permission of step 2. The two predicates see the same body, so a mismatch between them means the acceptance is authorized and then blocked by the rule that exists to let it through. (bson-request-whitelistdoes take several comma-separated and dotted keys,bson-request-whitelist(consents.tos, consents.pp); that form fits a body that names the sub-keys, not the one above.) -
not (A and B)blocks when either acceptance is missing.not A and not Bwould block only the users who accepted neither — worth reading twice. -
The rule reads
latestConsents, never theconsentsarray. The array is history; access decisions are made on the flat sub-document, whose keys resolve in a predicate with the dotted form@user.latestConsents.tos.
5. Get the user a token that reflects the acceptance
A JWT is a snapshot taken at issuance. After a user accepts, the token they are holding still says they have not, and the rule keeps blocking them — so the acceptance has to be followed by a new token.
Which request you make depends on what the client is holding.
Holding a bearer token — the usual case right after an acceptance — add ?renew=true to get a new one issued without re-sending credentials:
curl -X GET 'https://<service>/token?renew=true' -H 'Authorization: Bearer <token>'
This request is made while the user is still blocked — the token it carries is the one that says they have not accepted. That is why /token is among the exclusions of the condition: without it the acceptance can never take effect.
Authenticating with credentials — ?renew=true is not needed. Every GET /token authenticated with Basic credentials is a fresh issuance built from the user document as it is read at that moment:
curl -X GET 'https://<service>/token' -u '<userid>:<password>'
The renewed token is built from the user document read again from the database, so it carries latestConsents as it is at that moment — along with any other change, including the user’s roles.
|
Note
|
When the user cannot be read from the service’s users collection, the token is renewed from its own claims instead — a later expiry, the same data. That happens when the token was issued elsewhere, or by a realm that is not backed by a users collection. Renewal keeps working; it just has nothing fresher to build from.
|
|
Tip
|
A JWT payload is base64, not encrypted — whatever is in the claim list is readable by any client holding the token. Keep it to the flags the rule compares (latestConsents/tos, latestConsents/pp) rather than the whole sub-document, and leave the consents history out: it is an array that grows at every acceptance, and no access decision reads it.
|
The alternative, and often the better one: have the acceptance endpoint return the new token in the same response. That is what restheart-accounts does when a user switches team or activates their account. It saves a round trip, and the user never passes through a state where they are blocked by a decision they have already made.
Signing out and back in works too. It is the blunt version of the same thing.
Checking It Works
Five requests, on a test account that has not accepted yet. Run them in order — each one tells you which piece is wrong if it does not answer as described.
# 1. A token. Must succeed: if this is blocked, /token is missing from the exclusions.
curl -X GET 'https://<service>/token' -u '<userid>:<password>'
# 2. Any other request. Must be blocked with your status code and message.
curl -X GET 'https://<service>/<collection>' -H 'Authorization: Bearer <token>'
# 3. The acceptance. Must return 200 — a 403 means the permission of step 2 is missing
# or does not match; a 500 ConflictingUpdateOperators means dotted keys in mergeRequest.
curl -X PATCH 'https://<service>/users/<userid>' \
-H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
-d '{"consents": []}'
# 4. A fresh token. Decode the payload: latestConsents must carry both flags at the
# current versions. Only one of them means only one is in the JWT claim list.
curl -X GET 'https://<service>/token' -u '<userid>:<password>'
# 5. The request from step 2 again, with the new token. Must now pass.
curl -X GET 'https://<service>/<collection>' -H 'Authorization: Bearer <token>'
Then check the user document: latestConsents carries the versions and an acceptedAt the server wrote, and consents has one record per acceptance rather than one that keeps being overwritten.
|
Tip
|
With both flags on the same version, a condition mistakenly written not A and not B behaves exactly like the correct not (A and B). To exercise that difference, raise one version in the rule and in mergeRequest and confirm the user is blocked again.
|
How a Client Uses This
The application never checks whether the user has accepted. It reacts to being told:
-
A request comes back with the status code the rule sets —
451above. -
The client shows its acceptance screen.
-
On acceptance it sends the
PATCHof step 2, then gets a new token as in step 5.
Nothing in the client compares versions. That is what keeps the two sides from drifting: raise the version in the rule and in the permission, and every client starts seeing 451 on the next request, with nothing to rebuild or redeploy.
|
Note
|
POST /auth/register can carry the fields — with a JSON Schema configured it copies body properties it does not map itself into the user document — so a sign-up form could record the acceptance up front. There is no mergeRequest on that path, though, so the client would be stating the version and the timestamp itself. Leaving registration alone and letting the same PATCH serve every user is both simpler and better attested.
|
Disabling and Uninstalling
-
Disable — temporarily turns off rule evaluation. Requests proceed as if the plugin were not there. Click Enable to re-activate.
-
Uninstall — removes the plugin and its rules. Re-installing gives you an empty rule list.
Both actions are available in the header bar when the plugin is in the corresponding state.
Best Practices
-
Start with one rule and a narrow condition. Broad conditions are how lockouts happen.
-
Put the exclusions (
/auth,/token,/users/meand friends) at the front of the condition, where they are hard to miss when re-reading it. -
Order rules from most specific to most general — the first match wins.
-
Prefer
blockwith a status code your frontend can act on (451,402,403) over a redirect, unless the client is a browser navigating between pages. -
Leave
on_erroratallowunless the rule is a security control.
Related Pages
-
Managing Permissions (ACL) — role-based access control, evaluated before guards.
-
Managing Users — user documents and the JWT claims a service issues.
-
JSON Schema Validation — enforce the shape of the data your conditions read.
-
Origin Allowlist — restrict which websites can call your API.