NFT-Gated Content System Development
Typical mistake when implementing NFT-gated access — check ownership only on frontend. User connects wallet, JS does ownerOf(tokenId), gets address, compares with wallet account — that's it, access granted. Problem: trivially bypass this from DevTools. All gating must happen on backend; frontend only initiates flow.
Proper Ownership Verification
Message Signature Scheme (Sign-In With Ethereum)
Standard EIP-4361 (SIWE) is the right path. User signs standardized message with private key, backend verifies signature and checks ownership contract.
Scheme:
- Frontend requests nonce from backend for address (replay attack protection)
- Forms SIWE message — standard text with domain, address, nonce, timestamp, expiry
- User signs via wallet (
personal_sign) - Backend verifies signature: recovers address from signature via
ecrecover, checks nonce, checks timestamp, checksownerOf/balanceOfon contract
// Backend verification (Node.js)
import { SiweMessage } from "siwe"
import { createPublicClient, http } from "viem"
async function verifyNFTAccess(message: string, signature: string, contractAddress: string) {
const siweMessage = new SiweMessage(message)
const { success, data } = await siweMessage.verify({ signature })
if (!success) throw new Error("Invalid signature")
if (data.nonce !== await getNonce(data.address)) throw new Error("Invalid nonce")
if (new Date(data.expirationTime!) < new Date()) throw new Error("Expired")
// Check NFT ownership on-chain
const client = createPublicClient({ chain: mainnet, transport: http(RPC_URL) })
const balance = await client.readContract({
address: contractAddress,
abi: ERC721_ABI,
functionName: "balanceOf",
args: [data.address as `0x${string}`]
})
if (balance === 0n) throw new Error("No NFT found")
// Issue JWT session token
return issueJWT(data.address)
}
After successful verification — JWT token with short TTL (e.g., 24 hours). Repeated ownership verification on every request not needed — check JWT, recheck ownership on token refresh.
Granular Access: Specific Token vs. Any from Collection
Two modes:
Collection-level gating: any collection token holder gets access. Check balanceOf(address) > 0. Fast, cheap on RPC calls.
Token-specific gating: access only for specific tokenId holder (e.g., NFT ticket for event). Check ownerOf(tokenId) == address. Need to store tokenId → resource mapping.
Trait-based gating: access only for NFTs with specific attributes (rare traits, certain level). Either on-chain attribute recording or verified mapping with IPFS metadata. Most complex — need to pre-index collection metadata.
ERC-1155: Multi-token Gating
ERC-1155 opens more flexible models. balanceOf(address, tokenId) returns specific token ID count. Can build tier-based access:
- tokenId 1 = basic access
- tokenId 2 = premium access
- tokenId 3 = VIP access
Or: require 10 tokens of tokenId 1 for specific action (gamification). More complex logic, but verified same way with single balanceOf call.
Infrastructure for Scalable Gating
Caching Ownership Data
With active audience of 10k+ users, checking ownerOf on every request stresses RPC. Solution: cache with TTL.
User authenticates → check ownership → cache result (TTL: 5 min) → issue JWT (TTL: 24h)
Every API request: check JWT (locally, fast)
JWT refresh: check cache, on miss go to RPC
On NFT transfer cache stales in 5 minutes — acceptable for most scenarios. For immediate reaction on transfer — subscribe to Transfer events via WebSocket and invalidate cache.
Monitoring Transfer Events for Access Revocation
Selling NFT should immediately revoke seller's access — critical for paid communities. Subscribe to Transfer event via Alchemy/Infura WebSocket:
const filter = {
address: NFT_CONTRACT,
topics: [
ethers.id("Transfer(address,address,uint256)"),
null, // from: any
null // to: any
]
}
provider.on(filter, (log) => {
const [from, to, tokenId] = parseTransferEvent(log)
revokeAccess(from) // invalidate session for previous owner
grantAccess(to) // pre-cache for new owner
})
Multichain Gating
Collection may be on Ethereum, users want to pay gas on Polygon — common for bridged collections. Multichain gating: check ownership on multiple chains, one match sufficient.
Moralis or Alchemy NFT API simplify — single call with multiple chains:
const nfts = await alchemy.nft.getNftsForOwner(address, {
contractAddresses: [CONTRACT_ETH, CONTRACT_POLYGON],
})
const hasAccess = nfts.ownedNfts.length > 0
Content: What and How to Protect
Static content (PDF, video, images): files in S3/R2 with private access. Backend generates signed URL with short TTL (15-60 minutes) only for verified holders. Direct file links don't leak.
Dynamic content (pages, API data): middleware checks JWT token before every response. Without valid JWT — 401.
Streaming content (live video): auth via token in query param or header on stream connect. After connect — periodic revalidation every N minutes.
Development Timeline
SIWE integration with basic collection-level gating and JWT sessions — 2 days. Adding Transfer event watcher and automatic access revocation — 1 day more. Trait-based gating with metadata indexing — 1-2 days depending on collection size. Multichain support — 1-2 days more.
Full system with monitoring, caching, and multichain verification — 4-5 working days.







