Token-Gated Access System Development
Token-gating—restricting access to content or features based on owning specific tokens. Technically solvable in 20 minutes with ready solutions like Lit Protocol or Guild.xyz. But in 9 of 10 real cases, you need custom implementation: non-standard conditions, integration with existing auth, multi-chain checks, performance requirements.
Architectural Variants
Client-side (bad)—check balance in browser, show/hide content. Content is already in DOM; anyone can see it via DevTools. Only works for soft gates (nice-to-have, non-critical content).
Server-side (correct)—server checks token ownership before serving content. Client only gets what they have access to.
JWT with blockchain claims (optimal)—on SIWE login, check tokens, write permissions to JWT. Subsequent requests only need JWT verification, no blockchain calls.
SIWE + Token Check at Login
// After successful SIWE verification
async function createSessionToken(address: string): Promise<string> {
const [nftBalance, tokenBalance, ensName] = await Promise.all([
checkNFTOwnership(address),
checkTokenBalance(address),
resolveENS(address),
]);
const roles: string[] = [];
if (nftBalance > 0) roles.push("nft_holder");
if (tokenBalance >= parseEther("100")) roles.push("token_holder");
if (ensName) roles.push("ens_user");
return jwt.sign(
{
sub: address,
roles,
// Expires in 24 hours—balance could change
exp: Math.floor(Date.now() / 1000) + 86400,
},
process.env.JWT_SECRET!
);
}
Important: JWT expiry. Tokens are bought and sold. A week-long session means someone who sold their NFT keeps access for 7 days. For high stakes—short expiry (1-4 hours) + refresh via re-verification.
On-Chain Checks via viem/wagmi
import { createPublicClient, http, erc721Abi, erc20Abi } from "viem";
import { mainnet } from "viem/chains";
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.ETH_RPC),
});
// Check ERC-721 ownership
async function checkNFTOwnership(
address: `0x${string}`,
collection: `0x${string}`
): Promise<number> {
const balance = await client.readContract({
address: collection,
abi: erc721Abi,
functionName: "balanceOf",
args: [address],
});
return Number(balance);
}
// Check specific tokenId
async function checkSpecificToken(
address: `0x${string}`,
collection: `0x${string}`,
tokenId: bigint
): Promise<boolean> {
const owner = await client.readContract({
address: collection,
abi: erc721Abi,
functionName: "ownerOf",
args: [tokenId],
});
return owner.toLowerCase() === address.toLowerCase();
}
Multi-Chain Gates
User might hold NFT on Ethereum, tokens on Polygon. Check in parallel:
const [ethBalance, polyBalance, arbBalance] = await Promise.all([
checkBalanceOnChain(address, "ethereum"),
checkBalanceOnChain(address, "polygon"),
checkBalanceOnChain(address, "arbitrum"),
]);
const hasAccess = ethBalance > 0 || polyBalance > 0 || arbBalance > 0;
Cache results in Redis with 5-15 minute TTL—constant node calls are expensive and slow.
Complex Access Conditions
Real-world gates are rarely simple. Typical cases:
// AND: need both NFT and sufficient token balance
const hasAccess = nftBalance > 0 && tokenBalance >= minTokenThreshold;
// OR: any condition sufficient
const hasPremium = nftBalance > 0 || tokenBalance >= premiumThreshold || hasStaked;
// Trait-based: specific NFT attributes
// Requires off-chain metadata or on-chain traits
const isGoldMember = await checkTraitOwnership(address, "tier", "gold");
// Snapshot-based: balance at specific block (for airdrop eligibility)
const snapshotBalance = await client.readContract({
...tokenContract,
functionName: "balanceOfAt",
args: [address, snapshotBlockNumber],
blockNumber: snapshotBlockNumber,
});
Middleware for API Routes
// Express/Fastify middleware
async function tokenGateMiddleware(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "Unauthorized" });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET) as JWTPayload;
// Check roles from JWT (no blockchain call)
if (!payload.roles.includes("nft_holder")) {
return res.status(403).json({ error: "NFT required" });
}
req.user = payload;
next();
} catch {
return res.status(401).json({ error: "Invalid token" });
}
}
// Apply to protected routes
app.get("/api/premium/content", tokenGateMiddleware, getPremiumContent);
Frontend: UX for Access Denial
Nothing worse than user not understanding why they're blocked or what to do:
function GatedContent({ requiredNFT, children }) {
const { address } = useAccount();
const { data: balance } = useReadContract({
address: requiredNFT,
abi: erc721Abi,
functionName: "balanceOf",
args: [address],
query: { enabled: !!address },
});
if (!address) {
return <ConnectWalletPrompt />;
}
if (!balance || balance === 0n) {
return (
<AccessDenied
message="NFT from XYZ collection required to access"
ctaText="Buy on OpenSea"
ctaUrl={`https://opensea.io/collection/xyz`}
currentFloorPrice={useFloorPrice(requiredNFT)}
/>
);
}
return children;
}
Delegation via delegate.cash
User doesn't want to connect their cold wallet with valuable NFTs. delegate.cash (EIP-5639) lets the owner delegate rights to a hot wallet:
// Check not just direct ownership, but delegation
const { data: isDelegated } = useReadContract({
address: DELEGATE_REGISTRY,
abi: delegateRegistryAbi,
functionName: "checkDelegateForContract",
args: [hotWallet, coldWallet, nftCollection],
});
const hasAccess = directBalance > 0 || isDelegated;
For production gates with valuable content—delegate.cash support is mandatory.







