Stop Leaking Your API Keys - Implementing a Next-Level GraphQL Proxy
In the world of Web3, developer experience often clashes with security. We want to build fast, reactive dApps, but we often leave our frontends vulnerable by exposing sensitive API keys in the client-side bundle.
If you are building a dApp that relies on subgraphs, you've likely dropped an API key into a .env file and called it a day. But if that key is used in a client-side fetch, anyone with a "Network Tab" and a bit of curiosity can steal it.
Here is how to implement an API Proxy to take your dApp security to the next level.
The Problem: The "Client-Side Leak"
When you query a subgraph directly from a React, Vue, or vanilla JavaScript frontend, your API key is included in the request. This leads to three major risks:
- Visibility: The key is plain to see in the browser's developer tools.
- Theft: Malicious actors can "sniff" your key and use it to query data on your budget.
- Spoofing: Even with domain restrictions, headers can occasionally be spoofed, and keys can be used in scripts outside your website.
The Solution: The API Proxy
Instead of the browser talking directly to a decentralized gateway or an indexer, we introduce a middleman: our own server.
The flow changes from:
- Old Way: Client → Subgraph Gateway (Key Leaked)
- Secure Way: Client → Your Server (Proxy) → Subgraph Gateway (Key Hidden)
Step 1: Secure Your Environment Variables
First, create a central environment manager. This uses @t3-oss/env-nextjs and Zod to validate your keys at build time and runtime.
// env/server.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
// Ensuring the API key is the correct length for extra security
API_KEY: z.string().min(32).max(32),
// A fallback for local development
ROOT_URI: z.string().url().default("http://localhost:3000/api/graphql"),
},
runtimeEnv: {
API_KEY: process.env.API_KEY,
ROOT_URI: process.env.ROOT_URI,
},
});Create your .env.local file:
API_KEY=your_secret_key_here
ROOT_URI='http://localhost:3000/graphql'⚠️ Don't use the
NEXT_PUBLIC_prefix for sensitive keys.
Step 2: Create the API Proxy Route
1. Define the Validation Schema
To prevent malicious actors from sending malformed requests to your proxy, we use Zod to validate the incoming request body. This ensures the "shape" of the GraphQL query is correct before we even attempt to forward it.
// app/graphql/route.ts
import { z } from "zod";
const GraphqlReqSchema = z.object({
query: z.string().min(1),
operationName: z.string().optional().nullable(),
variables: z.record(z.string(), z.unknown()).optional().nullable(),
});2. Initialize the Hidden GraphQL Client
On the server side, we initialize the GraphQLClient. This client is configured once with your protected URL and the Authorization header.
// app/graphql/route.ts
import { GraphQLClient } from "graphql-request";
const SUBGRAPH_URL = "https://api.studio.thegraph.com/query/your-id/subgraph-name/version/latest";
const client = new GraphQLClient(SUBGRAPH_URL, {
headers: {
Authorization: `Bearer ${process.env.SUBGRAPH_API_KEY}`,
},
});3. Create the Proxy Logic
Now, we create a central process function. This function handles the validation, the execution, and the error reporting. By using safeParse, we can return a clean 400 Bad Request if the frontend sends something invalid.
// app/graphql/route.ts
import { NextResponse } from "next/server";
async function processRequest(request: Request) {
const body = await request.json();
const parsed = GraphqlReqSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error }, { status: 400 });
}
const { query, variables } = parsed.data;
try {
const gqlResponse = await client.request(query, variables ?? undefined);
return NextResponse.json({ data: gqlResponse }, { status: 200 });
} catch (error) {
return NextResponse.json({ error: "Upstream request failed" }, { status: 500 });
}
}4. Export the API Handlers
Finally, we expose this logic via GET and POST methods in our Next.js route file. This allows your frontend to fetch data using standard HTTP methods.
// app/graphql/route.ts
export async function GET(request: Request) {
return processRequest(request);
}
export async function POST(request: Request) {
return processRequest(request);
}Step 3: The "Dual-Client" Optimization
Now that our proxy route is live, we face a new challenge: Efficiency.
When Next.js renders a page on the server (SSR), it shouldn't have to make an HTTP request to its own API route — that's like calling your own house from the kitchen to ask what's for dinner. Instead, we want our server to talk directly to the Subgraph, while the browser continues to use the Proxy.
1. Creating the SSR Client (Server-Only)
Create a client that is only ever used on the server. Because this code never touches the browser, it is safe to use the secret API key and the direct Subgraph URL.
// lib/graphql/ssr.client.ts
import { GraphQLClient } from "graphql-request";
import { env } from "@/env/server";
export const client = new GraphQLClient(env.ROOT_URI);2. Creating the Browser Client (Proxy-Only)
This client is designed for your 'use client' components. It is hardcoded to point to the /graphql route we just built.
// lib/graphql/client.ts
import { GraphQLClient } from "graphql-request";
const getGraphQLUrl = () => {
if (typeof window !== "undefined") {
return `${window.location.origin}/graphql`;
}
return "/graphql";
};
export const client = new GraphQLClient(getGraphQLUrl());Step 4: Writing Universal Fetchers
The "Next Level" secret is making your data-fetching logic Client-Agnostic. You don't want to write one function for the server and one for the browser. Instead, you write a single function that accepts any GraphQLClient.
// services/messages.service.ts
export type QueryAllMessagesArgs = {
client: GraphQLClient;
vars?: GetAllMessagesQueryVariables;
};
export async function queryAllMessages({ client, vars }: QueryAllMessagesArgs) {
const GET_MESSAGES = `...your gql query...`;
const data = await client.request(GET_MESSAGES, vars);
return data.messages || [];
}For better DevEx and better type safety, you should export your queries from a dedicated graphql/queries.ts file using the GraphQL Code Generator client-preset:
export const GET_ALL_MESSAGES = graphql(`…your gql query…`)Step 5: Implementing in the UI
Now, you simply "inject" the correct client based on where your code is running.
// app/messages/page.tsx
const messages = await queryAllMessages({ client: ssrClient });⚠️ Make sure to use the correct client, based on the rendering context, or the app will crash.
Extra Step: Prefetching on the Server
To get the best possible performance, you should prefetch your data on the server using TanStack Query. This allows you to "prime" the cache so that the data is already there when the client-side React components mount.
// app/messages/page.tsx
import { QueryClient } from "@tanstack/react-query";
import { client } from "@/graphql/ssr.client";
import { queryAllMessages } from "@/lib/graphql/fetchers";
export default async function Page() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ["messages", "all"],
queryFn: () => queryAllMessages({ client }),
});
// Hydrate the client with the prefetched data
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<MessagesList />
</HydrationBoundary>
);
}Why This Matters
Security in Web3 isn't just about the smart contract; it's about the infrastructure that connects your users to that data. By hiding your API keys behind a proxy, you move from a "vulnerable-by-default" setup to a professional, hardened architecture.
References
-
Next.js API Routes Documentation
- https://nextjs.org/docs/app/building-your-application/routing/route-handlers
- Official guide for creating API routes in Next.js
-
GraphQL Request Documentation
- https://github.com/jasonkuhrt/graphql-request
- Lightweight GraphQL client library
-
Zod Schema Validation
- https://zod.dev
- TypeScript-first schema validation library
-
T3 Env - Environment Variable Validation
- https://env.t3.gg
- Type-safe environment variable management for Next.js
-
TanStack Query Documentation
- https://tanstack.com/query/latest
- Powerful async state management library