InstantDB Pre-Auth RCE via HoneySQL and Nippy
How an anonymous InstantDB query became SQL injection and unsafe deserialization in the server process.
On August 22, 2026, Instant announced that its team was joining OpenAI. The announcement made me interested in looking more closely at InstantDB, an open source project with more than 10,000 stars on GitHub.
After setting up InstantDB locally for security research, I found an exploit chain from an unauthenticated request to remote code execution in the server process.
An anonymous query could turn a JSON pagination cursor into HoneySQL syntax. From there, an attacker could use stacked SQL statements to create a credential and malicious history record. A request to the webhook route would then retrieve the record and send it to Nippy for Java object reconstruction.
Testing used revision a4d2ef33 of the public repository and its docker-compose.local.yml setup.
The exploit chain
anonymous POST /runtime/framework/query
|
v
JSON cursor map becomes HoneySQL {:raw ...}
|
v
stacked SQL commits and plants a token, webhook, and history row
|
v
GET /webhooks/payload/... with the planted token
|
v
Zstd decompress -> Nippy thaw -> Java object reconstruction
|
v
command execution in the server container
The first request had no Authorization header and used a nonexistent app UUID. The credential used by the second request did not exist beforehand. The SQL injection created it.
1. A cursor crossed into HoneySQL
POST /runtime/framework/query accepts a structured InstaQL query without requiring a bearer token. Anonymous queries are a normal product feature, so that behavior alone was not the bug.
For a query ordered by a numeric field, one pagination cursor element should also be numeric. The cursor validator checked the cursor’s shape, attribute UUID, and timestamp, but did not enforce the type of its value.
A JSON object could therefore appear where the number belonged:
{"raw": "<SQL text>"}
The JSON key became a Clojure keyword, producing {:raw "<SQL text>"}. In HoneySQL, that shape represents raw SQL. add-cursor-comparisons placed the cursor value directly inside the HoneySQL abstract syntax tree, so the map was interpreted as query syntax instead of bound as data. That allowed stacked PostgreSQL statements to be appended.
2. The read connection still allowed writes
The query path requested (aurora/conn-pool :read), but this was not a separate database role. The read wrapper reused the normal pool and changed JDBC connection state:
(let [conn (.getConnection pool)]
(.setReadOnly conn true)
(.setAutoCommit conn false)
conn)
In my local test, the injected SQL explicitly committed the read-only transaction and then issued writes with the server’s PostgreSQL role. It created or replaced an app admin token, a webhook, and a history record containing attacker-selected bytes.
The first request returned HTTP 500, but the injected changes had already committed. The response was not the execution oracle.
3. The forged history row reached Nippy
The second request called GET /webhooks/payload/:app_id/:webhook_id/* using the planted admin token. The route retrieved the planted webhook and looked up history by app ID and ISN.
For history stored in PostgreSQL, get-by-app-id-and-isn passed the row’s content through this decoder:
(defn unpack-wal-record ^WalRecord [^bytes ba]
(-> ba
(Zstd/decompress)
(nippy/fast-thaw)))
The decoder assumed those bytes came from InstantDB’s trusted WAL pipeline. SQL injection invalidated that assumption by letting an anonymous attacker choose the stored content.
The forged value contained Zstandard-compressed Nippy data wrapping a Java-serialized gadget. Nippy reconstructed the object during fast-thaw, executing a harmless marker command in the server process.
The webhook request returned HTTP 200. Inside the default local server container, the marker contained:
nathan was here: uid=0(root) gid=0(root) groups=0(root)

Proof of concept screenshot.
The fix
About 12 hours after my report, Joe Averbukh replied that two hotfixes had been deployed and shared commit 5dcf954. It added two independent defenses:
lift-literalnow wraps collection values before they enter HoneySQL. Cursor and other user-provided InstaQL values are bound as data instead of interpreted as query-language maps or vectors.- Nippy’s Java serialization freeze and thaw allowlists are now empty. A regression test confirms that a serialized Java object becomes
:nippy/unthawableinstead of being reconstructed.
I rebuilt a fresh local stack at the patched commit and reran the original proof of concept to confirm that the exploit chain was broken.
Timeline
- August 23: I privately reported the complete local chain and proof of concept.
- August 24: Joe Averbukh replied that two hotfixes had been deployed and supplied the fix commit.
- August 24: I confirmed the original proof of concept no longer worked against a fresh patched stack.
- October 12: Public disclosure.
The revision I tested was the latest public commit before the fix, which was committed directly on top of it the following day. I did not complete a full analysis of which earlier revisions were affected.
LLMs were used in the discovery of this vulnerability and the writing of this post.