Limitations

Neo4j’s role-based access control (RBAC) has limitations that can lead to unexpected results if not carefully considered during security model design and when writing Cypher queries. Depending on the restrictions applied to the roles a user are assigned to, query results may differ in the following ways:

  • Access control and indexes: Indexes are used to speed up queries, but what they return to the user depends on the security model. If a user has restrictions on certain labels, relationship types, or properties, the system will not return any results that contain those elements, regardless of the indexes used. Also, full-text and vector indexes may return fewer results than expected because Lucene prevents Neo4j from checking security rules for each returned index entry. As a result, Neo4j only returns results it can guarantee do not violate security rules and excludes results that might violate security rules, even if they do not.

  • Fail-open DENY behavior: A DENY rule fails open when its criteria are not met, so Neo4j does not apply the restriction, and it grants access by default if a broader GRANT exists. This can lead to unintended data exposure if the DENY rule is not carefully crafted.

  • Access control and labels: Query results differ when nodes have multiple labels, and the user has access to traverse on some of those labels but not others. This is because the user can see all labels attached to a node they have access to, even if they do not have access to traverse on all of those labels.

  • Access control and performance: Complex security rules and large graphs with restrictive access can lead to significant performance degradation, especially for queries that would otherwise be efficient.

Access control and indexes

Neo4j uses indexes to speed up Cypher queries. See the Cypher Manual → Indexes for more details on the different types of indexes available in Neo4j.

However, Neo4j’s security model still controls what results you see, regardless of whether or not you use indexes.

Search-performance indexes (range, text, point, and token lookup)

Search-performance indexes are automatically used by the Cypher planner when they are relevant to a query. The results returned by these indexes are filtered by the security model, so that users only see results they have access to. If the security model causes fewer results to be returned due to restricted read access in graph and sub-graph access control, the index will also return the same fewer results.

Semantic indexes (full-text and vector)

Full-text and vector indexes differ from search-performance indexes because they are built on Lucene and are not automatically used by the Cypher planner.

Lucene implications

Lucene prevents Neo4j from checking the security rules for each specific entry returned from the index. As a result, Neo4j needs to take a more conservative approach to ensure that it does not return results that violate the security rules. If a result might violate the security rules, Neo4j treats it as if it does violate the security rules and excludes it from the results returned by the index. This means it either returns zero results (if all results from the index are potentially affected) or a partial result (if some rows can be guaranteed not to be affected).

Usage implications

Cypher does not automatically use full-text or vector indexes. You must explicitly state the index you want to use when calling procedures for full-text indexes, such as db.index.fulltext.queryNodes and db.index.fulltext.queryRelationships, or for vector indexes: db.index.vector.queryNodes, db.index.vector.queryRelationships, or via the Cypher /docs/cypher-manual/current/clauses/search/[SEARCH] clause (introduced in Neo4j 2026.01). This avoids a situation in which the same Cypher query returns different results because of an underlying index. The problem is that if you do not know this behavior, you might expect the full-text or vector index to return the same results that a different but semantically similar Cypher query does.

The following examples illustrate the implications of using full-text indexes with different security rules, compared to using native indexes. The full-text index examples also apply to the vector indexes. Vector indexes with multiple labels, multiple relationship types, or additional properties are introduced in Neo4j 2026.01. In previous versions, they could only be on a single property and a single label or relationship type.

Example 1: Denied labels and index on multiple properties

The example assumes that the database contains nodes with the labels :User and :Person, and the properties name and surname.

  1. Create a single property range index on name, a composite range index on name and surname, and a full-text index on both properties:

    CREATE INDEX singleProp FOR (n:User) ON (n.name);
    CREATE INDEX composite  FOR (n:User) ON (n.name, n.surname);
    CREATE FULLTEXT INDEX userNames FOR (n:User|Person) ON EACH [n.name, n.surname];

    Full-text indexes support multiple labels. See Cypher Manual → Indexes for full-text search for more details on creating and using full-text indexes.

    After creating these indexes, it may appear that the latter two indexes accomplish the same thing. However, this is not completely accurate.
    The composite and full-text indexes behave differently and are focused on different use cases. A key difference is that full-text indexes are backed by Lucene and use the Lucene syntax for querying. This has consequences for users restricted to the labels or properties involved in the indexes. Ideally, if the labels and properties in the index are denied, they can correctly return zero results from both native indexes and full-text indexes. However, there are borderline cases where this is not as simple as it seems.

  2. Let’s add the following nodes to the database:

    CREATE (:User {name: 'Sandy'});
    CREATE (:User {name: 'Mark', surname: 'Andy'});
    CREATE (:User {name: 'Andy', surname: 'Anderson'});
    CREATE (:User:Person {name: 'Mandy', surname: 'Smith'});
    CREATE (:User:Person {name: 'Joe', surname: 'Andy'});
  3. Deny the label :Person:

    DENY TRAVERSE ON GRAPH * NODES Person TO users;
  4. Now run a query that uses one of the indexes:

    Run a query that uses the single property range index on name:

    MATCH (n:User)
    WHERE n.name CONTAINS 'ndy'
    RETURN n.name

    This query performs several checks:

    • Scans the index to create a stream of results of nodes with the name property, which leads to five results.

    • Filters the results to include only nodes where n.name CONTAINS 'ndy', filtering out Mark and Joe, which leads to three results.

    • Filters the results to exclude nodes that also have the denied label :Person, filtering out Mandy, leaving two results.

    Finally, the query returns two results, but only one has the surname property.

    Run a query that uses the composite range index on name and surname. To use the native composite index on name and surname, the query needs to include a predicate on the surname property as well:

    MATCH (n:User)
    WHERE n.name CONTAINS 'ndy' AND n.surname IS NOT NULL
    RETURN n.name

    This query performs several checks, which are almost identical to the single property index query:

    • Scans the index to create a stream of results of nodes with the name and surname property, which leads to four results.

    • Filters the results to include only nodes where n.name CONTAINS 'ndy', filtering out Mark and Joe, which leads to two results.

    • Filters the results to exclude nodes that also have the denied label :Person, filtering out Mandy, leaving only one result.

    However, in the end, it returns only one result.

    Run the same query as for the composite index, but using the full-text index instead:

    CALL db.index.fulltext.queryNodes("userNames", "ndy") YIELD node, score
    RETURN node.name

    The problem now is that it is not clear whether the results provided by the index are achieved due to a match to the name or the surname property.

    The query performs the following steps:

    • Run a Lucene query on the full-text index to return results containing ndy in either property, leading to five results.

    • Filter the results to exclude nodes that also have the label :Person, filtering out Mandy and Joe, leading to three results.

    This difference in results is caused by the OR relationship between the two properties in the index creation.

Example 2: Denied properties on all labels

The example uses the same database as in Example 1: Denied labels and index on multiple properties, but instead of denying the :Person label, it denies the surname property for all labels.

  1. Deny the surname property for all labels:

    DENY READ {surname} ON GRAPH * TO users;
  2. Run the same queries as in the previous example using one of the indexes:

    Run a query that uses the single property range index on name:

    MATCH (n:User)
    WHERE n.name CONTAINS 'ndy'
    RETURN n.name

    This query operates exactly as before, except for the check on the :Person label, because nothing in it relates to the denied property:

    • Scans the index to create a stream of results of nodes with the name property, which leads to five results.

    • Filters the results to include only nodes where n.name CONTAINS 'ndy', filtering out Mark and Joe, which leads to three results.

    Finally, the query returns three results, only one of which has the surname property.

    Run a query that uses the composite range index on name and surname:

    MATCH (n:User)
    WHERE n.name CONTAINS 'ndy' AND n.surname IS NOT NULL
    RETURN n.name

    Since the surname property is denied, it always appears to be null and the composite index to be empty. Therefore, the query returns no result.

    Run the same query as for the composite index, but using the full-text index instead:

    CALL db.index.fulltext.queryNodes("userNames", "ndy") YIELD node, score
    RETURN node.name

    The problem now is that it is not clear whether the results provided by the index are achieved due to a match to the name or the surname property. Results from the surname property now need to be excluded by the security rules, because they require the user not to be able to see any surname properties. However, the security model cannot introspect the Lucene query to determine what it will actually do, whether it works only on the allowed name property or also on the disallowed surname property. Therefore, to prevent the index from returning results that the user should not see, the index treats all properties as denied rather than just the surname property. The query performs the following steps:

    • Run a Lucene query on the full-text index to return results containing ndy in either property, leading to five results.

    • Filter the results to exclude all nodes, filtering out everyone, leading to no result.

    In the end, the query returns zero results rather than the expected results Andy, Mandy, and Sandy.

Example 3: Denied properties on a specific label

Instead of denying the surname property for all labels, like the example in Example 2: Denied properties on all labels, consider denying reading it only for nodes with the :Person label.

  1. Deny read access to the surname property for nodes with the :Person label:

    DENY READ {surname} ON GRAPH * NODES Person TO users;
  2. Run the same queries as in the previous examples using one of the indexes:

    Run a query that uses the single property range index on name:

    MATCH (n:User)
    WHERE n.name CONTAINS 'ndy'
    RETURN n.name

    The query operates exactly as in Example 2: Denied properties on all labels, returning the same three results, because nothing in it relates to the denied property.

    Run a query that uses the composite range index on name and surname:

    MATCH (n:User)
    WHERE n.name CONTAINS 'ndy' AND n.surname IS NOT NULL
    RETURN n.name

    Since the surname property is denied only for nodes with the label :Person, it appears to be null only for those nodes, and the composite index does not contain them. The composite index still contains the remaining :User nodes that have both properties. Therefore, the query returns only one result.

    Run a query that uses the full-text index on name and surname:

    CALL db.index.fulltext.queryNodes("userNames", "ndy") YIELD node, score
    RETURN node.name

    The problem now is that it is not clear whether the results provided by the index are achieved due to a match to the name or the surname property. However, the impact is lessened, as only nodes with the label :Person need to exclude the results from the surname property. Nodes without the :Person label can read the surname property and do not need to be excluded.

    However, the security model cannot introspect the Lucene query to determine what it will actually do, whether it works only on the allowed name property or also on the disallowed surname property. Therefore, to prevent the index from returning results that the user should not see, the index treats all properties of nodes with the label :Person as denied, rather than just the surname property. Since the surname property is denied only for nodes with the label :Person, the index can safely return any nodes that do not have that label.

    The query performs the following steps:

    • Run a Lucene query on the full-text index to return results containing ndy in either property, leading to five results.

    • Filter the results to exclude nodes that also have the label :Person, filtering out Mandy and Joe, leading to three results.

    In the end, the query returns three results rather than zero, as in Example 2: Denied properties on all labels, because the security model can still allow results from nodes that do not have the :Person label, even if they match on the denied surname property.

Fail-open DENY behavior

A DENY rule fails open when its criteria are not met, so Neo4j does not apply the restriction, and it grants access by default if a broader GRANT exists. This can lead to unintended data exposure if the DENY rule is not carefully crafted. To avoid this, apply the principle of least privilege and grant access only to the specific data the user needs to see.

For example, consider the following scenarios:

An unmet DENY failing open with property-based RBAC

You grant a user access to a property and try to restrict it with a DENY rule. However, if the DENY rule does not match any data, for example, if the property is null or misspelled, the DENY rule will not apply, and the user can still access the property.

Let’s take a few examples:

Example 1. Grant read access to the salary property on all nodes with the Employee label, but deny it for employees whose position is 'CEO':
GRANT READ {salary} ON GRAPH * NODES Employee TO myRole;
DENY READ {salary} ON GRAPH * FOR (e:Employee) WHERE e.position = 'CEO' TO myRole;

In this case, if the e.position property is null or misspelled, the DENY rule will not apply, and myRole will see the salary property.

A better way is to apply the principle of least privilege and only grant access to the salary property for employees whose position is not 'CEO':

Example 2. Grant read access to the salary property on all nodes with the Employee label, whose position is not 'CEO':
GRANT READ {salary} ON GRAPH * FOR (e:Employee) WHERE e.position <> 'CEO' TO myRole;

In this way, if e.position is null or misspelled, the user will not see the salary property, and the DENY will not be needed.

Or, if for some reason using DENY is unavoidable, the problem can be mitigated by adding an additional DENY to cover the case where e.position is null:

Example 3. Deny read access to the salary property for employees whose position is null:
DENY READ {salary} ON GRAPH * FOR (e:Employee) WHERE e.position IS NULL TO myRole;

This way, if e.position is null, the user will not see the salary property, and the DENY will not apply.

Alternatively, you can add a constraint to ensure that the e.position property cannot be null, so the DENY condition is always checkable:

Example 4. Add a constraint to ensure that the position property cannot be null:
CREATE CONSTRAINT FOR (e:Employee) REQUIRE e.position IS NOT NULL;

This way, the DENY will never apply due to null values, and the user will not see the salary property for employees whose position is 'CEO'.

An unmet DENY failing open with label-based RBAC

A DENY rule does not apply when it is too broad and does not match the data.

For example, if you grant read access to the salary property on all nodes and then try to restrict it with a DENY rule for nodes with a specific label, if the nodes with that label are not present in the graph, the DENY rule will not apply, and the user can still access the salary property on all nodes.

Example 5. Grant read access to the salary property on all nodes:
GRANT READ {salary} ON GRAPH * NODES * TO myRole;

This grants read access to the salary property on all nodes, including those that should not be accessible.

Then, you try to restrict it with a DENY rule to prevent access to the salary property on nodes labeled Management:

Example 6. Deny read access to the salary property for nodes with the Management label:
DENY READ {salary} ON GRAPH * NODES Management TO myRole;

In this case, if the Management label is not present on a node that has the salary property, the DENY rule does not apply, and myRole can still see the salary property on that node.

A better way is to apply the principle of least privilege and only grant access to the salary property for nodes that have a specific label, such as IndividualContributor:

Example 7. Grant read access to the salary property only for nodes with the IndividualContributor label:
GRANT READ {salary} ON GRAPH * NODES IndividualContributor TO myRole;

This way, the user only sees the salary property on nodes with the IndividualContributor label, not on any other nodes.

Access control and labels

In Neo4j, nodes can have multiple labels, but relationships only have one type. This is important when it comes to controlling who can see what.

The following scenarios only focus on nodes because they can have multiple labels. The same general rules apply to relationships, but they are simpler.

For details on the general influence of access control privileges on graph traversal, see Graph and sub-graph access control.

Traversing multi-labeled nodes with partial access to labels

To get information about labels attached to a node by calling the built-in labels() function, a user needs to have the privilege to traverse on one or multiple labels attached to that node using GRANT TRAVERSE or GRANT MATCH. In the case of nodes with multiple labels, the user will be able to see all labels attached to the node, even if they were not granted access to traverse on some of those labels.

For example, let’s assume that a graph contains three nodes: one labeled :A, another labeled :B, and one with both labels :A and :B.

  1. Grant traversal access to nodes with the label :A to the custom role:

    GRANT TRAVERSE ON GRAPH * NODES A TO custom;
  2. Then, run one of the following queries and see the results:

    Run a query to get all labels of nodes with the :A label:

    MATCH (n:A)
    RETURN n, labels(n)

    The query returns a result with two nodes: the node with label :A and the node with labels :A :B.

    In contrast, if you run a query to get the labels of nodes with the :B label:

    MATCH (n:B)
    RETURN n, labels(n)

    The query returns only the node that has both labels: :A and :B. Even though :B does not have access to traversals, there is one node with that label accessible in the dataset due to the allow-listed label :A that is attached to the same node.

Traversing multi-labeled nodes with denied access to some of the labels

If a user is denied access to a label, they will never get results from any node with that label. Thus, the label name will never appear to them.

For example, let’s assume that a graph contains three nodes: one labeled :A, another labeled :B, and one with both labels :A and :B and that the user has access to traverse on label :A but not on label :B.

  1. Grant traversal access to nodes with the label :A to the custom role, but deny traversal access to nodes with the label :B:

    GRANT TRAVERSE ON GRAPH * NODES A TO custom;
    DENY TRAVERSE ON GRAPH * NODES B TO custom;
  2. Then, run one of the following queries and see the results:

    Run a query to get all labels of nodes with the :A label:

    MATCH (n:A)
    RETURN n, labels(n)

    The query returns the node that only has the :A label. It does not return the node with both labels :A and :B, because the user does not have access to traverse on it because of :B, even though it also has the label :A that is allow-listed.

    Run a query to get the labels of nodes with the :B label:

    MATCH (n:B)
    RETURN n, labels(n)

    The query returns no nodes.

The db.labels() procedure

In contrast to the normal graph traversal described in the previous section, the built-in db.labels() procedure does not process the data graph itself, but the security rules defined on the system graph. That means:

  • If a label is explicitly whitelisted (granted), it will be returned by this procedure.

  • If a label is denied or is not explicitly allowed, it will not be returned by this procedure.

For example, let’s assume that a graph contains three nodes: one labeled :A, another labeled :B, and one with both labels :A and :B.

  1. Grant traversal access to nodes with the label :A to the custom role:

    GRANT TRAVERSE ON GRAPH * NODES A TO custom;
  2. Then, run the following query:

    CALL db.labels()

    The query returns only label :A, because the user does not have access to traverse on label :B.

Privileges for nonexistent labels, relationship types, and property names

Privileges for nonexistent labels, relationship types, and property names take effect only after they are created. In other words, when authorizing a user, only privileges for existing labels, relationship types, and property names are applied. This is because the graph elements must be resolved internally to check against privileges when users later try to use them. If a label, relationship type, or property name does not yet exist, it will not resolve, and therefore, the privileges will not apply.

A workaround is to create the label, relationship type, or property name using the db.createLabel(), db.createRelationshipType(), and db.createProperty() procedures in the relevant database when creating the privileges.

Labels, relationship types, and property names are considered nonexistent in a database if:

  • There has never been a node with that label, a relationship with that relationship type, or a property with that name. Nor any index or constraint on them.

  • There has been no attempt to add a node with that label, a relationship with that relationship type, or a property with that name. An attempted creation adds the label, relationship type, or property name to the known labels, relationship types, and property names, even if the operation ultimately fails, unless it fails due to missing or denied privileges to create new labels, relationship types, or property names.

  • There has been no attempt to create an index or constraint using that label, relationship type, or property name. Although many index or constraint errors are detected before the label, relationship type, or property name is created, it may still be created in some cases (for example, if a property existence constraint fails because existing data violates it).

  • They have not been created using any of the db.createLabel(), db.createRelationshipType(), or db.createProperty() procedures.

There is currently no way to remove a label, relationship type, or property name from the database. Once existent in the database, they cannot return to nonexistent.

For example, let’s assume you have a new, empty database called testing.

The example focuses only on nodes and their labels, though the same principle applies to relationships and their relationship type, and properties (on both nodes and relationships) and their names.

  1. Define some privileges to the custom role that include a nonexistent label :A:

    GRANT MATCH {*} ON GRAPH testing NODES * TO custom;
    GRANT CREATE ON GRAPH testing NODES A TO custom;
    GRANT SET LABEL A ON GRAPH testing TO custom;
    GRANT CREATE NEW NODE LABEL ON DATABASE testing TO custom;
  2. Then, connect as a user with the custom role, for example, Alice, and run the following query to create a node with the nonexistent label :A:

    CREATE (:A)

    You get the following exception even though you are allowed to create new labels:

    Create node with labels 'A' on database 'testing' is not allowed for user 'Alice' with roles [PUBLIC, custom].

However, rerunning the same query creates the node. This is because the failed creation still creates the label, making it no longer nonexistent when the query is run a second time.

To ensure success on the first attempt, when setting up the privileges for the custom role, the administrator should run the db.createLabel() procedure on the affected databases for all nonexistent labels that get assigned privileges. In this example, when creating the custom role, connect to testing and run CALL db.createLabel('A') to ensure you create the node successfully on your first attempt.

Access control and performance

Neo4j’s role-based access control can have performance implications, especially when security rules are complex or when querying large graphs with restrictive access. For example, count store operations, which are usually fast lookups, may experience significant performance differences because the database must check each node or relationship against the security rules rather than simply retrieving counts from the count store.

Access control and performance of database operations

When security rules that restrict access to certain nodes, relationships, or properties are in place, Neo4j must perform additional checks to ensure that the user only sees what they are allowed to see.

For example, consider a graph with nodes labeled :Person and :Customer, where some nodes have both labels.

  1. Define two roles with different privileges:

    • restricted — with some restrictions on traversals.

    • unrestricted — with no restrictions on traversals.

      GRANT TRAVERSE ON GRAPH * NODES Person TO restricted;
      DENY TRAVERSE ON GRAPH * NODES Customer TO restricted;
      GRANT TRAVERSE ON GRAPH * ELEMENTS * TO unrestricted;

      The restricted role allows traversing nodes with the label :Person, but denies traversing nodes with the label :Customer, while the unrestricted role allows traversing all nodes and relationships.

  2. When you run the following query, the execution plan looks the same regardless of the role:

    Cypher query to count nodes with the :Person label
    MATCH (n:Person)
    RETURN count(n)
    Execution plan for both users
    +--------------------------+
    | Operator                 |
    +--------------------------+
    | +ProduceResults          |
    | |                        +
    | +NodeCountFromCountStore |
    +--------------------------+

    However, the underlying operations can differ significantly due to the security rules applied to each role:

User with unrestricted role User with restricted role

The database accesses the count store to retrieve the total number of nodes with the label :Person, without needing to check each node against any security rules. This is a very quick operation.

The database cannot access the count store because it must make sure that only traversable nodes with the desired label :Person are counted. It needs to check each node with the :Person label to see whether it also has the :Customer label, and deny access to it if it does. Therefore, due to the additional data access required by the security checks, this operation is slower compared to executing the query as an unrestricted user.

Access control and performance of property rules

Extra node or relationship-level security checks are necessary when adding security rules based on property rules, and these can have a significant performance impact.

The following example shows how the database behaves when adding security rules for nodes to roles restricted and unrestricted. The same limitations apply to relationships.

  1. Define two roles with different privileges:

    GRANT TRAVERSE ON GRAPH * FOR (n:Customer) WHERE n.secret <> true TO restricted;
    GRANT TRAVERSE ON GRAPH * ELEMENTS * TO unrestricted;
  2. When you run the following query, the execution plan looks the same regardless of the role:

    Cypher query to get all nodes with the :Customer label
    MATCH (n:Customer)
    RETURN n
    Execution plan for users with either role
    +--------------------------+
    | Operator                 |
    +--------------------------+
    | +ProduceResults          |
    | |                        +
    | +AllNodesScan             |
    +--------------------------+

    However, the underlying operations can differ significantly due to the security rules applied to each role:

User with unrestricted role User with restricted role

The database scans all nodes and quickly identifies accessible nodes based solely on the presence of the :Customer label. This is a relatively quick operation.

The database scans all nodes, identifies potentially accessible nodes based on the presence of the specified label, then accesses the properties of those nodes and inspects their values to ensure the property rule criteria are met (i.e., that secret is not set to true in this case). Due to the additional data access required by the security checks, this operation is slower than executing the query as an unrestricted user.