Implementing Token-Gated Content (Access by Token Ownership) on Website
Token Gating is a mechanism where access to website content or functions is restricted to users who own specific NFTs or tokens in their wallet. Balance verification happens through RPC calls to the blockchain.
Token Gating Logic
User connects wallet
│
Site checks token balance via RPC
│
[Has token?]
│ │
Yes No
│ │
[Access [Offer to buy
granted] token / notify]
Checking ERC-20 Balance
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
);
Checking NFT (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) {
// Check ownership of specific token
const owner = await client.readContract({
address: nftContract,
abi: ERC721_ABI,
functionName: 'ownerOf',
args: [specificTokenId]
});
return owner.toLowerCase() === walletAddress.toLowerCase();
}
// Check if user owns at least one token
const balance = await client.readContract({
address: nftContract,
abi: ERC721_ABI,
functionName: 'balanceOf',
args: [walletAddress as `0x${string}`]
});
return balance > 0n;
}
Middleware for Route Protection
// Server check on every protected request
async function tokenGateMiddleware(req, res, next) {
const user = req.user; // JWT with walletAddress
if (!user?.walletAddress) {
return res.status(401).json({ error: 'Wallet not connected' });
}
// Cache verification result (5 minutes) — RPC calls are expensive
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();
}
// Apply to routes
app.get('/premium/content', authenticate, tokenGateMiddleware, getContent);
app.get('/members-only/*', authenticate, tokenGateMiddleware, handleMemberRoute);
Morally Sound Approach: Caching with Invalidation
Token balance can change (user sold NFT). Invalidate cache through blockchain events:
// Listener for Transfer events from NFT
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) {
// Invalidate cache for sender and recipient
await redis.del(`token_gate:${log.args.from}:${TOKEN_CONTRACT}`);
await redis.del(`token_gate:${log.args.to}:${TOKEN_CONTRACT}`);
}
}
});
Timeline
Token Gating with ERC-20/ERC-721 checking, caching, and middleware — 3–5 days.







