Sitemap

🔐 Protecting Node Provider URLs for Frontend dApps

7 min readOct 21, 2024
Press enter or click to view image in full size
Image
Secure your Alchemy, QuickNode, and Infura endpoints

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.

Press enter or click to view image in full size
Image
😱 How do I protect my provider API in my React app? 😱

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.pem

Generate the Public Key:

openssl rsa -pubout -in private_key.pem -out public_key.pem

This 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.pem

We 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.

Press enter or click to view image in full sizeImage
Press enter or click to view image in full sizeImage
Import your public key

After it’s set up, you’ll see your KEY ID — note this down, as you’ll need it later.

Press enter or click to view image in full size
Image
Note your Key ID, we’ll use it in our code

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 jose

First, 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:

  1. 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.
  2. API Requests: The JWT is passed via the 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.
  3. 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.

--

--

henryzhu.eth
henryzhu.eth

Written by henryzhu.eth

@0xproject • prev, design systems @zendesk • eng @shipt

Responses (1)