14 min read
Overview
Now that NFTs are being used as membership cards, event tickets, and status symbols, sites are looking for ways to gate their content based on whether a user owns an NFT from a given collection. We've seen Spotify, Shopify, and more roll out this "token-gating" functionality and countless tutorials on how to implement it yourself. Almost all of these implementations lack protections against a simple hack in which, once a user passes the check, they send the NFT to another user who can then access the gated content.
In the video below, we demonstrate how to create a secure NFT Gated website that prevents double spends and token sharing by alerting in real time of any token transfers using QuickAlerts.
Video
An end-to-end video course explaining how to Build a secure NFT-gated website using JavaScript
Code
Following are the code examples shown in the video. Alternatively, you can access the full repo on GitHub or clone it to your local with:
git clone https://github.com/kshitijofficial/nftGating.git
api/destination.js
const axios = require('axios');
const headers = {
'accept': 'application/json',
'x-api-key': 'YOUR API KEY'
};
const data = {
name: 'My Destination',
to_url: 'https://7de9-103-171-168-142.ngrok-free.app/webhook',
webhook_type: 'POST',
service: 'webhook',
payload_type: 5
};
axios.post('https://api.quicknode.com/quickalerts/rest/v1/destinations', data, { headers })
.then(response => console.log("Response Data",response.data))
.catch(error => console.log('error', error));
api/notification.js
const axios = require('axios');
const headers = {
'accept': 'application/json',
'x-api-key': 'YOUR API KEY'
};
const data = {
name: 'NFT Transfer',
expression: 'KHR4X2xvZ3NfdG9waWMxID1+ICdhMmM4MzQ1YjMwQjRjMjZlMTE4RjMwNzI2OWRFOTY5MTNEYmRCNGJDJykgJiYgDQoodHhfbG9nc19hZGRyZXNzID09ICcweEQ2MTg1ODE0MDIyMjZjOTJiMTRDOWY0ODcwNzk5YjMwMDBBQzRDNzcnKSAmJiANCih0eF9sb2dzX3RvcGljMCA9PSAnMHhkZGYyNTJhZDFiZTJjODliNjljMmIwNjhmYzM3OGRhYTk1MmJhN2YxNjNjNGExMTYyOGY1NWE0ZGY1MjNiM2VmJyk=',
network: 'ethereum-sepolia',
destinationIds: ['fa375e89-8c4c-4260-8c0b-91cb20cd4da9']
};
axios.post('https://api.quicknode.com/quickalerts/rest/v1/notifications', data, { headers })
.then(response => console.log(response.data))
.catch(error => console.log('error', error));
api/server.js
const express = require('express');
const cors = require('cors')
const {Web3} = require('web3');
const ABI =require('./ABI.json')
const socketIO = require('socket.io')
const app = express();
app.use(cors())
app.use(express.json());
const web3 =new Web3('YOUR API KEY')
const contractAddress = 'YOUR CONTRACT ADDRESS';
const contract = new web3.eth.Contract(ABI,contractAddress);
// console.log(contract)
const fetchNFTs = async(account)=>{
try{
const nftBalance = await contract.methods.balanceOf(account).call();
return {userNFTs:Number(nftBalance)}
}catch(error){
console.log('Error fetching NFTs',error);
}
}
app.post('/members',async(req,res)=>{
try{
const account = req.body.from;
console.log(account)
const numNFTs = await fetchNFTs(account)
if(numNFTs.userNFTs>0){
res.status(200).json({status:200,numNFTs})
}else{
res.status(404).json({status:404,message:"You don't own any nft",numNFTs});
}
}catch(error){
res.status(500).json({status:500,message:"Internal Server Error"});
}
})
app.post('/webhook',async(req,res)=>{
try{
const account = req.body[0].from;
const numNFTs = await fetchNFTs(account);
io.emit('nftsUpdated',{userNFTs:numNFTs.userNFTs})
res.status(200).json({status:200,message:"Webhook Triggered"})
}catch(error){
console.error(error)
}
})
const PORT=3000;
const server = app.listen(PORT,()=>{
console.log(`Sever running at ${PORT}`)
})
const io = socketIO(server);
io.on('connection',()=>{
console.log("Connected")
})
api/ABI.json
[
{
"inputs": [],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "approved",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "Approval",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"indexed": false,
"internalType": "bool",
"name": "approved",
"type": "bool"
}
],
"name": "ApprovalForAll",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "_fromTokenId",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "_toTokenId",
"type": "uint256"
}
],
"name": "BatchMetadataUpdate",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "_tokenId",
"type": "uint256"
}
],
"name": "MetadataUpdate",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "from",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "Transfer",
"type": "event"
},
{
"inputs": [
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "approve",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "player",
"type": "address"
},
{
"internalType": "string",
"name": "tokenURI",
"type": "string"
}
],
"name": "awardItem",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"name": "balanceOf",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "getApproved",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
},
{
"internalType": "address",
"name": "operator",
"type": "address"
}
],
"name": "isApprovedForAll",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "name",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "ownerOf",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "safeTransferFrom",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "safeTransferFrom",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"internalType": "bool",
"name": "approved",
"type": "bool"
}
],
"name": "setApprovalForAll",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes4",
"name": "interfaceId",
"type": "bytes4"
}
],
"name": "supportsInterface",
"outputs": [
{
"internalType": "bool",
"name": "",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "symbol",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "tokenURI",
"outputs": [
{
"internalType": "string",
"name": "",
"type": "string"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "transferFrom",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
]
client/.eslintrc.cjs
/* eslint-env node */
module.exports = {
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
client/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
client/src/components/Home.css
.beautiful-sentence {
font-family: "Arial", sans-serif;
font-size: 18px;
color: #ff4081;
text-align: center;
animation: rainbow 5s infinite;
}
@keyframes rainbow {
0% { color: #ff4081; }
14% { color: #ff7f00; }
28% { color: #ffff00; }
42% { color: #00ff00; }
57% { color: #0000ff; }
71% { color: #4b0082; }
85% { color: #9400d3; }
100% { color: #ff4081; }
}
client/src/components/Home.jsx
import {useNavigate,useLocation} from "react-router-dom"
import './Home.css'
const Home=()=>{
const location = useLocation()
const navigateTo=useNavigate()
const revealMsg=async()=>{
try{
const account = location.state.address;
const res = await fetch(`http://localhost:3000/members`,{
method:"POST",
headers:{
"content-type":"application/json"
},
body:JSON.stringify({from:account})
})
const data = await res.json();
if(data.status===200){
navigateTo("/members")
}else{
window.alert("You currently do not hold any NFTs in collection w/ address 0xd618581402226c92b14c9f4870799b3000ac4c77")
}
}catch(error){
console.error(error)
}
}
return(
<>
<span className="beautiful-sentence">I have a secret message for holders of my NFT collection with <br></br>address 0xd618581402226c92b14c9f4870799b3000ac4c77</span>
<br></br>
<br></br>
<button onClick={revealMsg}>Reveal Message</button>
</>
)
}
export default Home;
client/src/components/Members.jsx
import {useState,useEffect} from "react";
import {useNavigate} from "react-router-dom"
import io from 'socket.io-client'
import welcomeImg from '../images/GM.png'
const Members=()=>{
const [socket,setSocket]=useState(null);
const navigateTo = useNavigate();
useEffect(()=>{
const socketInstance = io('http://localhost:3000');
setSocket(socketInstance);
return()=>{
socketInstance.disconnect()
}
},[])
useEffect(()=>{
if(socket){
socket.on('nftsUpdated',(data)=>{
if(data.userNFTs<1){
navigateTo('/')
alert("You've been logged out because you no longer hold any NFTs in collection with address 0xd618581402226c92b14c9f4870799b3000ac4c77")
}
})
}
},[socket])
return<>
<p>Thank you for being a holder of my NFT collection, here's your message:</p>
<img src={welcomeImg}></img>
</>
}
export default Members;
client/src/components/Wallet.jsx
import { useNavigate } from "react-router-dom";
const Wallet=()=>{
const navigateTo =useNavigate()
const connectWallet =async()=>{
try{
if(window.ethereum){
const accounts = await window.ethereum.request({method:'eth_requestAccounts'});
navigateTo("/home",{state:{address:accounts[0]}})
}else{
alert("Install Metamask")
}
}catch(error){
console.error(error)
}
}
return<><button onClick={connectWallet}>Connect Wallet</button></>
}
export default Wallet;
client/src/images/GM.png
We ❤️ Feedback!
Let us know if you have any feedback or requests for new topics. We'd love to hear from you.