🔐 Protecting Node Provider URLs for Frontend dApps
Problem
Ever wondered how to protect your node provider API key and URLs for services like Alchemy in your frontend dApp? You’re not alone — it’s a common challenge for many developers. While there are plenty of discussions, such as this one, this one, this one, this one, this one, and this one, most of them fall short of providing effective solution.
Frontend dApps face a unique challenge that backend services don’t: making node RPC calls directly from the browser. When instantiating an Ethers.js provider or a wagmi config, most people expose their API key on the frontend:
// https://wagmi.sh/react/api/createConfig
const config = createConfig({
chains: [mainnet],
client({ chain }) {
return createClient({
chain,
transport: http("https://eth-mainnet.g.alchemy.com/v2/<YOUR-API-KEY>") // Your API key is exposed
});
},
});
// https://docs.ethers.org/v5/api/providers/api-providers/#AlchemyProvider
const alchemyProvider = () => {
return new providers.AlchemyProvider(
"mainnet",
<YOUR-API-KEY> // Your API key is exposed
);
};So, how can frontend dApps protect their node provider API key? Here are some common suggestions:
- Add your app’s domain to a referrer allowlist on Alchemy or QuickNode
- Lock down your API key to a set of IPs
- Use a backend to proxy the RPC calls
Unfortunately, none of these methods are truly effective. Allowing only your app’s domain to access the RPC can be bypassed by spoofing the referrer header. Locking down the API key to specific IPs is unrealistic — you can’t allowlist all your users’ IPs. Proxying RPC calls through a backend hides the API key, but abusers can still exploit your proxy.
Solution
An effective solution is to use short-lived JSON Web Tokens (JWTs) for authentication. In this approach, your app generates a short-lived JWT and includes it with the RPC request. These JWTs are securely created by your backend using a private key. Since the tokens are short-lived and can only be generated by you, they remain safe on the client side.
You can see the full implementation of the solution in the live demo here and its code in the GitHub repository here. Clone the repository to follow along. The demo app is bootstrapped with wagmi’s Next.js example. This guide assumes you’re using Next.js, but the approach can be adapted for any other framework.
Step 1: Generate a Public & Private Key Pair
Start by generating a public & private key pair using OpenSSL.
Generate a Private Key:
openssl genpkey -algorithm RSA -out private_key.pemGenerate the Public Key:
openssl rsa -pubout -in private_key.pem -out public_key.pemThis will create two files: public_key.pem and private_key.pem. The private key must be kept secure and never shared outside your team. The generated keys should resemble the following mocked examples:
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyQ2v0e7xWt9RsYT0GpPQ
fg36YmPGlzSh50bHT2lmgKcIqk3C9GRl4gImJb75Lo5P9sMGB1PzV6lhScjqA2BG
5KXhN+NwCsQZ1YQ9yPT9KDhcdzlx35xLft2kctAjbhT/6eXXF4M9yOmClA0mRsUN
LbCx5sSRxgD7cOFAXwrWc7yqPpETVqIN2jcFyErwE5zznAJIb3hwxZ6sAkwlP3mS
MYgOl+oSk+N+lvO7vAoeSy9g1VR0hI9LbqOGGyWRywo7nAe7Vjl5lYxOYV9gFG0v
-----END PUBLIC KEY----------BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAzQ8X7hY9sTZ8FHt9LbKyT9V1MQZXjz9U0GrHWBoHGeUIAdCn
ZfbCQ3dKCV5xtHiyXkrjUj9DZ8OAjHZsT9MFlBDnL2z0TB7hr4Vc2V1BLd+2oHeM
KslHXOXTdRmfR9poXq6pHwgTR5x8EJNtvpReE8zQlwRmfHlZXcPmZpbkE+LdToxf
0HHeIt4OB+CrwDs6CQPCPMhCVAvwZLlv2t612KhbZlmB1UjbBhzjlg0oq9GpW4A8
3aodC4DAs6jmlPZIiJ98kHrL8gWnhCRNrfCPeJUCgYBSM+/M5hvZy43bFuSREzPN
zywm5ys7kjqfVmIm5+7cyhDOyNCNl0Pf5Q1x+ItmMki9PVuqGjdB5mFehRkcQlBo
46U1cQKBgQCJvEn1LYChLQf0osZY0kswjCZZb2DypFssAe4PrvVoHzX7Zni+3uGV
VvmbSClYy2sAHC3GYgiIpmxrPaNPf58AX8Y1UJfiPV3JSuzdTVMgTLFgSkMnD5HQ
so5XYTUSrPf3cG4fB1TlyUwUYFbZ+FcNZ4nmUp8BLFRH0VSaYN/hgw==
-----END RSA PRIVATE KEY-----Convert the Private Key to PKCS8 Format
Now, convert the private key to PKCS8 format and store it in an environment variable to sign tokens. Use the following command:
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private_key.pem -out privatekey-pkcs8.pemWe will store the contents of privatekey-pkcs8.pem in an environment variable called ALCHEMY_PRIVATE_KEY_PKCS8 to sign JWTs in our Next.js app.
Step 2: Set Public Key in Provider Dashboard
Navigate to “Apps” in your Alchemy dashboard, find your app, and click “Import Public Key”. A modal should pop up — enter a name for your public key and paste the key from the file.
After it’s set up, you’ll see your KEY ID — note this down, as you’ll need it later.
Step 3: Utility to Create New JWTs
Install the jose package before setting up the API routes. Let’s use jose for the demo app because it is edge runtime compatible.
npm install joseFirst, let’s create a utility function in src/lib/generateJWT.ts, to create our short-lived JWTs:
import { SignJWT, importPKCS8 } from "jose";
export async function generateJWT() {
if (!process.env.ALCHEMY_PRIVATE_KEY_PKCS8 || !process.env.ALCHEMY_KEY_ID) {
throw new Error(
"Missing required environment variables for JWT generation."
);
}
const privateKeyPEM = process.env.ALCHEMY_PRIVATE_KEY_PKCS8;
const privateKey = await importPKCS8(privateKeyPEM, "RS256");
const token = await new SignJWT({})
.setProtectedHeader({ alg: "RS256", kid: process.env.ALCHEMY_KEY_ID })
.setExpirationTime("5m") // JWT expires in 5 minutes
.sign(privateKey);
return token;
}Step 4: Generate Initial JWT
Then we’ll generate the initial JWT in layout.tsx which is a React server component. This component only renders on the server:
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const jwt = await generateJWT();
return (
<html lang="en">
<body>
<Providers initialJwt={jwt}>{children}</Providers>
</body>
</html>
);
}Step 5: Issue Fresh JWTs
Create a new API route that generates and returns a fresh JWT. Since each JWT is short-lived and expires after 5 minutes, our app will require a new token periodically. Below is an example of what the src/app/api/get-jwt/route.ts file should contain:
import { NextResponse } from "next/server";
import { generateJWT } from "@/app/lib/generate-jwt";
export async function POST(req: Request) {
try {
const nodeProviderJwt = await generateJWT();
const response = NextResponse.json({ nodeProviderJwt });
return response;
} catch (error: any) {
console.error(error.message);
return NextResponse.json({ error: error.message }, { status: 403 });
}
}Here’s the generateJWT:
import { SignJWT, importPKCS8 } from "jose";
export async function generateJWT() {
if (!process.env.ALCHEMY_PRIVATE_KEY_PKCS8 || !process.env.ALCHEMY_KEY_ID) {
throw new Error(
"Missing required environment variables for JWT generation."
);
}
const privateKeyPEM = process.env.ALCHEMY_PRIVATE_KEY_PKCS8;
const privateKey = await importPKCS8(privateKeyPEM, "RS256");
const token = await new SignJWT({})
.setProtectedHeader({ alg: "RS256", kid: process.env.ALCHEMY_KEY_ID })
.setExpirationTime("5m")
.sign(privateKey);
return token;
}Step 6: Client Side Setup
Our initial short-lived JWT expires in 5 minutes, so we’ll set an interval to fetch a new JWT every 4 minutes in providers.tsx. First, let's create a React hook called useToken to handle this:
import { useQuery } from "@tanstack/react-query";
import { useEffect, useState } from "react";
const FETCH_INTERVAL = 4 * 60 * 1000; // 4 minutes
async function queryFn() {
const response = await fetch("/api/get-jwt", {
method: "POST",
cache: "no-store",
});
const data = await response.json();
if (!response.ok) {
throw new Error(`❌ Failed to refresh token: ${response.statusText}`);
} else {
return data;
}
}
export default function useToken() {
const [isEnabled, setIsEnabled] = useState(false);
useEffect(() => {
const timeoutId = setTimeout(() => {
setIsEnabled(true);
}, FETCH_INTERVAL);
return () => clearTimeout(timeoutId); // Cleanup the timer on unmount
}, []);
const { data } = useQuery({
queryFn,
enabled: isEnabled,
queryKey: ["get-jwt"],
refetchInterval: FETCH_INTERVAL,
refetchIntervalInBackground: true,
});
useEffect(() => {
if (data?.nodeProviderJwt) {
localStorage.setItem("node-provider-jwt", data.nodeProviderJwt);
}
}, [data]);
}Then use it in providers.tsx:
"use client";
import React, { ReactNode, useState } from "react";
import { WagmiProvider } from "wagmi";
import { getWagmiConfig } from "./config";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export function Providers(props: { children: ReactNode; initialJwt: string }) {
const [config] = useState(() => {
return getWagmiConfig(props.initialJwt);
});
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
{props.children}
</QueryClientProvider>
</WagmiProvider>
);
}Now, we can securely fetch a new JWTs as long as the user keeps the app open.
Step 8: Set Up JWT Handling
Now that we have valid short-lived JWTs in our app and a secure way to refresh them, we’ll use these JWTs to securely access the Node provider by passing them directly to the provider. First, define the getConfig function to configure wagmi with your chains, and connectors, passing the JWT in the Authorization header the the http transport:
import { mainnet } from "wagmi/chains";
import { http, createConfig } from "wagmi";
import { metaMask, walletConnect, coinbaseWallet } from "wagmi/connectors";
const connectors = [
metaMask(),
coinbaseWallet(),
walletConnect({ projectId: process.env.NEXT_PUBLIC_WC_PROJECT_ID! }),
];
export const getWagmiConfig = (initialJwt: string) => {
const config = createConfig({
chains: [mainnet, base],
connectors,
transports: {
[mainnet.id]: http("https://eth-mainnet.g.alchemy.com/v2", {
onFetchRequest: (_, init) => {
return {
...init,
headers: {
...init.headers,
Authorization: `Bearer ${initialJwt}`,
},
};
},
})
},
});
return config;
};Conclusion
By leveraging short-lived JWTs, you can protect your node provider URLs. Here’s a summary of the key components involved in this approach:
- JWT Generation: The backend generates a JWT when the dApp initializes, with a short expiry time (e.g., 5 minutes), and includes it in the server-rendered content. The JWT is signed using your private key.
- API Requests: The JWT is passed via the
Authorizationheader, and the node provider verifies its authenticity by checking the signature against the public key submitted through their dashboard, confirming it was signed by your backend with the private key. - Scheduled Refresh: The frontend automatically refreshes the JWT one minute before it expires (i.e., every 4 minutes), keeping the dApp active as long as it’s in use.
To explore the code and see the solution in action, check out the live demo here and the GitHub repository here.
