When a Hosted Subgraph Became a DoS Vector
Drafted December 2024. Finished and published August 2026 as part of the migration away from WordPress.
An API key shipped to a browser is an identifier, not a secret. For Upside, that key was attached to a metered subgraph which supplied token balances to the dashboard. Anyone who copied it could consume our query allowance until the feature stopped working.
We had built the subgraph to answer a deceptively difficult question: which of the protocol’s tokens does this wallet hold?
An RPC can return the balance of a token you already know about. It cannot efficiently discover balances across every ERC-20 the protocol has deployed. We built a subgraph for that job and initially hosted it on Satsuma, which had become Alchemy Subgraphs.
The indexing worked. The trust boundary did not.
Origin restrictions are not authentication
The frontend queried the hosted GraphQL endpoint directly, so its API key appeared in every user’s browser. We restricted the origins allowed to call it. That stopped another website from casually reusing the endpoint through a browser, but it did not stop a script from reproducing the request and its headers.
The risk became concrete when a subgraph used by another application received what looked like deliberate query flooding. It went from tens of thousands of queries in a normal month to more than half a million in one day. The requests did not need to breach our infrastructure. They only needed to exhaust an allowance.
Moving to another hosted provider would not have changed that property. The Graph’s free hosted service had shut down in June 2024. Subgraph Studio included 100,000 queries per month for free, then charged for additional usage. There was still an API key, a meter and an untrusted client capable of spending against it.
The problem was not that our key had leaked. Delivering it to the frontend was how the product was designed to work.
Three ways out
| Approach | What it solved | What it cost |
|---|---|---|
| Keep the hosted endpoint | No migration or new infrastructure | The public allowance remained exhaustible |
| Run Graph Node | Reuse the subgraph and its GraphQL interface | Operate Graph Node, PostgreSQL, IPFS and substantial RPC capacity |
| Build our own event index | Use infrastructure and data models we already controlled | Change the contracts, pay gas for another event and build the indexer |
The custom indexer was attractive. Each token could emit an additional protocol-specific event alongside its standard ERC-20 Transfer, and a backend listener could maintain balances in our database. It would have been a simpler long-term system, but the event would be permanent in every deployed token contract and we did not have much time to make the change safely.
Self-hosting was the quickest reversible option. The subgraph already described the data correctly, so I could move the existing workload instead of redesigning it. That was the route I took.
The index we kept
The protocol could deploy an open-ended number of token contracts, so the subgraph could not list their addresses in advance. It listened for UpTokenDeployed on the protocol contract and created a dynamic data source for each new token.
From that point it processed the token’s standard Transfer events and maintained one balance entity for each token and wallet pair:
export function handleUpTokenDeployed(event: UpTokenDeployed): void {
UpToken.create(event.params.upTokenAddress)
}
export function handleTransfer(event: Transfer): void {
updateBalance(event.address, event.params.from, event.params.value.neg())
updateBalance(event.address, event.params.to, event.params.value)
}
The production mapping also handled entity loading, self-transfers and token metadata. The important part was that the frontend could ask for every positive balance belonging to one wallet without inspecting every token contract itself.
What self-hosting actually meant
A subgraph is the manifest, schema and mappings. The service running them is Graph Node. Moving it in-house meant running Graph Node, PostgreSQL for its state, IPFS for deployment data and a Base RPC connection for chain data.
The frontend still made GraphQL queries, but query volume no longer consumed a hosted-service allowance. Graph Node independently indexed Base and stored the derived balances in PostgreSQL.
Graph Node was also intensive on the RPC behind it. It continuously ingested blocks and receipts, searched for relevant logs and replayed that work when the subgraph had to be reindexed. An RPC which was adequate for normal application calls was not necessarily adequate for an indexer catching up from an earlier block.
The result needed monitoring at several layers. We had to watch the Graph Node process, PostgreSQL, the RPC and the indexed block reported by _meta.block.number. A GraphQL endpoint can return a successful response while its indexed state trails the chain.
Self-hosting did not eliminate denial of service either. A public GraphQL endpoint can still be flooded. It changed the failure mode from an externally imposed query allowance to infrastructure we could rate-limit, monitor and scale ourselves.
A good fix, but probably not the destination
Once the self-hosted subgraph had caught up, the frontend moved to its new endpoint and the hosted one was disabled. Because the GraphQL schema stayed the same, the application needed very little migration work. For the problem in front of us, it was a good solution and a useful experience.
I am less convinced it is the right long-term architecture. Subgraphs are supposed to remove indexing infrastructure from an application. Once Graph Node is self-hosted, that benefit reverses: we keep the convenient mapping model but inherit a specialised database, deployment dependency, RPC workload and another service to operate. Each additional chain or index increases that burden.
If I were designing the protocol again, I would lean towards emitting the data the application needs and consuming it with an indexer we already operate. That still requires infrastructure, but it keeps the indexing logic alongside the rest of the backend instead of introducing an entire Graph Node stack.
Self-hosting bought us control when we needed it. The more important lesson was that a metered API called directly by an untrusted client is a product availability risk, however convenient the service behind it may be.