Almost every Neo4j application we are called into eventually asks the same question: how do we let more than one customer, team, or region share this graph without any of them seeing each other's data? It usually arrives late — after the model is settled, after the pipelines are running — and by then the cheap answer ("we filter by tenantId in the query") has already been shipped. Application-level filtering is not isolation. One forgotten WHERE clause, one ad-hoc query in Browser, one LLM-generated Cypher statement, and the tenant boundary is gone.
Neo4j gives you three real isolation models and a proper privilege system underneath them. This tutorial walks through all three, the Cypher that implements them, and a test suite that proves the walls actually hold.
Everything below needs Neo4j Enterprise Edition (or Aura Professional/Business Critical). Community Edition has one database and no role management — if you are on Community, isolation has to be per-deployment.
Prerequisites
- Neo4j 2025.x Enterprise (the Docker setup from our earlier tutorial works if you swap the image to
neo4j:2025-enterpriseand accept the evaluation licence) - A connection as a user with the
adminrole - Ten minutes and a scratch instance — do not learn
DENYon production
Step 0 — decide which isolation model you need
| Model | Isolation | Cost per tenant | Cross-tenant queries | Good fit |
|---|---|---|---|---|
| Database per tenant | Strongest — separate stores, separate transactions | High (memory, page cache, backup jobs) | Only via composite databases | Tens to low hundreds of tenants, regulated data |
| One graph, fine-grained privileges | Logical, enforced by the database | Near zero | Trivial | Thousands of small tenants, shared reference data |
| Composite database over per-tenant databases | Strongest, with a query-time union | High | Native, read-only | Analytics across otherwise separated tenants |
The decision usually comes down to two questions: does a compliance requirement demand physical separation, and how many tenants will exist in three years? Databases are cheap at 50 tenants and painful at 5,000 — each one carries its own store files, transaction logs, and page-cache footprint.
Step 1 — the primitives: users, roles, privileges
Neo4j's access control is role-based. Privileges are granted to roles, roles to users, and privileges come in three families: database privileges (access, index and constraint management, transaction management), graph privileges (read, match, traverse, write on nodes, relationships, and properties), and DBMS privileges (creating databases, managing users and roles, impersonation).
Start with a clean, least-privilege reader:
// run these against the `system` database
CREATE ROLE app_reader IF NOT EXISTS;
GRANT ACCESS ON DATABASE neo4j TO app_reader;
GRANT MATCH {*} ON GRAPH neo4j NODES * TO app_reader;
GRANT MATCH {*} ON GRAPH neo4j RELATIONSHIPS * TO app_reader;
CREATE USER report_svc IF NOT EXISTS
SET PASSWORD 'rotate-me-from-a-secret-store' CHANGE NOT REQUIRED
SET STATUS ACTIVE;
GRANT ROLE app_reader TO report_svc;
Three rules we apply on every engagement:
- No application ever connects as
neo4j. That account is for break-glass administration and should have an audited, rotated password. - Read paths get read-only users, not read-only intentions. This is the same principle behind the read-only account in our text-to-Cypher guardrails post — the credential is the control, not the prompt.
DENYbeatsGRANT. A deny is absolute and cannot be overridden by any grant on any other role the user holds. That makes deny the right tool for "never, under any circumstances" rules and the wrong tool for everyday shaping.
Inspect what you have built at any time:
SHOW ROLE app_reader PRIVILEGES AS COMMANDS;
SHOW USER report_svc PRIVILEGES;
AS COMMANDS is the underrated one: it prints the exact statements needed to recreate the role, which is how you get access control into version control instead of into a wiki page.
Step 2 — database per tenant
The simplest strong isolation. One database, one role, one user per tenant:
CREATE DATABASE `tenant-acme` IF NOT EXISTS WAIT;
CREATE ROLE tenant_acme IF NOT EXISTS;
GRANT ACCESS ON DATABASE `tenant-acme` TO tenant_acme;
GRANT MATCH {*} ON GRAPH `tenant-acme` NODES * TO tenant_acme;
GRANT MATCH {*} ON GRAPH `tenant-acme` RELATIONSHIPS * TO tenant_acme;
GRANT WRITE ON GRAPH `tenant-acme` TO tenant_acme;
CREATE USER acme_app IF NOT EXISTS SET PASSWORD $pw CHANGE NOT REQUIRED;
GRANT ROLE tenant_acme TO acme_app;
Two operational details bite teams here. First, provisioning must be scripted — a tenant is a database, a role, a user, a secret, a constraint/index set, and a backup entry; do it by hand once and you will have drifted by tenant twelve. Second, watch the memory maths: each database consumes page cache and heap. On a 32 GB machine, a few hundred small tenant databases will spend more on overhead than on data. Size with neo4j-admin server memory-recommendation and then measure.
In the driver, isolation is one parameter — the session's database — so keep it out of business code:
def tenant_session(driver, tenant_id):
return driver.session(database=f"tenant-{tenant_id}",
default_access_mode=neo4j.READ_ACCESS)
Step 3 — one graph, fine-grained privileges
For many tenants, keep a single graph and label each tenant's data. Say every tenant-owned node carries a :Tenant_ACME-style label alongside its domain label, and shared reference data carries neither.
// tenant nodes: (:Customer:Tenant_ACME {name: ..., ssn: ...})
CREATE ROLE tenant_acme_ro IF NOT EXISTS;
GRANT ACCESS ON DATABASE neo4j TO tenant_acme_ro;
// see nothing by default
DENY MATCH {*} ON GRAPH neo4j NODES * TO tenant_acme_ro;
// ...except this tenant's nodes and the shared reference data
GRANT MATCH {*} ON GRAPH neo4j NODES Tenant_ACME TO tenant_acme_ro;
GRANT MATCH {*} ON GRAPH neo4j NODES Currency, Country TO tenant_acme_ro;
GRANT TRAVERSE ON GRAPH neo4j RELATIONSHIPS * TO tenant_acme_ro;
// property-level: nobody in this role reads the national ID
DENY READ {ssn} ON GRAPH neo4j NODES Customer TO tenant_acme_ro;
Three things worth internalising about how this behaves at query time:
TRAVERSEvsREADvsMATCH.TRAVERSElets you find and walk an element;READlets you see its properties;MATCHis both. A user with traverse but not read gets the node back with properties asnullrather than an error.- Invisible elements are simply absent. A denied node does not raise a security exception; it does not exist as far as that query is concerned. That is what makes the model safe for shared queries — and what makes silent, wrong results possible if you deny too much. Test both directions.
- Fine-grained security has a query cost. Every element access is checked. Label-level rules are cheap; broad property-level rules on hot paths are not. If a tenant-filtered query suddenly slows down, run it under
PROFILEas the restricted user, not as admin — the plan you read asneo4jis not the plan the tenant gets.
Write privileges follow the same shape, and this is where teams under-invest:
CREATE ROLE tenant_acme_rw IF NOT EXISTS;
GRANT ROLE tenant_acme_ro TO tenant_acme_rw; -- roles compose
GRANT CREATE ON GRAPH neo4j NODES Tenant_ACME TO tenant_acme_rw;
GRANT SET PROPERTY {*} ON GRAPH neo4j NODES Tenant_ACME TO tenant_acme_rw;
GRANT DELETE ON GRAPH neo4j NODES Tenant_ACME TO tenant_acme_rw;
Note what is not granted: SET LABEL. Without it a tenant user cannot relabel a node into another tenant's namespace, which is the obvious escalation path in this model.
Step 4 — impersonation instead of a connection pool per tenant
An application that holds one connection pool per tenant user does not scale past a few dozen tenants. Impersonation solves it: the service account authenticates once, then executes each request as the tenant user, with that user's privileges.
GRANT IMPERSONATE (acme_app, globex_app) ON DBMS TO app_service;
with driver.session(database="neo4j",
impersonated_user=f"{tenant_id}_app") as session:
session.run(query, params)
The privilege check happens in the database, so a bug in your routing layer produces an authorization error rather than a data leak. Grant IMPERSONATE on an explicit list of users — never IMPERSONATE (*) — and log the impersonated user on every request.
Step 5 — composite databases for cross-tenant reads
When separated tenants still need one analytical view, a composite database aliases the constituents and queries them with USE:
CREATE COMPOSITE DATABASE portfolio IF NOT EXISTS;
CREATE ALIAS portfolio.acme FOR DATABASE `tenant-acme`;
CREATE ALIAS portfolio.globex FOR DATABASE `tenant-globex`;
UNWIND ['portfolio.acme', 'portfolio.globex'] AS graphName
CALL {
USE graph.byName(graphName)
MATCH (o:Order) WHERE o.createdAt > datetime() - duration('P30D')
RETURN count(o) AS orders, sum(o.total) AS revenue
}
RETURN graphName, orders, revenue ORDER BY revenue DESC;
Composite databases are read-only and each constituent is queried in its own transaction, so this is a reporting tool, not a way to write across tenants. Access to the composite is a separate grant — the analytics role gets it, tenant roles do not.
Step 6 — prove it, in CI
Access control that nobody tests decays on the first "temporary" grant. We ship a negative-test suite with every multi-tenant build: for each tenant user, assert that the tenant's own data is visible, that another tenant's data returns zero rows, and that denied properties come back null.
def test_tenant_cannot_see_other_tenant(acme_driver):
with acme_driver.session(database="neo4j") as s:
n = s.run("MATCH (c:Tenant_GLOBEX) RETURN count(c) AS n").single()["n"]
assert n == 0
def test_denied_property_is_null(acme_driver):
with acme_driver.session(database="neo4j") as s:
rec = s.run("MATCH (c:Customer:Tenant_ACME) RETURN c.ssn AS ssn LIMIT 1").single()
assert rec["ssn"] is None
Run it against an ephemeral instance seeded with two tenants' fixtures. Pair it with a drift check that dumps SHOW ALL PRIVILEGES AS COMMANDS and diffs the output against the committed baseline — every unexpected line is either a change someone forgot to commit or an incident.
A short checklist
- No application connects as
neo4j; every service has its own least-privilege user - Access control lives in version control, generated with
AS COMMANDS DENYfor absolutes,GRANTfor shaping, and roles composed rather than duplicated- Impersonation instead of one pool per tenant, with an explicit user list
- Composite databases for cross-tenant reads only
- Negative tests in CI, plus a privilege-drift diff
- Security events forwarded off-box; check the
security.logand enable query logging for the tenant users
Multi-tenancy decisions are hard to reverse once data has landed, and the retrofit is always more expensive than the design. If you are choosing between these models, or inherited a tenantId-filter application that now needs real isolation, get in touch — a short architecture review is usually enough to settle it.