Neo4j Integration Guide
This guide covers how to connect a Neo4j graph database to BurnLedger, write Cypher query templates, set up a read-only Neo4j user, and choose the right hash scope for your compliance needs.
Integration-tested end to end against Neo4j 5 Enterprise (the neo4j:5-enterprise image).
Connection Configuration
BurnLedger connects to Neo4j over the Bolt protocol using the official Neo4j Go driver. Credentials are supplied as separate configuration fields — they are never embedded in the URI.
Configuration fields:
| Field | Required | Description |
|---|---|---|
uri |
Yes | Bolt endpoint, e.g. bolt+s://neo4j.example.com:7687. Port defaults to 7687 if omitted. |
username |
Yes | Neo4j user name. Connection is refused if empty. |
password |
Yes | Password for that user. Connection is refused if empty. |
database |
No | Database to query. Defaults to neo4j. |
read_only |
Conditional | Your assertion that the credential is read-only. Required on Community Edition (see Read-Only User Setup). Ignored when BurnLedger can introspect privileges. |
These are the exact JSON keys the connector reads. read_only is snake_case in both SDKs — do not write readOnly.
Do not put credentials in the
uri. BurnLedger authenticates with theusername/passwordfields, so there is nothing to percent-encode and a password containing@ : / ? #is handled safely. If you ever write a Neo4j URI with userinfo somewhere else in your stack (a driver config, a shell script), remember that a URI must be escaped —@→%40,#→%23,/→%2F— or the host is parsed incorrectly and the connection is rejected as targeting an invalid host. The simplest option is a password limited to letters, digits,-and_.
REPLACE-WITH-YOUR-PASSWORD is a placeholder, not a suggestion: choose your own password when you create the user below, and substitute it wherever the placeholder appears in this guide.
TypeScript
const system = await bl.registerSystem({
name: "users-graph",
connectorType: "neo4j",
connectionConfig: {
uri: "bolt+s://neo4j.example.com:7687",
username: "burnledger_ro",
password: process.env.NEO4J_PASSWORD,
database: "neo4j",
read_only: true,
},
subjectQuery: "MATCH (u:User {email: $identifier}) RETURN u.id AS id, u.email AS email, u.name AS name",
});
Python
system = bl.register_system(
name="users-graph",
connector_type="neo4j",
connection_config={
"uri": "bolt+s://neo4j.example.com:7687",
"username": "burnledger_ro",
"password": os.environ["NEO4J_PASSWORD"],
"database": "neo4j",
"read_only": True,
},
subject_query=(
"MATCH (u:User {email: $identifier}) "
"RETURN u.id AS id, u.email AS email, u.name AS name"
),
)
Connection requirements:
| Requirement | Details |
|---|---|
| URI scheme | Use bolt+s:// (direct connection over TLS). BurnLedger runs the query inside an attestation enclave and reaches your server through its own egress tunnel, which terminates as a direct Bolt connection — routing discovery via neo4j:// / neo4j+s:// is not performed, so the neo4j-prefixed schemes buy nothing here. |
| Transport security | bolt+s:// (or neo4j+s://) is required. There is no TLS field in the connector config; the URI scheme is the only place transport is expressed. Plain bolt:// / neo4j:// is plaintext and bolt+ssc:// / neo4j+ssc:// accepts any self-signed certificate — both are refused before the connector is built. The egress bridge originates the TLS itself, pinned to the hostname in your URI, so the server certificate must chain to a trusted root and match that name. See connector transport security. |
| Network access | BurnLedger connects from a fixed egress address. Allowlist it for port 7687 in your firewall or security group. The uri host must be publicly resolvable: at registration it is resolved and rejected if it is localhost, a .local/.internal name, or resolves into a private/loopback/link-local range (BAD_REQUEST, "connection config targets a blocked host"). A VPN-only or VPC-private address cannot be registered. |
| Connectivity check | On registration BurnLedger calls the driver's connectivity verification against your URI. Failure is reported as CONNECTION_FAILED before any query runs. |
| System database | BurnLedger opens a session against the system database to introspect the credential's privileges. If that session or the SHOW USER PRIVILEGES call fails for any reason, introspection is treated as unavailable and the read_only assertion applies (fail-closed without it). |
| Permissions | Read-only. BurnLedger refuses to connect if the credential has write privileges. See Read-Only User Setup. |
| Timeout | Queries must complete within the system's query_timeout (default 30 seconds); the connector cancels them at the deadline. |
Query Template Format
A query template is a read-only Cypher statement using $identifier as the parameter placeholder for the data subject identifier. This is the standard Cypher parameter syntax, and BurnLedger binds the subject value as a real query parameter — never by string interpolation.
$identifier is the only placeholder this connector substitutes. There is no universal placeholder shared across BurnLedger connectors: $1, ?, {identifier} and $IDENTIFIER belong to other connectors and mean nothing here.
Rules:
- Must contain the literal
$identifier, lowercase, exactly as written. A template without it is rejected withINVALID_QUERY_TEMPLATEat registration. This is a hard failure on purpose: an unparameterized template would either match the wrong set of nodes or match none, and a "0 records" answer would be certified as a cryptographically signed false negative. - Should contain
MATCHandRETURN. A template with neither raises a warning at registration. - Must not contain a write or side-effecting clause. The connector statically rejects, case-insensitively and as whole words:
CREATE,MERGE,DELETE,DETACH,SET,REMOVE,DROP,FOREACH, plusLOAD CSVandCALL {subqueries. Any hit isINVALID_QUERY_TEMPLATE, and the offending clause is named in the error context. Note that this guard runs in the connector, on every attestation and verification — unlike the$identifiercheck it is not applied at registration, so a template with a write clause registers successfully and then fails the first time it runs. - A trailing
;is stripped. BurnLedger appends its ownLIMITto bound the result set, so do not end the template with your ownLIMIT. - Templates are normalized before they are stored and executed: whitespace is collapsed and bare tokens that are SQL keywords are uppercased. Cypher keywords are case-insensitive so this is normally invisible, but avoid variables and aliases that collide with one — a variable named
setbecomesSETand trips the write-clause guard, and... AS countbecomesAS COUNT, changing the column name that feeds the record hash.
Examples:
// Single label, subject identified by email
MATCH (u:User {email: $identifier}) RETURN u.id AS id, u.email AS email, u.name AS name
// Subject identified by an external ID property
MATCH (c:Customer) WHERE c.external_id = $identifier RETURN c.id AS id, c.email AS email
// Traverse to related data that also belongs to the subject
MATCH (u:User {email: $identifier})-[:PLACED]->(o:Order)
RETURN o.id AS order_id, o.total AS total, o.created_at AS created_at
// Multiple hops: profile and addresses attached to the subject
MATCH (u:User {email: $identifier})-[:HAS_PROFILE]->(p:Profile)
OPTIONAL MATCH (u)-[:LIVES_AT]->(a:Address)
RETURN p.phone AS phone, a.street AS street, a.city AS city
Return scalar properties, not whole nodes. RETURN u works, but BurnLedger hashes the rendered value of each returned column — for a node that string includes its internal element identifier and every property. Internal ids are not stable across restores, re-imports or store copies, so a whole-node projection can produce a different hash for data that did not change. Projecting the properties you actually care about (RETURN u.email AS email, u.name AS name) gives a stable, auditable hash.
Important: design your query to return all records associated with the data subject that you need to prove exist (or have been deleted). If a subject's data spans disconnected parts of the graph, use OPTIONAL MATCH, or register multiple systems.
How BurnLedger enforces read-only execution
Three independent layers, all of them in the code path for every attestation:
- Static template guard. The raw template is scanned for write clauses (list above) before anything is sent to the server, and rejected with
INVALID_QUERY_TEMPLATE. - Server-enforced read transaction. The query runs inside a managed read transaction (
AccessMode: Read). Neo4j itself rejects any write attempted in that transaction — on Community Edition too, regardless of what the credential is allowed to do. - Credential check at connection time. BurnLedger refuses to construct the connector at all if it detects, or cannot rule out, write privileges. See below.
BurnLedger never writes to your database — not even to test whether it could.
Read-Only User Setup
BurnLedger requires a dedicated read-only Neo4j user, and it verifies this non-destructively: at connection time it opens a session on the system database and runs
SHOW USER PRIVILEGES YIELD access, action, segment
Every row with access = GRANTED is inspected, and if any action is a write-class action the connection is refused with WRITE_ACCESS_DETECTED.
Actions treated as write access include: write, create, create_element, delete, delete_element, set_property, set_label, remove_label, merge, graph_actions, all_graph_privileges, index and constraint management (create_index, drop_index, index_management, create_constraint, drop_constraint, constraint_management), token/name management (create_label, create_reltype, create_propertykey, name_management, token), and administration (database_actions, dbms_actions, create_database, drop_database, set_database_access, role_management, user_management, privilege_management, execute_admin, execute_boosted).
Read-class actions — read, match, traverse, access, show_privilege — are fine, and are exactly what the built-in reader role grants.
Enterprise Edition (privileges are introspectable)
Run these as an administrator, against the system database:
// Create the user
CREATE USER burnledger_ro
SET PASSWORD 'REPLACE-WITH-YOUR-PASSWORD'
SET PASSWORD CHANGE NOT REQUIRED;
// Grant the built-in read-only role
GRANT ROLE reader TO burnledger_ro;
If you prefer an explicitly scoped custom role instead of reader:
CREATE ROLE burnledger_reader;
GRANT ACCESS ON DATABASE neo4j TO burnledger_reader;
GRANT MATCH {*} ON GRAPH neo4j ELEMENTS * TO burnledger_reader;
GRANT ROLE burnledger_reader TO burnledger_ro;
Verify — this is the same view BurnLedger reads:
// As an administrator
SHOW USER burnledger_ro PRIVILEGES;
// Or, connected as burnledger_ro, exactly the connector's query
SHOW USER PRIVILEGES YIELD access, action, segment;
Every GRANTED row should show a read-class action (read, match, traverse, access). If a write-class action appears, revoke it:
REVOKE WRITE ON GRAPH neo4j FROM burnledger_reader;
REVOKE ROLE editor FROM burnledger_ro;
REVOKE ROLE publisher FROM burnledger_ro;
REVOKE ROLE architect FROM burnledger_ro;
REVOKE ROLE admin FROM burnledger_ro;
On Enterprise you do not need to set read_only — the introspection result is authoritative and the assertion is not consulted.
Community Edition (read_only: true is required)
Community Edition has no privilege subsystem: there are no roles, SHOW USER PRIVILEGES is unsupported, and every user that can log in has full access to the graph. BurnLedger therefore cannot verify that the credential is read-only, and it fails closed: without read_only, registration is refused with CONNECTION_FAILED and the message "cannot verify the credential is read-only".
Setting read_only: true is your operator assertion, on the record, that these credentials are not used to write. BurnLedger logs a warning that write access was not actively verified and proceeds. It does not make the credential read-only — what actually prevents writes on Community is the static template guard plus the server-enforced read transaction described above.
// Community: you can still isolate BurnLedger to its own user,
// but the user cannot be restricted to read-only by the database.
CREATE USER burnledger_ro
SET PASSWORD 'REPLACE-WITH-YOUR-PASSWORD'
SET PASSWORD CHANGE NOT REQUIRED;
connection={
"uri": "bolt+s://neo4j.example.com:7687",
"username": "burnledger_ro",
"password": os.environ["NEO4J_PASSWORD"],
"database": "neo4j",
"read_only": True, # required on Community Edition
}
If your compliance posture will not accept an unverifiable assertion, the options are Enterprise Edition (real RBAC and introspection) or pointing BurnLedger at a read replica the credential cannot write to.
If you take the replica option, POST /v1/systems/test-connection now reports which endpoint answered: replication.role comes from SHOW DATABASES — primary for a standalone or the writer, replica for a follower, secondary or read replica, and unknown when the database is hosted by more than one server and the result does not say which one replied. A replica re-seeded from an older store can report zero where the writer would not. No lag is available; Neo4j publishes it only through metrics endpoints a query credential cannot reach. See endpoint role.
Hash Scope Options
The hashScope parameter controls what BurnLedger hashes when it runs your query.
full (recommended for most cases)
Hashes the complete content of every record returned by the query. Columns are canonicalized in sorted order by name, so RETURN u.email AS email, u.name AS name and RETURN u.name AS name, u.email AS email produce the same hash. Any change to any returned value produces a different hash, giving you the strongest proof that data has or has not been modified.
const system = await bl.registerSystem({
// ...
});
Use when: You need to prove the exact data that existed, or prove that data was deleted and not merely modified (e.g., nulling out properties on a retained node is not the same as deletion under some interpretations of GDPR).
existence
Hashes only whether records exist and how many, not their content. The hash changes only when records are added or removed, not when property values change.
const system = await bl.registerSystem({
// ...
hashScope: "existence",
});
Use when: You only need to prove that records existed and were later removed, and you do not need to prove what was in those records. This is faster for large result sets and avoids hashing sensitive content.
Comparison
full |
existence |
|
|---|---|---|
| Detects node/row deletion | Yes | Yes |
| Detects node/row insertion | Yes | Yes |
| Detects property value changes | Yes | No |
| Hash includes PII content | Yes | No |
| Performance on large result sets | Slower | Faster |
| Recommended for GDPR Art. 17 | Yes | Acceptable |
End-to-End Example
This example walks through the full process: creating a read-only user, registering the system, creating an attestation before deletion, performing the deletion, verifying, and generating a verification record.
1. Database setup (run once, against the system database)
// Enterprise Edition
CREATE USER burnledger_ro
SET PASSWORD 'REPLACE-WITH-YOUR-PASSWORD'
SET PASSWORD CHANGE NOT REQUIRED;
GRANT ROLE reader TO burnledger_ro;
On Community Edition, create the user without the GRANT ROLE line and set read_only: true in the connection config.
2. Application code
TypeScript
import { BurnLedger } from "burnledger";
import fs from "node:fs/promises";
const bl = new BurnLedger({
apiKey: process.env.BURNLEDGER_API_KEY,
});
// Register the system (run once, store the system ID)
const system = await bl.registerSystem({
name: "myapp-users-graph",
connectorType: "neo4j",
connectionConfig: {
uri: "bolt+s://neo4j.example.com:7687",
username: "burnledger_ro",
password: process.env.NEO4J_PASSWORD,
database: "neo4j",
read_only: true,
},
subjectQuery:
"MATCH (u:User {email: $identifier}) " +
"OPTIONAL MATCH (u)-[:HAS_PROFILE]->(p:Profile) " +
"RETURN u.id AS id, u.email AS email, u.name AS name, p.phone AS phone, p.address AS address",
});
// --- When a deletion request comes in ---
const subjectEmail = "jane.doe@example.com";
// Step 1: Attest that the data currently exists
const attestation = await bl.attest(subjectEmail, {
systemIds: [system.id],
proofMode: "merkle",
});
for (const s of attestation.systems) {
console.log(`${s.systemName}: ${s.recordCount} records found`);
}
// Step 2: Delete the data in your application
// await session.run(
// "MATCH (u:User {email: $email}) OPTIONAL MATCH (u)-[:HAS_PROFILE]->(p) DETACH DELETE u, p",
// { email: subjectEmail },
// );
// Step 3: Verify the deletion
// The certificate is ISSUED BY verify() once the data is gone -- there is
// no separate "create certificate" step.
const result = await bl.verify(attestation.id, subjectEmail, { timeout: 60 });
for (const s of result.systems) {
console.log(`${s.systemName}: now ${s.recordCount} records`);
}
if (result.certificate) {
await bl.savePdf(result.certificate.id, `erasure-cert-${subjectEmail}.pdf`);
console.log(`Certificate issued: ${result.certificate.id}`);
console.log(`Transparency status: ${result.certificate.transparencyStatus}`);
} else {
console.log(`Not certified: status ${result.status}`);
}
Python
import os
from burnledger import BurnLedger
bl = BurnLedger(api_key=os.environ["BURNLEDGER_API_KEY"])
# Register the system (run once, store the system ID)
system = bl.register_system(
name="myapp-users-graph",
connector_type="neo4j",
connection_config={
"uri": "bolt+s://neo4j.example.com:7687",
"username": "burnledger_ro",
"password": os.environ["NEO4J_PASSWORD"],
"database": "neo4j",
"read_only": True,
},
subject_query=(
"MATCH (u:User {email: $identifier}) "
"OPTIONAL MATCH (u)-[:HAS_PROFILE]->(p:Profile) "
"RETURN u.id AS id, u.email AS email, u.name AS name, "
"p.phone AS phone, p.address AS address"
),
)
# --- When a deletion request comes in ---
subject_email = "jane.doe@example.com"
# Step 1: Attest that the data currently exists
attestation = bl.attest(
subject_email,
system_ids=[system.id],
proof_mode="merkle",
)
for s in attestation.systems:
print(f"{s.system_name}: {s.record_count} records found")
# Step 2: Delete the data in your application
# session.run(
# "MATCH (u:User {email: $email}) "
# "OPTIONAL MATCH (u)-[:HAS_PROFILE]->(p) DETACH DELETE u, p",
# email=subject_email,
# )
# Step 3: Verify the deletion
# The certificate is ISSUED BY verify() once the data is gone -- there is
# no separate "create certificate" step.
result = bl.verify(attestation.id, subject_email, timeout=60)
for s in result.systems:
print(f"{s.system_name}: now {s.record_count} records")
if result.certificate:
bl.save_pdf(result.certificate.id, f"erasure-cert-{subject_email}.pdf")
print(f"Certificate issued: {result.certificate.id}")
print(f"Transparency status: {result.certificate.transparency_status.value}")
else:
print(f"Not certified: status {result.status.value}")
Note that the deletion in step 2 is performed by your code with your own write credentials. BurnLedger's credential cannot execute it.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
CONNECTION_FAILED with "missing uri" / "missing credentials" |
uri, username or password absent or empty in the connection config |
Supply all three. Check for readOnly-style camelCase typos in neighbouring keys too — the connector reads uri, username, password, database, read_only. |
BAD_REQUEST with "connection config targets a blocked host" |
The uri host is localhost/.local/.internal, fails DNS resolution, or resolves to a private, loopback or link-local address |
Register a publicly resolvable hostname or IP for the Bolt endpoint and restrict it with an IP allowlist; private-network-only endpoints cannot be registered. |
CONNECTION_FAILED with "connectivity check failed" |
Bolt endpoint unreachable, wrong port, or credentials rejected | Verify the host and port 7687, confirm BurnLedger's IPs are allowlisted, and test the same user/password with cypher-shell. |
CONNECTION_FAILED on attestation: "connector neo4j: transport security plaintext is below the required minimum verified". A health check reports only connection failed — read transport_security on the system |
The URI scheme is bolt:// or neo4j:// |
Use bolt+s:// and enable TLS on the Bolt connector. |
CONNECTION_FAILED on attestation: "connector neo4j: transport security encrypted is below the required minimum verified". A health check reports only connection failed — read transport_security on the system |
The URI scheme is bolt+ssc:// or neo4j+ssc:// — TLS accepting any self-signed certificate |
Use bolt+s:// with a certificate that chains to a trusted root and matches the URI hostname. |
CONNECTION_FAILED with "cannot verify the credential is read-only" |
Privilege introspection unavailable (Community Edition) and read_only not set |
Set "read_only": true to assert read-only credentials, or move to an edition/replica where privileges are introspectable. |
WRITE_ACCESS_DETECTED |
SHOW USER PRIVILEGES returned a GRANTED write-class action for this user |
Revoke the write role/privilege, or point at a user holding only reader. See Read-Only User Setup. |
INVALID_QUERY_TEMPLATE with "contains no $identifier" |
Template uses a placeholder from another connector ($1, ?, {identifier}, $IDENTIFIER) or hardcodes the subject |
Rewrite the WHERE/pattern to use $identifier, lowercase and literal. |
INVALID_QUERY_TEMPLATE with "write or side-effecting clause" |
Template contains CREATE, MERGE, DELETE, DETACH, SET, REMOVE, DROP, FOREACH, LOAD CSV or CALL { |
Reduce the template to a pure MATCH … RETURN read. The rejected clause is named in the error context. |
CONNECTION_FAILED with "query timed out" |
Query exceeded the system's query_timeout |
Add an index on the matched property (e.g. CREATE INDEX FOR (u:User) ON (u.email) — run this yourself, not via a template), narrow the traversal, or raise query_timeout. |
RECORD_LIMIT_EXCEEDED |
The query matched more records than max_records |
Narrow the query, or raise max_records up to the plan limit. |
QUERY_HASH_MISMATCH during verification |
Data changed between attestation and verification | Expected if you deleted or modified records. Review the changes array in the verification result. |