Imagine you have a site with premium analytics, video courses, or a closed community. You want to give access only to holders of your NFT collection or a certain amount of ERC-20 tokens. But how to do it reliably, without data leaks, and with minimal RPC costs? We implement server-side balance checking with caching and blockchain event invalidation — a ready-to-use solution in 3–5 days.
Why Server-Side Checking Is Mandatory
Token-gating isn't just a frontend balance check. Client-side verification is easily bypassed through DevTools. Server-side validation is required, but each RPC call costs money (about $0.0001–$0.01 on Ethereum Mainnet). Without caching, at 1000 requests per day you'd spend a noticeable amount, and at peak loads — hundreds of dollars per month. Moreover, the balance can change (user sold an NFT), so the cache must be invalidated.
Another problem is supporting different standards and networks. ERC-20 and ERC-721 require different ABIs, and RPC endpoints for Polygon, Arbitrum, and other L2s have varying costs and latencies. We use the viem library — it unifies calls and supports dozens of networks. According to the viem documentation, it provides a lightweight, efficient interface for Ethereum interactions.
How Token Checks Work
After connecting a wallet (MetaMask, WalletConnect), the server receives the address from a JWT token. Middleware checks the cache (Redis); if missing, it makes an RPC call to the contract. The result is cached for 5 minutes, while simultaneously subscribing to Transfer events for automatic invalidation. This approach balances security and cost, delivering reliable performance.
ERC-20 Balance Check
import { createPublicClient, http, parseAbi } from 'viem';
import { mainnet } from 'viem/chains';
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.ETHEREUM_RPC_URL)
});
const ERC20_ABI = parseAbi([
'function balanceOf(address owner) view returns (uint256)',
'function decimals() view returns (uint8)'
]);
async function checkERC20Balance(
walletAddress: string,
tokenContractAddress: `0x${string}`,
minBalance: bigint
): Promise<boolean> {
const balance = await client.readContract({
address: tokenContractAddress,
abi: ERC20_ABI,
functionName: 'balanceOf',
args: [walletAddress as `0x${string}`]
});
return balance >= minBalance;
}
// Example: need >= 100 EXAMPLE tokens
const hasAccess = await checkERC20Balance(
userWalletAddress,
'0xYourTokenContract',
100n * 10n ** 18n // 100 tokens with 18 decimals
);
NFT Check (ERC-721)
const ERC721_ABI = parseAbi([
'function balanceOf(address owner) view returns (uint256)',
'function ownerOf(uint256 tokenId) view returns (address)'
]);
async function checkNFTOwnership(
walletAddress: string,
nftContract: `0x${string}`,
specificTokenId?: bigint
): Promise<boolean> {
if (specificTokenId !== undefined) {
const owner = await client.readContract({
address: nftContract,
abi: ERC721_ABI,
functionName: 'ownerOf',
args: [specificTokenId]
});
return owner.toLowerCase() === walletAddress.toLowerCase();
}
const balance = await client.readContract({
address: nftContract,
abi: ERC721_ABI,
functionName: 'balanceOf',
args: [walletAddress as `0x${string}`]
});
return balance > 0n;
}
Middleware to Protect Routes
async function tokenGateMiddleware(req, res, next) {
const user = req.user;
if (!user?.walletAddress) {
return res.status(401).json({ error: 'Wallet not connected' });
}
const cacheKey = `token_gate:${user.walletAddress}:${TOKEN_CONTRACT}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
if (cached === '0') return res.status(403).json({ error: 'Token required' });
return next();
}
const hasToken = await checkNFTOwnership(user.walletAddress, TOKEN_CONTRACT);
await redis.setex(cacheKey, 300, hasToken ? '1' : '0');
if (!hasToken) {
return res.status(403).json({
error: 'Access denied',
requiredToken: TOKEN_CONTRACT,
purchaseUrl: 'https://opensea.io/collection/your-nft'
});
}
next();
}
app.get('/premium/content', authenticate, tokenGateMiddleware, getContent);
app.get('/members-only/*', authenticate, tokenGateMiddleware, handleMemberRoute);
Case Study: 24x Speed Improvement
For an NFT community with 10,000 holders, we initially made direct RPC calls on every request — LCP grew to 4 seconds, TTFB to 1.2 s. After implementing Redis cache with a 5-minute TTL, TTFB dropped to 50 ms for cached users — that's a 24x improvement in time to first byte. Invalidation via Transfer events ensures that if a holder sells their NFT, access is revoked within 15 seconds. Overall, RPC costs decreased by 10x (saving about $500 per month), and users stopped complaining about lag.
How Caching Reduces Costs
Without caching, each premium content request would trigger an RPC call to the blockchain. This is not only expensive (about $0.01 per call on Ethereum Mainnet) but also slow: Ethereum responses can take 2–5 seconds. Cache in Redis with a 5-minute TTL solves both problems. And invalidation via Transfer events ensures access is revoked immediately after token sale.
How to Implement Cache Invalidation
const ERC721_TRANSFER_ABI = parseAbi([
'event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)'
]);
client.watchContractEvent({
address: TOKEN_CONTRACT,
abi: ERC721_TRANSFER_ABI,
eventName: 'Transfer',
onLogs: async (logs) => {
for (const log of logs) {
await redis.del(`token_gate:${log.args.from}:${TOKEN_CONTRACT}`);
await redis.del(`token_gate:${log.args.to}:${TOKEN_CONTRACT}`);
}
}
});
Token Types and Caching Comparison
| Parameter | ERC-20 | ERC-721 |
|---|---|---|
| Token type | Fungible | Non-fungible |
| Check | balanceOf + min | balanceOf or ownerOf |
| Example | 100 USDT → access | Bored Ape → VIP |
| Cache type | Latency | Cost | Invalidation |
|---|---|---|---|
| No cache | 2–5 sec | High (>$100/mo) | N/A |
| Redis + TTL | <50 ms | Low ($20/mo) | 5 minutes |
| Redis + events | <50 ms | Low ($20/mo) | 15 seconds |
How Redis caching reduces latency
Redis stores responses in memory, eliminating RPC round-trips that take 2–5 seconds. With a 5-minute TTL, 95% of requests hit the cache, reducing average latency to under 50 ms — a 50x improvement over direct RPC calls.What's Included
- Architecture diagram for caching and network selection.
- Middleware for Express/NestJS with JWT and Redis integration.
- Subscription to Transfer events with automatic invalidation.
- Frontend component for wallet connection (MetaMask, WalletConnect).
- API documentation and deployment instructions.
- Load testing and post-release monitoring (2 weeks).
How to Set Up Token-Gating: Step-by-Step
- Prepare RPC endpoint and token contracts.
- Install Redis and dependencies (viem, ethers).
- Implement middleware as per examples above.
- Configure webhooks or listen for Transfer events.
- Test scenarios: connection, ownership change, RPC errors.
- Deploy on a server with monitoring.
Common Mistakes
- Client-side only check — easily bypassed. Always perform server-side validation for access by NFT.
- No caching — high RPC costs and slow loading. Our RPC call caching reduces costs by 10x.
- Ignoring invalidation — access remains after token sale. Implement cache invalidation by events.
- Unhandled RPC errors (rate limit, timeout) — user sees 'access denied' even when holding the token.
Timeline and Cost
Token Gating with ERC-20/ERC-721 checks, caching, and middleware — 3–5 days. If a custom contract and multi-network integration are needed — up to 2 weeks. Cost is estimated individually; our experience includes 5+ years in Ethereum and Polygon, with 20+ gating solutions implemented. We guarantee reliable performance and provide a certificate upon completion. Get a consultation on token-gating integration for your project.







