Skip to main content

🎥 How to Create a Secure NFT Gated Website

Created on
Updated on
Nov 26, 2024

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
Subscribe to our YouTube channel for more videos!

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.

Share this guide