+1 (415) 649-9454

Hardening Neo4j for Production: TLS, OIDC SSO, Secret Rotation, and Audit Logs

Most Neo4j security writing stops at "create a role and grant it MATCH." That is authorization, and we covered it in depth in Multi-Tenant Neo4j: RBAC, Fine-Grained Privileges, and Composite Databases. This tutorial is about everything around the role model: encrypting traffic, getting humans out of shared passwords and into your identity provider, keeping credentials out of config files, and producing an audit trail that a security reviewer will actually accept.

It has become urgent for a boring reason: the graph is no longer only read by your application. It is read by AI agents, notebooks, BI tools, and MCP servers, often with credentials that were pasted into a .env file two years ago. Assume that at some point someone will ask you who queried what, and from where.

What we are hardening

A production Neo4j deployment (self-managed 2025.x/2026.x, cluster or single instance) across four layers:

  1. Transport — TLS on Bolt and HTTPS, with certificates that rotate.
  2. Identity — SSO via OIDC, with local accounts reduced to a break-glass minimum.
  3. Secrets — no plaintext passwords in neo4j.conf, Helm values, or container images.
  4. Evidence — security events and query logs shipped somewhere immutable.

Aura users can skip most of layers 1 and 3 (TLS is terminated for you, and there is no neo4j.conf), but layers 2 and 4 still apply — the SSO and log-forwarding settings live in the console rather than on disk.

Step 1 — TLS on every listener

Neo4j organises TLS into SSL policies, one per scope: bolt, https, and cluster. Each policy points at a directory of certificates.

# neo4j.conf
server.bolt.tls_level=REQUIRED
dbms.ssl.policy.bolt.enabled=true
dbms.ssl.policy.bolt.base_directory=certificates/bolt
dbms.ssl.policy.bolt.private_key=private.key
dbms.ssl.policy.bolt.public_certificate=public.crt
dbms.ssl.policy.bolt.client_auth=NONE

dbms.ssl.policy.https.enabled=true
dbms.ssl.policy.https.base_directory=certificates/https
server.https.enabled=true
server.http.enabled=false

Two details cause most of the support tickets:

  • tls_level=REQUIRED is the point. With OPTIONAL, a misconfigured client silently falls back to cleartext and nobody notices for a year. Set it to REQUIRED and fix the clients that break.
  • Intermediates belong in the trusted directory, and the certificate file must contain the full chain. A driver failing with Failed to establish secure connection is usually an incomplete chain, not a bad key.

Drivers then use the neo4j+s:// (or bolt+s://) scheme, which enforces both encryption and hostname verification:

from neo4j import GraphDatabase

driver = GraphDatabase.driver("neo4j+s://graph.internal.example.com:7687",
                              auth=(user, password))
driver.verify_connectivity()

Resist neo4j+ssc:// (self-signed, no verification) outside a laptop. If you need something permissive in CI, use Testcontainers without TLS instead — see Neo4j in CI/CD — so the insecure scheme never appears in shipped code.

Rotation. Certificates expire on a Saturday. Neo4j re-reads certificates when the SSL policy is reloaded, so drive rotation with cert-manager (on Kubernetes) or a scheduled job that replaces the files and triggers a rolling restart. On Kubernetes this pairs naturally with the procedure in Neo4j on Kubernetes. Add a days-to-expiry alert — the metrics pipeline from Neo4j Observability is the right place for it.

Step 2 — SSO with OIDC, and one break-glass account

Local Neo4j users are fine for services and poor for people: they do not get deprovisioned when someone leaves. Neo4j Enterprise supports OIDC for both the driver path and Neo4j Browser.

dbms.security.authentication_providers=oidc-corp,native
dbms.security.authorization_providers=oidc-corp,native

dbms.security.oidc.corp.display_name=Corporate SSO
dbms.security.oidc.corp.auth_flow=pkce
dbms.security.oidc.corp.well_known_discovery_uri=https://login.example.com/.well-known/openid-configuration
dbms.security.oidc.corp.audience=neo4j-prod
dbms.security.oidc.corp.claims.username=sub
dbms.security.oidc.corp.claims.groups=groups
dbms.security.oidc.corp.authorization.group_to_role_mapping=\
  "11111111-aaaa-bbbb-cccc-222222222222"=analyst; \
  "33333333-dddd-eeee-ffff-444444444444"=admin

Notes from doing this on real clusters:

  • Map groups to roles, not users to roles. The IdP group becomes the grant; role definitions stay in Cypher and in version control.
  • Keep native in the provider list for exactly one emergency account with a long random password in your vault, and alert whenever it authenticates. Losing IdP connectivity with no local admin is a genuinely bad afternoon.
  • Service accounts stay native (or use client-credentials tokens). Do not run application connections through an interactive flow.
  • Verify the mapping with SHOW CURRENT USER and SHOW ROLES while logged in as a test identity, before you cut anyone over.

Step 3 — get secrets out of config

Never bake a password into neo4j.conf or an image layer. Two clean options:

Initial password from a file. In Docker and Kubernetes, NEO4J_AUTH_PATH points at a mounted secret file instead of passing NEO4J_AUTH as an environment variable (env vars leak into docker inspect, crash dumps, and CI logs):

env:
  - name: NEO4J_AUTH_PATH
    value: /secrets/neo4j-auth
volumeMounts:
  - name: neo4j-auth
    mountPath: /secrets
    readOnly: true

Rotate application credentials without restarts. The v6 drivers accept an auth token manager, so a short-lived credential (a vault lease or an IdP access token) can be refreshed underneath a long-lived driver:

from neo4j import GraphDatabase, auth_management

def fetch():
    token, expires_at = vault.lease("neo4j/prod")   # your vault client
    return auth_management.ExpiringAuth(
        auth=("neo4j", token), expires_at=expires_at)

driver = GraphDatabase.driver(
    URI, auth=auth_management.AuthManagers.expiration_based(fetch))

Because the driver re-authenticates on the existing connection pool, rotation stops being a deploy event. That is what makes 24-hour credential lifetimes practical. Session-level detail on driver behaviour is in Causal Consistency in Neo4j Clusters.

Step 4 — the security log is the evidence

Neo4j writes several logs; for audits the two that matter are security.log (authentication and authorization events, including failures and administration commands) and query.log.

# server-logs.xml governs levels and rotation; these are the knobs to check
db.logs.query.enabled=VERBOSE
db.logs.query.parameter_logging_enabled=false
db.logs.query.threshold=0

Set parameter_logging_enabled=false unless you have consciously decided that parameter values — which are your customers' data — belong in a log file. That single setting is the most common finding we raise on client health checks; see the Neo4j Health Check Checklist.

Ship both logs off the box. Logs that live only on the instance you are investigating are not evidence. Then build three alerts:

SignalWhat to watch in your log storeWhy
Authentication failure spikessecurity.log events per source IP per minuteCredential stuffing, or a rotated secret a service did not pick up
Administration commands outside change windowsCREATE USER, GRANT, DROP ROLE in security.logPrivilege drift; every one should map to a ticket
Unbounded reads by human identitiesquery.log entries returning huge row counts from SSO usersBulk export, and usually also a missing LIMIT

SHOW TRANSACTIONS is useful for live inspection, but do not treat it as an audit trail — it is a moment in time.

Step 5 — reduce the attack surface

A short list that takes an afternoon and removes most of the easy wins:

  • Network: bind Bolt and HTTP to private interfaces (server.default_listen_address) and let a load balancer or service mesh handle exposure. A Neo4j reachable from the internet on 7687 will be found by scanners within hours.
  • Procedures: allowlist rather than blanket-enable. dbms.security.procedures.unrestricted should name specific procedures, never apoc.* — the review in Auditing Your APOC Dependency is the natural time to trim it.
  • File access: keep apoc.import.file.enabled and apoc.export.file.enabled off in production; a procedure that reads local files is a data-exfiltration primitive.
  • Browser and admin endpoints: do not expose Neo4j Browser publicly; reach it through your VPN or an authenticating proxy.
  • Agents and MCP servers: give them their own read-only role scoped to the labels they need, not the application's credentials. The MCP server tutorial shows where that credential is configured, and any text-to-Cypher path should carry the guardrails from Text-to-Cypher with Guardrails.

A verification pass you can re-run

Hardening decays. Put this in a quarterly runbook:

SHOW USERS YIELD user, suspended, passwordChangeRequired;
SHOW ROLES WITH USERS;
SHOW PRIVILEGES AS COMMANDS;

Diff the output against a committed expected state and fail the job on drift. Pair it with certificate expiry checks (openssl s_client) and a test asserting that a cleartext Bolt connection is refused. Something that verifies a control is worth more than a wiki page claiming it exists.

Where teams get stuck

The pattern we see most often is a graph that started as a pilot: one neo4j superuser, TLS disabled because it was "internal", APOC fully unrestricted, and no log retention. None of that is hard to fix, but doing it on a live cluster without an outage takes sequencing — TLS before SSO, service credentials before human ones, logging before you change anything you might need to roll back.

If you want a second pair of eyes on a production graph, GraphGuru's senior Neo4j consultants run security and architecture reviews and can implement the remediation alongside your team. Get in touch with your version, deployment model, and compliance target, and we will tell you what actually needs doing.