Every cloud security team already owns the raw material for an attack graph: an asset inventory, an IAM export, a vulnerability scan, and a network policy dump. What they usually lack is the join. Asking "if this laptop is phished, what production data can the attacker reach, and how many hops away is it?" is a path question, and path questions are exactly what relational inventories answer badly and Neo4j answers natively.
This tutorial builds a working attack-path graph: a small identity-and-infrastructure model, the Cypher that finds blast radius and choke points, and the operational bits — indexes, refresh cadence, and scoring — that keep it useful once it holds a few million relationships.
Why a graph, not another dashboard
A CSPM tool tells you that a role has iam:PassRole. A vulnerability scanner tells you a host runs a vulnerable library. Neither tells you that the vulnerable host runs as a service account that can assume a role that can read the customer-PII bucket. That finding only exists in the composition of three tools' data, and composition over variable-length chains is where a graph query is a few lines and a SQL query is a recursive CTE nobody maintains.
The pattern is well proven — BloodHound made it standard practice for Active Directory — but the interesting work in 2026 is doing it across cloud IAM, Kubernetes RBAC, and CI/CD identity in one graph, and keeping it fresh enough to act on.
Step 1 — a model you can actually populate
Keep the schema small. Every label must map to something a real exporter emits.
(:Identity {id, name, type}) // human user, service account, CI principal
(:Role {id, name, account})
(:Permission {action, effect})
(:Resource {id, type, account, sensitivity})
(:Host {id, name, env, exposed})
(:Vulnerability {cve, cvss, kev})
(:Network {id, cidr})
Relationships carry the semantics of what an attacker can do, not what the org chart says:
(:Identity)-[:CAN_ASSUME]->(:Role)
(:Role)-[:CAN_ASSUME]->(:Role)
(:Role)-[:GRANTS]->(:Permission)
(:Permission)-[:ON]->(:Resource)
(:Identity)-[:AUTHENTICATES_ON]->(:Host)
(:Host)-[:RUNS_AS]->(:Identity)
(:Host)-[:HAS_VULN]->(:Vulnerability)
(:Host)-[:REACHES]->(:Host)
Two modelling rules earn their keep here. First, make edges directional in the direction of privilege flow — CAN_ASSUME points at the thing you gain, so every traversal reads as an attack step. Second, keep Permission a node rather than a property blob, because deduplicating s3:GetObject across thousands of policies is what stops your graph exploding into a supernode swamp. (If you inherit a model that already exploded, our write-up on finding supernodes and refactoring a live graph covers the cleanup.)
Step 2 — constraints and indexes first
CREATE CONSTRAINT identity_id IF NOT EXISTS
FOR (i:Identity) REQUIRE i.id IS UNIQUE;
CREATE CONSTRAINT role_id IF NOT EXISTS
FOR (r:Role) REQUIRE r.id IS UNIQUE;
CREATE CONSTRAINT resource_id IF NOT EXISTS
FOR (x:Resource) REQUIRE x.id IS UNIQUE;
CREATE CONSTRAINT host_id IF NOT EXISTS
FOR (h:Host) REQUIRE h.id IS UNIQUE;
CREATE CONSTRAINT vuln_cve IF NOT EXISTS
FOR (v:Vulnerability) REQUIRE v.cve IS UNIQUE;
CREATE INDEX resource_sensitivity IF NOT EXISTS
FOR (x:Resource) ON (x.sensitivity);
CREATE INDEX host_exposed IF NOT EXISTS
FOR (h:Host) ON (h.exposed);
Uniqueness constraints are not hygiene here, they are performance: every ingest run is a MERGE storm, and an unconstrained MERGE on a million-node label is a full scan per row.
Step 3 — load a snapshot idempotently
Exports arrive as JSON from aws iam get-account-authorization-details, kubectl get rolebindings -o json, your scanner's API, and so on. Flatten each to edge lists, then load with parameterised batches:
UNWIND $edges AS e
MERGE (a:Identity {id: e.from})
MERGE (b:Role {id: e.to})
MERGE (a)-[r:CAN_ASSUME]->(b)
SET r.source = e.source, r.observed_at = datetime($run_at);
Stamp every relationship with observed_at and the exporter that produced it. That single habit gives you three things for free: idempotent re-runs, the ability to delete stale edges (WHERE r.observed_at < datetime($run_at)) instead of dropping and rebuilding the database, and provenance when an engineer disputes a finding.
For the initial bulk load of a large estate use neo4j-admin database import; for the nightly delta, batched writes are fine — see bulk writes without the deadlocks for sizing the batches.
Step 4 — the queries that matter
Blast radius from a compromised identity. Quantified path patterns (Cypher 25) express bounded privilege chains cleanly:
MATCH (start:Identity {id: $identity})
MATCH path = (start)-[:CAN_ASSUME]->{1,5}(r:Role)-[:GRANTS]->(:Permission)-[:ON]->(res:Resource)
WHERE res.sensitivity IN ['restricted', 'pii']
RETURN res.id AS resource,
min(length(path)) AS hops,
count(DISTINCT path) AS distinct_paths
ORDER BY hops ASC, distinct_paths DESC
LIMIT 50;
Bound the repetition. {1,5} is a security decision as much as a performance one: an unbounded assume-role chain across a large org will enumerate paths for minutes and tell you nothing an analyst can act on.
Shortest route from the internet to the crown jewels. SHORTEST k gives you the handful of routes worth triaging rather than all of them:
MATCH (h:Host {exposed: true})-[:HAS_VULN]->(v:Vulnerability)
WHERE v.kev = true
MATCH p = SHORTEST 3 (h)-[:REACHES|RUNS_AS|CAN_ASSUME|GRANTS|ON]-+(res:Resource {sensitivity: 'pii'})
RETURN h.name AS entry_point, v.cve AS exploited, length(p) AS hops,
[n IN nodes(p) | coalesce(n.name, n.id, n.action)] AS route
ORDER BY hops ASC
LIMIT 25;
Filtering entry points to CISA KEV-listed vulnerabilities is the difference between a report with 40 000 findings and one with 25. Exploitability first, then reachability, then sensitivity.
Choke points — the fixes with the best return. Which single node, if removed or tightened, breaks the most attack paths? Betweenness centrality on the attack subgraph answers it:
CALL gds.graph.project(
'attack',
['Identity','Role','Permission','Resource','Host'],
{
CAN_ASSUME: {orientation: 'NATURAL'},
GRANTS: {orientation: 'NATURAL'},
ON: {orientation: 'NATURAL'},
RUNS_AS: {orientation: 'NATURAL'},
REACHES: {orientation: 'NATURAL'}
}
);
CALL gds.betweenness.stream('attack')
YIELD nodeId, score
WITH gds.util.asNode(nodeId) AS n, score
WHERE score > 0
RETURN labels(n)[0] AS kind, coalesce(n.name, n.id, n.action) AS thing, round(score) AS betweenness
ORDER BY betweenness DESC
LIMIT 20;
On real estates the top of that list is nearly always the same shape: two or three over-broad roles that everything routes through. Those are your quarter's remediation backlog, ranked by evidence instead of by whoever shouted loudest.
Toxic combinations. Static patterns that should never exist, run as assertions in CI:
MATCH (i:Identity {type: 'ci'})-[:CAN_ASSUME]->{1,3}(:Role)-[:GRANTS]->(p:Permission)
WHERE p.action IN ['iam:PassRole', 'iam:CreatePolicyVersion', 'sts:AssumeRole']
RETURN i.name AS ci_principal, collect(DISTINCT p.action) AS escalation_primitives;
Step 5 — make it fast enough to query interactively
Three things dominate performance on this workload:
- Bound your patterns. Depth caps on every quantified pattern, and
LIMITon everything an analyst runs. - Prune before you traverse. Anchor on the small set — exposed hosts with KEV vulns, or sensitive resources — never on
MATCH (n). - Read the plan. If a query is slow,
PROFILEit and look for the expand that blows up row counts; our guide to reading a PROFILE plan walks through the fix pattern.
Attack graphs are read-heavy and refreshed in batch, which makes them a good fit for a read replica or an Aura instance sized for page cache rather than write throughput. Check the memory sizing guide before you provision: if the whole graph fits in page cache, these traversals run in milliseconds.
Step 6 — ship findings, not a database
A security graph nobody queries dies in a quarter. Two integration patterns work:
- Scheduled detections. Each toxic-combination query runs nightly, diffs against yesterday's result, and opens a ticket for new matches only. Diffing is what keeps alert fatigue away.
- Investigation UI. Analysts want to click a path, not write Cypher. The Neo4j Visualization Library gives you an embeddable path explorer — see building a graph investigation UI with NVL and React.
If you want natural-language questions over the graph ("what can this contractor reach?"), constrain generation to a handful of parameterised templates rather than free-form Cypher; the guardrail patterns in text-to-Cypher with guardrails apply directly, and they matter more here than anywhere, because this database is itself a map of how to attack you.
Guard the guard rail
Treat the attack graph as a crown-jewel system: it is an attacker's dream artefact. Restrict it with fine-grained RBAC, keep production credentials out of the ingest jobs' blast radius, and audit reads — the patterns in hardening Neo4j for production and multi-tenant RBAC are the baseline, not the ceiling.
Where teams get stuck
The model is the easy part. What stalls these projects is ingestion: normalising five exporters into one identity namespace, deciding what "reaches" means for a service mesh, and keeping the refresh under the window the security team needs. That is graph data engineering, and it is the work our Neo4j consultants do most often on security engagements. If you are standing one of these up and want senior help on the model or the pipeline, get in touch.