Securing Fliplet data sources
How to protect data sources with access rules, required-data validation, column-level restrictions, and custom JavaScript security rules.
Contents
Security rules
Access to Data Sources is secured via the Access Rules tab of the App Data section in Fliplet Studio. Each rule controls who can access the data source, what operations they can perform, and what conditions must be met.
Rules are evaluated from top to bottom. The first rule that grants access is used and evaluation stops — later rules are not checked. If no rules match, access is denied by default. If no rules are defined at all, all access is denied — data sources are locked down until you explicitly add rules.
For every operation, unmet require conditions skip that rule and evaluation continues. A later matching rule can grant access to the same read or write. Restrictive rules do not override broader grants. See Data requirements for details.
Access rule structure
Each access rule is a JSON object with the following properties:
| Property | Type | Required | Description |
|---|---|---|---|
type |
Array of strings | Yes | Operations this rule applies to: "select", "insert", "update", "delete" |
allow |
String or Object | Yes | Who can access: "all", "loggedIn", { "user": {...} }, or { "tokens": [...] } |
enabled |
Boolean | No | Whether the rule is active (defaults to true). Set to false to disable the rule without deleting it — disabled rules are skipped during evaluation |
include |
Array of strings | No | Allowlist of returned columns for reads, taking precedence over exclude. Does not enforce a write allowlist |
exclude |
Array of strings | No | Columns hidden from reads (unless include is present). Individual inserts and updates omit these fields before saving |
require |
Array | No | Data requirements that must be met (see Data requirements) |
appId |
Array of numbers | No | Restrict this rule to specific app IDs (applies to all apps if omitted) |
name |
String | No | Descriptive label for identifying the rule in Studio |
script |
String | No | Custom JavaScript code for advanced security logic. When present, allow and type are ignored — see Custom security rules |
Defining who can access
The allow property supports four modes:
All users (including anonymous):
{ "allow": "all" }
Logged-in users only:
{ "allow": "loggedIn" }
Specific users (filtered by session data). allow.user checks the logged-in user’s identity — use it with literal values to control who can access. To control which records they can access, use require instead (see Data requirements):
{
"allow": {
"user": {
"Role": { "equals": "Admin" }
}
}
}
User filters support three operators: equals, notequals, and contains. Multiple conditions in the same user object are combined with AND logic. For OR logic, create separate rules instead.
Important: allow.user conditions check the logged-in user's session data — not data source records. Always use literal values here (e.g., "Admin", "Manager"). Although Handlebars syntax is supported, using it in allow.user (e.g., {{ user.[Department] }}) compares the user's field against itself — always true, granting access to any logged-in user. Handlebars templates are useful in require conditions, where they scope queries by the user's data. See Data requirements.
{
"allow": {
"user": {
"Role": { "equals": "Manager" },
"Status": { "notequals": "Inactive" }
}
}
}
Specific API token:
{
"allow": {
"tokens": [42857]
}
}
Restricting columns
For reads, use include to allow specific returned columns or exclude to hide columns. If both are specified, include takes precedence. Without either, all columns are returned. For individual inserts and updates, exclude removes named fields before saving; include does not restrict writes. An exact write allowlist requires independent server enforcement; see Data requirements.
{
"type": ["select"],
"allow": "loggedIn",
"exclude": ["Password", "SSN", "Salary"]
}
Security note: Always exclude privilege fields (e.g., Admin, Role, Permissions) from rules that allow logged-in users to insert or update records. Otherwise, users can escalate their own privileges.
Example: role-based access with protected fields
This example shows an “Employees” data source where admins have full access, regular users can read records (without sensitive fields), and can only update their own record or insert records with a non-privileged role.
Data source columns:
| First Name | Role | Department | Salary | Password | Admin | Permissions | |
|---|---|---|---|---|---|---|---|
| [email protected] | Alice | Admin | Engineering | 150000 | ••••• | Yes | all |
| [email protected] | Bob | User | Marketing | 85000 | ••••• | No | read |
| [email protected] | Carol | User | Engineering | 95000 | ••••• | No | read |
Security rules:
[
{
"type": ["select", "insert", "update", "delete"],
"allow": { "user": { "Role": { "equals": "Admin" } } },
"enabled": true
},
{
"type": ["select"],
"allow": "loggedIn",
"exclude": ["Password", "Salary"],
"enabled": true
},
{
"type": ["update"],
"allow": "loggedIn",
"require": [
{ "Email": { "equals": "{{user.[Email]}}" } }
],
"exclude": ["Role", "Admin", "Permissions"],
"enabled": true
},
{
"type": ["insert"],
"allow": "loggedIn",
"require": [
"Email",
"First Name",
{ "Email": { "equals": "{{user.[Email]}}" } },
{ "Role": { "equals": "User" } }
],
"exclude": ["Admin", "Permissions"],
"enabled": true
}
]
Queries that succeed:
When Alice (Admin) is logged in, the first rule matches — she has full access to all records and columns:
// JS API — Alice reads all records including sensitive fields
const connection = await Fliplet.DataSources.connectByName('Employees');
const allRecords = await connection.find();
// Returns all 3 records with all columns including Salary and Password
// REST API equivalent
POST /v1/data-sources/123/data/query
Auth-token: <Alice's token>
Content-Type: application/json
// "type" here is the REST API operation type, not a security rule field
{ "type": "select" }
// Response (200 OK): all entries with all columns
When Bob (User) is logged in, the second rule matches for reads — he can see records but Password and Salary are excluded:
// JS API — Bob reads records (sensitive columns automatically excluded)
const connection = await Fliplet.DataSources.connectByName('Employees');
const records = await connection.find();
// Returns all 3 records, but each record is missing Password and Salary
// e.g. { Email: "[email protected]", "First Name": "Alice", Role: "Admin", Department: "Engineering", Admin: "Yes", Permissions: "all" }
Bob can update his own record (the third rule checks the stored record’s email):
// JS API — Bob updates his own name
const connection = await Fliplet.DataSources.connectByName('Employees');
await connection.update(456, {
Email: '[email protected]',
'First Name': 'Robert'
});
// Succeeds: the stored Email matches Bob's session; protected fields are excluded
// REST API equivalent
PUT /v1/data-sources/123/data/456
Auth-token: <Bob's token>
Content-Type: application/json
{ "Email": "[email protected]", "First Name": "Robert" }
// Response (200 OK): updated entry
Queries that fail:
When a query is denied, the API responds with HTTP status 400 and a JSON error body. The message field changes based on the denied operation — for example, "do not allow this app to read data" for selects, or "do not allow this app to insert data" for inserts:
{
"message": "The security rules for the Data Source \"Employees\" do not allow this app to read data.",
"type": "datasource.access",
"payload": { "dataSourceId": 123 }
}
In the JS API, the promise is rejected with this error object. The message adapts to include the data source name and the specific operation that was denied (read, insert, update, or delete). The type is always "datasource.access" and payload.dataSourceId identifies the data source.
// Bob tries to update another user's record — fails
// (The stored Email does not match Bob's session, regardless of the submitted Email)
await connection.update(789, {
Email: '[email protected]',
'First Name': 'Carolina'
});
// Bob tries to escalate his own role — fails
// (Role is excluded from the update rule, so it cannot be written)
await connection.update(456, {
Email: '[email protected]',
Role: 'Admin'
});
// Bob tries to insert a record with an admin role — fails
// (the insert rule requires Role: "User")
await connection.insert({
Email: '[email protected]',
'First Name': 'Bob Clone',
Role: 'Admin'
});
// Bob tries to delete a record — fails
// (no delete rule matches for non-admin users)
await connection.removeById(789);
Example: department-scoped access
In this example, managers see all records for their department, regular users only see their own record, and inserts are restricted to the user’s department. Department scoping is enforced through require conditions on the query, not through allow.user (which checks the user’s identity, not the data being accessed).
Data source columns:
| Name | Role | Department | Salary | ManagerNotes | |
|---|---|---|---|---|---|
| [email protected] | Alice | Manager | Engineering | 150000 | Top performer |
| [email protected] | Bob | User | Engineering | 85000 | |
| [email protected] | Carol | User | Marketing | 95000 | |
| [email protected] | Dave | Manager | Marketing | 140000 | Needs training |
Security rules:
[
{
"type": ["select"],
"allow": {
"user": { "Role": { "equals": "Manager" } }
},
"require": [
{ "Department": { "equals": "{{user.[Department]}}" } }
],
"exclude": ["Salary"],
"enabled": true
},
{
"type": ["select", "update"],
"allow": "loggedIn",
"require": [
{ "Email": { "equals": "{{user.[Email]}}" } }
],
"exclude": ["Salary", "ManagerNotes"],
"enabled": true
},
{
"type": ["insert"],
"allow": "loggedIn",
"require": [
"Name",
{ "Department": { "equals": "{{user.[Department]}}" } },
{ "CreatedBy": { "equals": "{{user.[Email]}}" } }
],
"exclude": ["Role", "Admin"],
"enabled": true
}
]
The first rule uses allow.user to check the user’s role (a literal match — “is this user a Manager?”) and require to enforce department scoping on the query (the where clause must include the user’s own department). This combination ensures only managers can use the rule and they can only query their own department’s data.
Queries that succeed:
When Alice (Manager, Engineering) is logged in, the first rule matches — she is a Manager, and her query includes Department: 'Engineering' which satisfies the require condition:
// JS API — Alice reads all Engineering records
const connection = await Fliplet.DataSources.connectByName('Staff');
const records = await connection.find({
where: { Department: 'Engineering' }
});
// Returns Alice and Bob's records, excluding Salary
// e.g. { Email: "[email protected]", Name: "Bob", Role: "User", Department: "Engineering", ManagerNotes: "" }
// REST API equivalent
POST /v1/data-sources/123/data/query
Auth-token: <Alice's token>
Content-Type: application/json
{ "type": "select", "where": { "Department": "Engineering" } }
// Response (200 OK): Alice and Bob's entries, Salary excluded
When Bob (User, Engineering) is logged in, the first rule does not match (wrong role). The second rule matches — he must filter by his own email:
// JS API — Bob reads his own record
const connection = await Fliplet.DataSources.connectByName('Staff');
const records = await connection.find({
where: { Email: '[email protected]' }
});
// Returns Bob's record only, excluding Salary and ManagerNotes
Bob can insert a record in his own department:
// JS API — Bob creates a record in Engineering
await connection.insert({
Name: 'New Hire',
Department: 'Engineering',
CreatedBy: '[email protected]'
});
// Succeeds — all require conditions are met, Role and Admin are excluded from writes
// REST API equivalent (Fliplet uses PUT for both inserts and updates on this endpoint)
PUT /v1/data-sources/123/data
Auth-token: <Bob's token>
Content-Type: application/json
{ "Name": "New Hire", "Department": "Engineering", "CreatedBy": "[email protected]" }
// Response (200 OK): new entry
Queries that fail:
// Alice (Manager) tries to read Marketing records — fails
// (the require condition demands Department matching Alice's own department "Engineering";
// "Marketing" does not match, so the first rule is skipped; no other rule grants broad access)
await connection.find({
where: { Department: 'Marketing' }
});
// Bob tries to read all records without filtering by email — fails
// (the second rule requires Email matching Bob's session; without a where clause,
// the require condition is not met, the rule is skipped, and no subsequent rule grants access)
await connection.find();
// Bob tries to read Alice's record — fails
// (Email does not match Bob's session)
await connection.find({
where: { Email: '[email protected]' }
});
// Bob tries to insert a record in Marketing — fails
// (Department does not match Bob's department "Engineering")
await connection.insert({
Name: 'Spy',
Department: 'Marketing',
CreatedBy: '[email protected]'
});
// Bob tries to insert with a different CreatedBy — fails
// (CreatedBy must equal Bob's email)
await connection.insert({
Name: 'Fake',
Department: 'Engineering',
CreatedBy: '[email protected]'
});
Data requirements and query validation
The require property defines conditions that incoming queries must satisfy. This is not the same as querying data — it controls how queries are assessed against the rule’s data requirements.
Components such as List from Data Source, Chart and Form may issue broad queries that do not satisfy your read requirements. Configure their filters or use custom queries that satisfy the intended restrictions. Adding a permissive fallback rule grants broader access even when a stricter rule appears first; an appId restriction scopes a rule to apps, not to individual components.
The behavior of require varies by operation type:
- For
selectqueries: Requirements validate the submitted filter. For example, if a rule requires{ "Email": { "equals": "{{user.[Email]}}" } }, afind()call must includewhere: { Email: user.Email }(or an equivalent supported filter) for that rule to grant access. Direct reads by entry ID instead validate the stored entry data. To obtain session data client-side, use the Session API. - For
deleteoperations: Direct deletion by entry ID validates the stored entry data and does not require a clientwherefilter. Query-based deletion requires a nonempty filter and read access, then validates delete access against each matched row’s stored data. Explicit commit deletion IDs provide an array of existing row data; other bulk deletion paths, such as replacing imported data, can omit row data. Do not assume every delete evaluates a submitted filter or always provides stored rows. - For
insertoperations: Required fields and value conditions validate the submitted data. - For
updateoperations: String requirements check that the submitted data includes the named fields. Object-form value conditions check the existing stored row, including stored rows loaded for commit updates. They do not restrict proposed replacement values. The current custom-rulequerycontext also cannot reliably enforce allowed new values or an exact set of write fields. Use a server write mechanism that independently enforces such constraints, or leave the constrained write unavailable.
Rules grant access independently: If any require condition fails, that rule does not grant access and evaluation continues for reads and writes alike. Access is denied only if no applicable rule grants it. Remove or narrow any other rule that would admit requests your restrictions are intended to reject; ordering a strict rule first does not make a broader fallback safe.
Data requirement types
require accepts an array of strings (required field names) and objects (field conditions). Four requirement types are available:
| Requirement type | Description | Query patterns that satisfy this requirement |
|---|---|---|
| Field is required | The query must include the specified column | Any query that provides the column as a key |
| Field equals | The query value for the column must match exactly | { Field: value } or { Field: { $eq: value } } |
| Field not equals | The query value for the column must not match the specified value | { Field: { $ne: value } } or any query where the field’s value differs from the specified value |
| Field contains | The query value for the column must contain the specified substring | { Field: value } or { Field: { $iLike: value } } or { Field: { $like: value } } |
These requirement types (equals, notequals, contains) are not query operators — they define how the security rule validates incoming queries. For the full list of query operators you can use when reading and writing data, see the query operators reference.
Handlebars templating
Condition values can reference the logged-in user’s session data using Handlebars syntax. This enables dynamic, per-user access scoping. Use bracket notation (e.g., [Email]) for field names — it is required for names with spaces or special characters (e.g., {{user.[First Name]}}), and also works for simple names. Plain dot notation ({{user.Email}}) works only for field names without spaces.
For per-account ownership, use an owner key that is unique to each account, stable and protected from end-user changes through every write path. This requirement applies to the owner key; shared fields such as Role and Department do not need to be unique. Before matching an account to private records already assigned to an email address or external identity, verify control of that identity or provision the account through a trusted process. A self-chosen email at public signup does not establish entitlement to those records. Public signup can support users creating their own records when a stable account key and server-enforced rules bind those records to the authenticated account.
{{user.[Email]}}— the user’s email address{{user.[Department]}}— the user’s department{{user.[Role]}}— the user’s role{{user.[ID]}}— the user’s data source entry ID
Require syntax
Required fields (string) — the query must include these columns:
"require": ["Email", "FirstName", "Department"]
Field conditions (object with operator) — the query must match these conditions:
"require": [
{ "Email": { "equals": "{{user.[Email]}}" } },
{ "Status": { "notequals": "Archived" } },
{ "Department": { "contains": "{{user.[Department]}}" } }
]
You can mix both formats in the same array:
"require": [
"Title",
"Description",
{ "CreatedBy": { "equals": "{{user.[Email]}}" } },
{ "Status": { "equals": "Draft" } }
]
For full examples of require in practice — including sample data, succeed queries, and fail queries — see role-based access with protected fields and department-scoped access above.
Custom security rules
For advanced logic beyond what the standard rule properties support, you can write custom JavaScript security rules.

The custom security rule editor in Fliplet Studio displays a code editor where you write JavaScript that controls access. The rule name and enabled toggle appear above the code area.
Important: When a rule has a script, the script is the sole determinant of access. Standard rule fields like allow and type are ignored — the script runs regardless of login status or operation type. Your script must perform its own identity and operation checks (e.g., if (!user) return { granted: false };). If the script does not return a value (e.g., an unhandled operation type falls through without a return), access is denied by default.
When writing a custom rule, these variables are available in the script context:
type(String) — the operation the user is attempting:select,insert,update, ordeleteuser(Object) — the authenticated user’s session data, orundefinedif not logged in. For data-source passport sessions (e.g., Email Verification, Fliplet Login), this contains the flat column values from the user’s row in the authentication data source (e.g.,user.Email,user.Role). For SAML2 sessions, it contains the assertion attributes.session(Object) — server-verified login context withloggedIn(Boolean),passports(Array of passport type names),dataSourceIdandentryId(Numbers when the current identity is a data source login). For Studio preview, it is scoped to the previewed app. Guard missing values and bindsession.dataSourceIdto the intended login source when access depends on that source. This is not the client-writable session data fromFliplet.Session.set().query(Object or Array) — legacy request context. A truthy submittedwheretakes precedence over operation data and the request body. It does not prove that the route will apply those fields as a filter or write them as data. Do not use it to authorize constrained reads or writes: withoutwhere, arbitrary body fields can look like a valid filter even though the route reads without that filter.entry(Object orundefined) — the existing entry for individual updates and each row of a query-based update, withid(Number) anddata(Object containing current column values). Commit updates do not provide a singleentry; do not infer stored ownership from their submitted payload arrays.DataSources(Function) — server-side library for reading data from other data sources. See Reading data from other Data Sources
The current query context cannot reliably distinguish a caller’s filter from proposed write data. Use the documented declarative rules when they express the permission. If the available context cannot establish a required constraint, deny that operation or use a server action that independently enforces it.
Granting access
Return an object with granted: true to grant access. For reads, return an exclude or include array to restrict returned columns. include does not enforce a write allowlist. This example grants read access only:
if (type === 'select') {
if (!user) {
return { granted: false };
}
// Grant full access to admin users
if (user.Admin === 'Yes') {
return { granted: true };
}
// Grant access to any other logged-in user, but hide sensitive columns
return { granted: true, exclude: ['Phone', 'NextOfKin'] };
}
// No further access is granted by this rule to other operation types
Important: You must return an object — bare boolean values (e.g., return true) are not supported and will be treated as a denial. Always use return { granted: true } or return { granted: false }.
Validating submitted filters
A plausible value in legacy query does not prove that the route will apply those fields as a read filter. Do not use it to authorize constrained reads or writes. Mutating a security-script context is not a supported way to rewrite the database operation. Use declarative read requirements when they express the restriction, and review hooks that may broaden an authorized query.
Checking data when committing changes
Commit operations evaluate inserts, updates and deletes separately. Do not assume an individual-record custom rule can authorize a bulk operation or that submitted owner values establish ownership of stored rows. Deny operation shapes for which the available context cannot establish the required permission. See the commit API.
Reading data from other Data Sources
Custom rules can read data from other Data Sources using the find (multiple records) and findOne (single record) methods of the DataSources server-side library. These reads run at server level and bypass all security rules on the target data source.
Cross-data-source lookups add a database round-trip per request and are not cached. Keep queries efficient and use findOne when you only need a single record. Script execution has a 3-second timeout — if the script does not return within 3 seconds, access is denied. Async operations (like DataSources queries) are supported via await.
Connect using the data source ID (number) or name (string):
if (!user) {
return { granted: false };
}
if (type === 'select') {
const entry = await DataSources(123).findOne({
where: {
Office: user.Office,
Managers: { $in: user.Manager },
Country: { $like: '%United Kingdom%' }
}
});
// Allow reading data if the user has a manager in the same office
if (entry) {
return { granted: true };
}
}
if (type === 'insert') {
const entries = await DataSources('Users').find();
// Only allow writes as long as there are fewer than 10 entries in the target Data Source
if (entries && entries.length < 10) {
return { granted: true };
}
}
find returns an array of matching entries (empty array if none match). findOne returns a single entry object, or null if no match is found.
Both methods accept the following properties:
where(Object) — query filter supporting standard query operators such as$eq,$ne,$like,$iLike,$gt,$gte,$lt,$lte,$inlimit(Number, defaults to100)offset(Number, defaults to0)
Use DataSources('Name') (data source name) instead of DataSources(123) (ID) in custom scripts. Data source names are preserved during app clone, so name-based lookups continue to work without manual remapping.