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 asthis one,this one,this one,this one,this one, andthis one, most of them fall short of providing effective solution.
Frontend dApps face aunique challenge that backend services don’t:making node RPC calls directly from the browser. When instantiating anEthers.js provider or awagmi 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:
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.
An effective solution is to use short-livedJSON 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 thelive demohereand its code in the GitHub repositoryhere.Clone therepository to follow along.The demo app is bootstrapped withwagmi’sNext.js example.This guide assumes you’re usingNext.js, but the approach can be adapted for any other framework.
Start by generating a public & private key pair usingOpenSSL.
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 andprivate_key.pem. The private key must be kept secure and never shared outside your team. The generated keys should resemble the followingmocked 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 ofprivatekey-pkcs8.pem in an environment variable calledALCHEMY_PRIVATE_KEY_PKCS8 tosign JWTs in our Next.js app.
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 yourKEY ID — note this down, as you’ll need it later.
Install thejose package before setting up the API routes. Let’s usejose for the demo app because it isedge runtime compatible.
npm install joseFirst, let’s create a utility function insrc/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;
}Then we’ll generate the initial JWT inlayout.tsx which is a Reactserver 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>
);
}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 thesrc/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 thegenerateJWT:
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;
}Our initial short-lived JWT expires in 5 minutes, so we’ll set an interval to fetch a new JWT every 4 minutes inproviders.tsx. First, let's create a React hook calleduseToken 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 inproviders.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.
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 thegetConfig function to configure wagmi with your chains, and connectors, passing the JWT in theAuthorization header the thehttp 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;
};By leveraging short-lived JWTs, you can protect your node provider URLs. Here’s a summary of the key components involved in this approach:
Authorization header, 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.To explore the code and see the solution in action, check out the live demohere and the GitHub repositoryhere.
@0xproject • prev, design systems @zendesk • eng @shipt